summaryrefslogtreecommitdiffstats
path: root/comm/mail/components
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components')
-rw-r--r--comm/mail/components/AboutRedirector.jsm124
-rw-r--r--comm/mail/components/AppIdleManager.jsm45
-rw-r--r--comm/mail/components/MailComponents.manifest9
-rw-r--r--comm/mail/components/MailGlue.jsm1380
-rw-r--r--comm/mail/components/MessengerContentHandler.jsm793
-rw-r--r--comm/mail/components/StartupRecorder.jsm229
-rw-r--r--comm/mail/components/about-support/AboutSupportMac.jsm16
-rw-r--r--comm/mail/components/about-support/AboutSupportUnix.jsm137
-rw-r--r--comm/mail/components/about-support/AboutSupportWin32.jsm77
-rw-r--r--comm/mail/components/about-support/content/aboutSupport.js1729
-rw-r--r--comm/mail/components/about-support/content/aboutSupport.xhtml956
-rw-r--r--comm/mail/components/about-support/content/accounts.js339
-rw-r--r--comm/mail/components/about-support/content/calendars.js77
-rw-r--r--comm/mail/components/about-support/content/chat.js73
-rw-r--r--comm/mail/components/about-support/content/export.js288
-rw-r--r--comm/mail/components/about-support/content/libs.js24
-rw-r--r--comm/mail/components/about-support/jar.mn13
-rw-r--r--comm/mail/components/about-support/moz.build13
-rw-r--r--comm/mail/components/accountcreation/AccountConfig.jsm463
-rw-r--r--comm/mail/components/accountcreation/AccountCreationUtils.jsm717
-rw-r--r--comm/mail/components/accountcreation/ConfigVerifier.jsm386
-rw-r--r--comm/mail/components/accountcreation/CreateInBackend.jsm459
-rw-r--r--comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm676
-rw-r--r--comm/mail/components/accountcreation/FetchConfig.jsm299
-rw-r--r--comm/mail/components/accountcreation/FetchHTTP.jsm401
-rw-r--r--comm/mail/components/accountcreation/GuessConfig.jsm1317
-rw-r--r--comm/mail/components/accountcreation/Sanitizer.jsm249
-rw-r--r--comm/mail/components/accountcreation/content/accountHub.js277
-rw-r--r--comm/mail/components/accountcreation/content/accountSetup.js3023
-rw-r--r--comm/mail/components/accountcreation/content/accountSetup.xhtml1333
-rw-r--r--comm/mail/components/accountcreation/jar.mn12
-rw-r--r--comm/mail/components/accountcreation/moz.build23
-rw-r--r--comm/mail/components/accountcreation/readFromXML.jsm352
-rw-r--r--comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml158
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml21
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js76
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js319
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js266
-rw-r--r--comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini9
-rw-r--r--comm/mail/components/accountcreation/views/container.mjs50
-rw-r--r--comm/mail/components/accountcreation/views/email.mjs185
-rw-r--r--comm/mail/components/accountcreation/views/start.mjs163
-rw-r--r--comm/mail/components/activity/Activity.jsm322
-rw-r--r--comm/mail/components/activity/ActivityManager.jsm157
-rw-r--r--comm/mail/components/activity/ActivityManagerUI.jsm47
-rw-r--r--comm/mail/components/activity/components.conf38
-rw-r--r--comm/mail/components/activity/content/activity-widgets.js384
-rw-r--r--comm/mail/components/activity/content/activity.js239
-rw-r--r--comm/mail/components/activity/content/activity.xhtml61
-rw-r--r--comm/mail/components/activity/jar.mn8
-rw-r--r--comm/mail/components/activity/modules/activityModules.jsm33
-rw-r--r--comm/mail/components/activity/modules/alertHook.jsm101
-rw-r--r--comm/mail/components/activity/modules/autosync.jsm433
-rw-r--r--comm/mail/components/activity/modules/glodaIndexer.jsm251
-rw-r--r--comm/mail/components/activity/modules/moveCopy.jsm396
-rw-r--r--comm/mail/components/activity/modules/pop3Download.jsm154
-rw-r--r--comm/mail/components/activity/modules/sendLater.jsm298
-rw-r--r--comm/mail/components/activity/moz.build34
-rw-r--r--comm/mail/components/activity/nsIActivity.idl492
-rw-r--r--comm/mail/components/activity/nsIActivityManager.idl135
-rw-r--r--comm/mail/components/activity/nsIActivityManagerUI.idl50
-rw-r--r--comm/mail/components/addrbook/content/abCommon.js145
-rw-r--r--comm/mail/components/addrbook/content/abContactsPanel.js374
-rw-r--r--comm/mail/components/addrbook/content/abContactsPanel.xhtml234
-rw-r--r--comm/mail/components/addrbook/content/abEditListDialog.xhtml99
-rw-r--r--comm/mail/components/addrbook/content/abMailListDialog.xhtml116
-rw-r--r--comm/mail/components/addrbook/content/abSearchDialog.js408
-rw-r--r--comm/mail/components/addrbook/content/abSearchDialog.xhtml200
-rw-r--r--comm/mail/components/addrbook/content/abView-new.js577
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.js4445
-rw-r--r--comm/mail/components/addrbook/content/aboutAddressBook.xhtml460
-rw-r--r--comm/mail/components/addrbook/content/addressBookTab.js172
-rw-r--r--comm/mail/components/addrbook/content/menulist-addrbooks.js271
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/adr.mjs149
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/custom.mjs60
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/edit.mjs1094
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/email.mjs135
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/fn.mjs71
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs12
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/impp.mjs97
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/n.mjs186
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/nickname.mjs59
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/note.mjs82
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/org.mjs197
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/special-date.mjs269
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/tel.mjs83
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/tz.mjs86
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/url.mjs89
-rw-r--r--comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml398
-rw-r--r--comm/mail/components/addrbook/jar.mn35
-rw-r--r--comm/mail/components/addrbook/moz.build10
-rw-r--r--comm/mail/components/addrbook/test/browser/browser.ini37
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js664
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js143
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js245
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js138
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js470
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_contact_tree.js1261
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_directory_tree.js982
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_display_card.js1020
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_display_multiple.js468
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_drag_drop.js417
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_async.js363
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_card.js3517
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_edit_photo.js866
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_ldap_search.js180
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_mailing_lists.js474
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_open_actions.js157
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_search.js139
-rw-r--r--comm/mail/components/addrbook/test/browser/browser_telemetry.js59
-rw-r--r--comm/mail/components/addrbook/test/browser/data/addressbook.sjs47
-rw-r--r--comm/mail/components/addrbook/test/browser/data/addressbooks.sjs62
-rw-r--r--comm/mail/components/addrbook/test/browser/data/auth_headers.sjs26
-rw-r--r--comm/mail/components/addrbook/test/browser/data/dns.sjs48
-rw-r--r--comm/mail/components/addrbook/test/browser/data/photo1.jpgbin0 -> 36775 bytes
-rw-r--r--comm/mail/components/addrbook/test/browser/data/photo2.jpgbin0 -> 38826 bytes
-rw-r--r--comm/mail/components/addrbook/test/browser/data/principal.sjs38
-rw-r--r--comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs21
-rw-r--r--comm/mail/components/addrbook/test/browser/data/token.sjs36
-rw-r--r--comm/mail/components/addrbook/test/browser/head.js445
-rw-r--r--comm/mail/components/cloudfile/cloudFileAccounts.jsm215
-rw-r--r--comm/mail/components/cloudfile/content/selectDialog.js17
-rw-r--r--comm/mail/components/cloudfile/content/selectDialog.xhtml32
-rw-r--r--comm/mail/components/cloudfile/jar.mn7
-rw-r--r--comm/mail/components/cloudfile/moz.build14
-rw-r--r--comm/mail/components/cloudfile/test/browser/browser.ini13
-rw-r--r--comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js246
-rw-r--r--comm/mail/components/cloudfile/test/browser/files/green_eggs.txt1
-rw-r--r--comm/mail/components/cloudfile/test/browser/files/icon.svg7
-rw-r--r--comm/mail/components/cloudfile/test/browser/files/management.html10
-rw-r--r--comm/mail/components/cloudfile/test/browser/head.js48
-rw-r--r--comm/mail/components/components.conf76
-rw-r--r--comm/mail/components/compose/composer.js65
-rw-r--r--comm/mail/components/compose/content/ComposerCommands.js2261
-rw-r--r--comm/mail/components/compose/content/MsgComposeCommands.js11654
-rw-r--r--comm/mail/components/compose/content/addressingWidgetOverlay.js1336
-rw-r--r--comm/mail/components/compose/content/bigFileObserver.js368
-rw-r--r--comm/mail/components/compose/content/cloudAttachmentLinkManager.js758
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAEAttributes.js973
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js146
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js362
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js200
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js342
-rw-r--r--comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml243
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorPicker.js290
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml103
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorProps.js476
-rw-r--r--comm/mail/components/compose/content/dialogs/EdColorProps.xhtml211
-rw-r--r--comm/mail/components/compose/content/dialogs/EdConvertToTable.js325
-rw-r--r--comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml86
-rw-r--r--comm/mail/components/compose/content/dialogs/EdDialogCommon.js679
-rw-r--r--comm/mail/components/compose/content/dialogs/EdDictionary.js138
-rw-r--r--comm/mail/components/compose/content/dialogs/EdDictionary.xhtml88
-rw-r--r--comm/mail/components/compose/content/dialogs/EdHLineProps.js227
-rw-r--r--comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml131
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageDialog.js639
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js144
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageProps.js293
-rw-r--r--comm/mail/components/compose/content/dialogs/EdImageProps.xhtml454
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsSrc.js162
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml67
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertChars.js412
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml92
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertMath.js317
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml73
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTOC.js378
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml505
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTable.js258
-rw-r--r--comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml126
-rw-r--r--comm/mail/components/compose/content/dialogs/EdLinkProps.js323
-rw-r--r--comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml112
-rw-r--r--comm/mail/components/compose/content/dialogs/EdListProps.js455
-rw-r--r--comm/mail/components/compose/content/dialogs/EdListProps.xhtml101
-rw-r--r--comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js159
-rw-r--r--comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml67
-rw-r--r--comm/mail/components/compose/content/dialogs/EdReplace.js380
-rw-r--r--comm/mail/components/compose/content/dialogs/EdReplace.xhtml126
-rw-r--r--comm/mail/components/compose/content/dialogs/EdSpellCheck.js496
-rw-r--r--comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml209
-rw-r--r--comm/mail/components/compose/content/dialogs/EdTableProps.js1426
-rw-r--r--comm/mail/components/compose/content/dialogs/EdTableProps.xhtml472
-rw-r--r--comm/mail/components/compose/content/editFormatButtons.inc.xhtml282
-rw-r--r--comm/mail/components/compose/content/editor.js2392
-rw-r--r--comm/mail/components/compose/content/editorUtilities.js1015
-rw-r--r--comm/mail/components/compose/content/images/tag-anchor.gifbin0 -> 127 bytes
-rw-r--r--comm/mail/components/compose/content/messengercompose.xhtml2572
-rw-r--r--comm/mail/components/compose/jar.mn58
-rw-r--r--comm/mail/components/compose/moz.build8
-rw-r--r--comm/mail/components/compose/texzilla/TeXZilla.js339
-rw-r--r--comm/mail/components/customizableui/CustomizableUI.sys.mjs360
-rw-r--r--comm/mail/components/customizableui/PanelMultiView.sys.mjs1699
-rw-r--r--comm/mail/components/customizableui/content/customizeMode.inc.xhtml128
-rw-r--r--comm/mail/components/customizableui/content/jar.mn6
-rw-r--r--comm/mail/components/customizableui/content/moz.build7
-rw-r--r--comm/mail/components/customizableui/content/panelUI.inc.xhtml606
-rw-r--r--comm/mail/components/customizableui/content/panelUI.js882
-rw-r--r--comm/mail/components/customizableui/moz.build14
-rw-r--r--comm/mail/components/devtools/components.conf15
-rw-r--r--comm/mail/components/devtools/devtools-loader.jsm80
-rw-r--r--comm/mail/components/devtools/moz.build13
-rw-r--r--comm/mail/components/devtools/tb-root-actor.js104
-rw-r--r--comm/mail/components/downloads/content/aboutDownloads.js414
-rw-r--r--comm/mail/components/downloads/content/aboutDownloads.xhtml98
-rw-r--r--comm/mail/components/downloads/jar.mn7
-rw-r--r--comm/mail/components/downloads/moz.build5
-rw-r--r--comm/mail/components/enterprisepolicies/Policies.sys.mjs1758
-rw-r--r--comm/mail/components/enterprisepolicies/content/aboutPolicies.js410
-rw-r--r--comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml107
-rw-r--r--comm/mail/components/enterprisepolicies/content/policies-active.svg6
-rw-r--r--comm/mail/components/enterprisepolicies/content/policies-documentation.svg6
-rw-r--r--comm/mail/components/enterprisepolicies/content/policies-error.svg6
-rw-r--r--comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs111
-rw-r--r--comm/mail/components/enterprisepolicies/helpers/moz.build12
-rw-r--r--comm/mail/components/enterprisepolicies/jar.mn10
-rw-r--r--comm/mail/components/enterprisepolicies/moz.build23
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/configuration.json10
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/moz.build12
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/policies-schema.json634
-rw-r--r--comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs16
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser.ini32
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js179
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js92
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js41
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js104
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js87
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js323
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js90
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js49
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js21
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js147
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js120
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js261
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js73
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js183
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js104
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js27
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini15
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js109
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json5
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini13
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js55
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json5
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html23
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini12
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js9
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json5
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/head.js103
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpibin0 -> 305 bytes
-rw-r--r--comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpibin0 -> 297 bytes
-rw-r--r--comm/mail/components/enterprisepolicies/tests/moz.build16
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/head.js140
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js22
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js80
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js25
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js44
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js118
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js110
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js490
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js255
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js122
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js47
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js21
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js378
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js48
-rw-r--r--comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini18
-rw-r--r--comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs78
-rw-r--r--comm/mail/components/extensions/ExtensionPopups.sys.mjs635
-rw-r--r--comm/mail/components/extensions/ExtensionToolbarButtons.jsm949
-rw-r--r--comm/mail/components/extensions/MailExtensionShortcuts.jsm87
-rw-r--r--comm/mail/components/extensions/child/.eslintrc.js15
-rw-r--r--comm/mail/components/extensions/child/ext-extensionScripts.js83
-rw-r--r--comm/mail/components/extensions/child/ext-mail.js28
-rw-r--r--comm/mail/components/extensions/child/ext-menus.js290
-rw-r--r--comm/mail/components/extensions/child/ext-tabs.js23
-rw-r--r--comm/mail/components/extensions/ext-mail.json171
-rw-r--r--comm/mail/components/extensions/extension.svg19
-rw-r--r--comm/mail/components/extensions/extensionPopup.js557
-rw-r--r--comm/mail/components/extensions/extensionPopup.xhtml92
-rw-r--r--comm/mail/components/extensions/extensions-mail.manifest4
-rw-r--r--comm/mail/components/extensions/jar.mn68
-rw-r--r--comm/mail/components/extensions/moz.build27
-rw-r--r--comm/mail/components/extensions/parent/.eslintrc.js81
-rw-r--r--comm/mail/components/extensions/parent/ext-accounts.js283
-rw-r--r--comm/mail/components/extensions/parent/ext-addressBook.js1587
-rw-r--r--comm/mail/components/extensions/parent/ext-browserAction.js329
-rw-r--r--comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js365
-rw-r--r--comm/mail/components/extensions/parent/ext-cloudFile.js804
-rw-r--r--comm/mail/components/extensions/parent/ext-commands.js103
-rw-r--r--comm/mail/components/extensions/parent/ext-compose.js1703
-rw-r--r--comm/mail/components/extensions/parent/ext-composeAction.js154
-rw-r--r--comm/mail/components/extensions/parent/ext-extensionScripts.js185
-rw-r--r--comm/mail/components/extensions/parent/ext-folders.js675
-rw-r--r--comm/mail/components/extensions/parent/ext-identities.js360
-rw-r--r--comm/mail/components/extensions/parent/ext-mail.js2883
-rw-r--r--comm/mail/components/extensions/parent/ext-mailTabs.js485
-rw-r--r--comm/mail/components/extensions/parent/ext-menus.js1544
-rw-r--r--comm/mail/components/extensions/parent/ext-messageDisplay.js348
-rw-r--r--comm/mail/components/extensions/parent/ext-messageDisplayAction.js251
-rw-r--r--comm/mail/components/extensions/parent/ext-messages.js1563
-rw-r--r--comm/mail/components/extensions/parent/ext-sessions.js62
-rw-r--r--comm/mail/components/extensions/parent/ext-spaces.js364
-rw-r--r--comm/mail/components/extensions/parent/ext-spacesToolbar.js308
-rw-r--r--comm/mail/components/extensions/parent/ext-tabs.js822
-rw-r--r--comm/mail/components/extensions/parent/ext-theme.js543
-rw-r--r--comm/mail/components/extensions/parent/ext-windows.js555
-rw-r--r--comm/mail/components/extensions/processScript.js71
-rw-r--r--comm/mail/components/extensions/schemas/LICENSE27
-rw-r--r--comm/mail/components/extensions/schemas/accounts.json235
-rw-r--r--comm/mail/components/extensions/schemas/addressBook.json977
-rw-r--r--comm/mail/components/extensions/schemas/browserAction.json848
-rw-r--r--comm/mail/components/extensions/schemas/chrome_settings_overrides.json194
-rw-r--r--comm/mail/components/extensions/schemas/cloudFile.json501
-rw-r--r--comm/mail/components/extensions/schemas/commands.json279
-rw-r--r--comm/mail/components/extensions/schemas/compose.json937
-rw-r--r--comm/mail/components/extensions/schemas/composeAction.json722
-rw-r--r--comm/mail/components/extensions/schemas/extensionScripts.json133
-rw-r--r--comm/mail/components/extensions/schemas/folders.json408
-rw-r--r--comm/mail/components/extensions/schemas/identities.json277
-rw-r--r--comm/mail/components/extensions/schemas/mailTabs.json428
-rw-r--r--comm/mail/components/extensions/schemas/menus.json757
-rw-r--r--comm/mail/components/extensions/schemas/menus_child.json31
-rw-r--r--comm/mail/components/extensions/schemas/messageDisplay.json159
-rw-r--r--comm/mail/components/extensions/schemas/messageDisplayAction.json721
-rw-r--r--comm/mail/components/extensions/schemas/messages.json933
-rw-r--r--comm/mail/components/extensions/schemas/sessions.json76
-rw-r--r--comm/mail/components/extensions/schemas/spaces.json290
-rw-r--r--comm/mail/components/extensions/schemas/spacesToolbar.json175
-rw-r--r--comm/mail/components/extensions/schemas/tabs.json989
-rw-r--r--comm/mail/components/extensions/schemas/theme.json542
-rw-r--r--comm/mail/components/extensions/schemas/windows.json511
-rw-r--r--comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs6
-rw-r--r--comm/mail/components/extensions/test/browser/.eslintrc.js7
-rw-r--r--comm/mail/components/extensions/test/browser/browser.ini135
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js116
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js29
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js17
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js399
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js82
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js348
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js200
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js614
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js1444
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js226
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js138
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js168
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js142
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js59
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js577
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js74
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_update.js357
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction.js268
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js266
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js52
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js125
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js531
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js2268
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js116
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js397
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js141
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js339
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js178
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js102
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js136
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js146
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js160
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js80
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_details.js725
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js469
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js727
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js214
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js1010
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js416
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js432
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js733
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js438
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_content_handler.js334
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js250
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js898
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js162
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js424
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js179
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js253
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js97
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js180
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js77
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js156
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js395
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js397
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js405
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js582
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js375
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js1016
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js337
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js294
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js113
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js184
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js636
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js38
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js212
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js221
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js221
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js221
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_message_external.js427
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js107
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js132
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_sessions.js90
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_spaces.js1047
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js755
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js336
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js275
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js591
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js306
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js226
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js113
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js578
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js150
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js685
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows.js439
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js94
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js116
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js255
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_events.js405
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_types.js121
-rw-r--r--comm/mail/components/extensions/test/browser/data/cloudFile1.txt1
-rw-r--r--comm/mail/components/extensions/test/browser/data/cloudFile2.txt1
-rw-r--r--comm/mail/components/extensions/test/browser/data/content.html12
-rw-r--r--comm/mail/components/extensions/test/browser/data/content_body.html1
-rw-r--r--comm/mail/components/extensions/test/browser/data/linktest.html11
-rw-r--r--comm/mail/components/extensions/test/browser/data/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/components/extensions/test/browser/head.js1533
-rw-r--r--comm/mail/components/extensions/test/browser/head_menus.js733
-rw-r--r--comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml186
-rw-r--r--comm/mail/components/extensions/test/browser/messages/messageWithLink.eml26
-rw-r--r--comm/mail/components/extensions/test/browser/test_browserAction.js845
-rw-r--r--comm/mail/components/extensions/test/xpcshell/.eslintrc.js13
-rw-r--r--comm/mail/components/extensions/test/xpcshell/data/utils.js124
-rw-r--r--comm/mail/components/extensions/test/xpcshell/head-imap.js12
-rw-r--r--comm/mail/components/extensions/test/xpcshell/head-nntp.js12
-rw-r--r--comm/mail/components/extensions/test/xpcshell/head.js298
-rw-r--r--comm/mail/components/extensions/test/xpcshell/images/redPixel.pngbin0 -> 119 bytes
-rw-r--r--comm/mail/components/extensions/test/xpcshell/images/whitePixel.pngbin0 -> 69 bytes
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/alternative.eml23
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml35
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml127
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample01.eml11
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample02.eml121
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample03.eml43
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample04.eml10
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample05.eml10
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample06.eml8
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample07.eml24
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js1089
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js220
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js2043
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js139
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js238
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js148
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js101
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_alias.js123
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js350
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js279
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_folders.js560
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js374
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js146
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages.js730
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js499
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js1073
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js256
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js121
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js656
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js153
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js333
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js415
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini7
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini23
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini7
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell.ini17
-rw-r--r--comm/mail/components/im/IMIncomingServer.sys.mjs359
-rw-r--r--comm/mail/components/im/IMProtocolInfo.sys.mjs49
-rw-r--r--comm/mail/components/im/all-im.js14
-rw-r--r--comm/mail/components/im/components.conf20
-rw-r--r--comm/mail/components/im/content/.eslintrc.js22
-rw-r--r--comm/mail/components/im/content/addbuddy.js58
-rw-r--r--comm/mail/components/im/content/addbuddy.xhtml59
-rw-r--r--comm/mail/components/im/content/am-im.js291
-rw-r--r--comm/mail/components/im/content/am-im.xhtml235
-rw-r--r--comm/mail/components/im/content/chat-contact.js282
-rw-r--r--comm/mail/components/im/content/chat-conversation-info.js353
-rw-r--r--comm/mail/components/im/content/chat-conversation.js1760
-rw-r--r--comm/mail/components/im/content/chat-group.js255
-rw-r--r--comm/mail/components/im/content/chat-imconv.js366
-rw-r--r--comm/mail/components/im/content/chat-menu.inc.xhtml109
-rw-r--r--comm/mail/components/im/content/chat-messenger.inc.xhtml192
-rw-r--r--comm/mail/components/im/content/chat-messenger.js2162
-rw-r--r--comm/mail/components/im/content/imAccountWizard.js526
-rw-r--r--comm/mail/components/im/content/imAccountWizard.xhtml180
-rw-r--r--comm/mail/components/im/content/imAccounts.js663
-rw-r--r--comm/mail/components/im/content/imAccounts.xhtml250
-rw-r--r--comm/mail/components/im/content/imContextMenu.js276
-rw-r--r--comm/mail/components/im/content/imStatusSelector.js383
-rw-r--r--comm/mail/components/im/content/joinchat.js195
-rw-r--r--comm/mail/components/im/content/joinchat.xhtml58
-rw-r--r--comm/mail/components/im/content/toolbarbutton-badge-button.js70
-rw-r--r--comm/mail/components/im/content/verify.js53
-rw-r--r--comm/mail/components/im/content/verify.xhtml46
-rw-r--r--comm/mail/components/im/jar.mn199
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.pngbin0 -> 581 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.pngbin0 -> 658 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.pngbin0 -> 592 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.pngbin0 -> 600 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.pngbin0 -> 676 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.pngbin0 -> 589 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.pngbin0 -> 666 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.pngbin0 -> 602 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.pngbin0 -> 677 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.pngbin0 -> 597 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.pngbin0 -> 682 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.pngbin0 -> 600 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.pngbin0 -> 669 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.pngbin0 -> 562 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.pngbin0 -> 647 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.pngbin0 -> 588 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.pngbin0 -> 667 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.pngbin0 -> 594 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.pngbin0 -> 672 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.pngbin0 -> 669 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.pngbin0 -> 590 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.pngbin0 -> 672 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.pngbin0 -> 591 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.pngbin0 -> 676 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.pngbin0 -> 588 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.pngbin0 -> 578 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.pngbin0 -> 662 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.pngbin0 -> 590 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.pngbin0 -> 677 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.pngbin0 -> 589 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.pngbin0 -> 673 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.pngbin0 -> 585 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.pngbin0 -> 670 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.pngbin0 -> 584 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.pngbin0 -> 679 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.pngbin0 -> 594 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.pngbin0 -> 561 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.pngbin0 -> 653 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.pngbin0 -> 674 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.pngbin0 -> 582 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.pngbin0 -> 674 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.pngbin0 -> 589 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.pngbin0 -> 672 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.pngbin0 -> 592 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.pngbin0 -> 678 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.pngbin0 -> 591 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.pngbin0 -> 592 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.pngbin0 -> 667 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.pngbin0 -> 599 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.pngbin0 -> 683 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.pngbin0 -> 593 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.pngbin0 -> 660 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.pngbin0 -> 525 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.pngbin0 -> 590 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.pngbin0 -> 661 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.pngbin0 -> 594 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.pngbin0 -> 675 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.pngbin0 -> 596 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.pngbin0 -> 680 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.pngbin0 -> 608 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.pngbin0 -> 620 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/minus.pngbin0 -> 619 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.pngbin0 -> 615 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Bitmaps/plus.pngbin0 -> 614 bytes
-rw-r--r--comm/mail/components/im/messages/bubbles/Footer.html5
-rw-r--r--comm/mail/components/im/messages/bubbles/Incoming/Content.html7
-rw-r--r--comm/mail/components/im/messages/bubbles/Incoming/Context.html7
-rw-r--r--comm/mail/components/im/messages/bubbles/Incoming/NextContent.html3
-rw-r--r--comm/mail/components/im/messages/bubbles/Info.plist41
-rw-r--r--comm/mail/components/im/messages/bubbles/NextStatus.html3
-rw-r--r--comm/mail/components/im/messages/bubbles/Status.html4
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css36
-rw-r--r--comm/mail/components/im/messages/bubbles/inline.js330
-rw-r--r--comm/mail/components/im/messages/bubbles/main.css210
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/Content.html2
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/Context.html2
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/NextContent.html2
-rw-r--r--comm/mail/components/im/messages/dark/Incoming/NextContext.html2
-rw-r--r--comm/mail/components/im/messages/dark/Info.plist41
-rw-r--r--comm/mail/components/im/messages/dark/Status.html1
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Blue.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Green.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Purple.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Red.css8
-rw-r--r--comm/mail/components/im/messages/dark/Variants/Yellow.css8
-rw-r--r--comm/mail/components/im/messages/dark/inline.js60
-rw-r--r--comm/mail/components/im/messages/dark/main.css127
-rw-r--r--comm/mail/components/im/messages/mail/Footer.html0
-rw-r--r--comm/mail/components/im/messages/mail/Header.html0
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/Content.html1
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/Context.html1
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/NextContent.html1
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/NextContext.html0
-rw-r--r--comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg6
-rw-r--r--comm/mail/components/im/messages/mail/Info.plist30
-rw-r--r--comm/mail/components/im/messages/mail/NextStatus.html1
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/Content.html0
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/Context.html0
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/NextContent.html0
-rw-r--r--comm/mail/components/im/messages/mail/Outgoing/NextContext.html0
-rw-r--r--comm/mail/components/im/messages/mail/Status.html1
-rw-r--r--comm/mail/components/im/messages/mail/Variants/Dark.css49
-rw-r--r--comm/mail/components/im/messages/mail/Variants/Light.css49
-rw-r--r--comm/mail/components/im/messages/mail/inline.js40
-rw-r--r--comm/mail/components/im/messages/mail/main.css155
-rw-r--r--comm/mail/components/im/messages/papersheets/Bitmaps/information.pngbin0 -> 740 bytes
-rw-r--r--comm/mail/components/im/messages/papersheets/Bitmaps/minus.pngbin0 -> 196 bytes
-rw-r--r--comm/mail/components/im/messages/papersheets/Bitmaps/plus.pngbin0 -> 196 bytes
-rw-r--r--comm/mail/components/im/messages/papersheets/Incoming/Content.html4
-rw-r--r--comm/mail/components/im/messages/papersheets/Incoming/Context.html4
-rw-r--r--comm/mail/components/im/messages/papersheets/Incoming/NextContent.html3
-rw-r--r--comm/mail/components/im/messages/papersheets/Info.plist38
-rw-r--r--comm/mail/components/im/messages/papersheets/NextStatus.html2
-rw-r--r--comm/mail/components/im/messages/papersheets/Status.html4
-rw-r--r--comm/mail/components/im/messages/papersheets/Variants/White.css22
-rw-r--r--comm/mail/components/im/messages/papersheets/inline.js81
-rw-r--r--comm/mail/components/im/messages/papersheets/main.css208
-rw-r--r--comm/mail/components/im/messages/simple/Incoming/Content.html1
-rw-r--r--comm/mail/components/im/messages/simple/Incoming/Context.html1
-rw-r--r--comm/mail/components/im/messages/simple/Incoming/NextContext.html1
-rw-r--r--comm/mail/components/im/messages/simple/Info.plist32
-rw-r--r--comm/mail/components/im/messages/simple/Status.html1
-rw-r--r--comm/mail/components/im/messages/simple/Variants/Dark.css23
-rw-r--r--comm/mail/components/im/messages/simple/Variants/Normal.css0
-rw-r--r--comm/mail/components/im/messages/simple/main.css90
-rw-r--r--comm/mail/components/im/modules/ChatEncryption.sys.mjs157
-rw-r--r--comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs352
-rw-r--r--comm/mail/components/im/modules/chatHandler.sys.mjs106
-rw-r--r--comm/mail/components/im/modules/chatIcons.sys.mjs106
-rw-r--r--comm/mail/components/im/modules/chatNotifications.sys.mjs262
-rw-r--r--comm/mail/components/im/modules/index_im.sys.mjs928
-rw-r--r--comm/mail/components/im/moz.build38
-rw-r--r--comm/mail/components/im/smileys/theme.json22
-rw-r--r--comm/mail/components/im/test/TestProtocol.sys.mjs308
-rw-r--r--comm/mail/components/im/test/browser/browser.ini26
-rw-r--r--comm/mail/components/im/test/browser/browser_browserRequest.js112
-rw-r--r--comm/mail/components/im/test/browser/browser_chatNotifications.js101
-rw-r--r--comm/mail/components/im/test/browser/browser_chatTelemetry.js52
-rw-r--r--comm/mail/components/im/test/browser/browser_contextMenu.js243
-rw-r--r--comm/mail/components/im/test/browser/browser_logs.js97
-rw-r--r--comm/mail/components/im/test/browser/browser_messagesMail.js235
-rw-r--r--comm/mail/components/im/test/browser/browser_readMessage.js49
-rw-r--r--comm/mail/components/im/test/browser/browser_removeMessage.js54
-rw-r--r--comm/mail/components/im/test/browser/browser_requestNotifications.js350
-rw-r--r--comm/mail/components/im/test/browser/browser_spacesToolbarChat.js255
-rw-r--r--comm/mail/components/im/test/browser/browser_tooltips.js194
-rw-r--r--comm/mail/components/im/test/browser/browser_updateMessage.js62
-rw-r--r--comm/mail/components/im/test/browser/head.js132
-rw-r--r--comm/mail/components/im/test/components.conf14
-rw-r--r--comm/mail/components/migration/content/migration.js464
-rw-r--r--comm/mail/components/migration/content/migration.xhtml89
-rw-r--r--comm/mail/components/migration/jar.mn7
-rw-r--r--comm/mail/components/migration/moz.build11
-rw-r--r--comm/mail/components/migration/public/moz.build12
-rw-r--r--comm/mail/components/migration/public/nsIMailProfileMigrator.idl70
-rw-r--r--comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm869
-rw-r--r--comm/mail/components/migration/src/components.conf38
-rw-r--r--comm/mail/components/migration/src/moz.build32
-rw-r--r--comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp86
-rw-r--r--comm/mail/components/migration/src/nsMailProfileMigratorUtils.h54
-rw-r--r--comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp371
-rw-r--r--comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h121
-rw-r--r--comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp135
-rw-r--r--comm/mail/components/migration/src/nsOutlookProfileMigrator.h30
-rw-r--r--comm/mail/components/migration/src/nsProfileMigrator.cpp121
-rw-r--r--comm/mail/components/migration/src/nsProfileMigrator.h36
-rw-r--r--comm/mail/components/migration/src/nsProfileMigratorBase.cpp173
-rw-r--r--comm/mail/components/migration/src/nsProfileMigratorBase.h40
-rw-r--r--comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp1175
-rw-r--r--comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h84
-rw-r--r--comm/mail/components/moz.build53
-rw-r--r--comm/mail/components/newmailaccount/content/accountProvisioner.js892
-rw-r--r--comm/mail/components/newmailaccount/content/accountProvisioner.xhtml226
-rw-r--r--comm/mail/components/newmailaccount/content/provisionerCheckout.js157
-rw-r--r--comm/mail/components/newmailaccount/content/uriListener.js281
-rw-r--r--comm/mail/components/newmailaccount/jar.mn9
-rw-r--r--comm/mail/components/newmailaccount/moz.build6
-rw-r--r--comm/mail/components/preferences/actionsshared.js23
-rw-r--r--comm/mail/components/preferences/applicationManager.js112
-rw-r--r--comm/mail/components/preferences/applicationManager.xhtml76
-rw-r--r--comm/mail/components/preferences/attachmentReminder.js100
-rw-r--r--comm/mail/components/preferences/attachmentReminder.xhtml54
-rw-r--r--comm/mail/components/preferences/chat.inc.xhtml198
-rw-r--r--comm/mail/components/preferences/chat.js193
-rw-r--r--comm/mail/components/preferences/colors.js15
-rw-r--r--comm/mail/components/preferences/colors.xhtml90
-rw-r--r--comm/mail/components/preferences/compose.inc.xhtml354
-rw-r--r--comm/mail/components/preferences/compose.js776
-rw-r--r--comm/mail/components/preferences/connection.js597
-rw-r--r--comm/mail/components/preferences/connection.xhtml264
-rw-r--r--comm/mail/components/preferences/cookies.js993
-rw-r--r--comm/mail/components/preferences/cookies.xhtml117
-rw-r--r--comm/mail/components/preferences/dockoptions.js11
-rw-r--r--comm/mail/components/preferences/dockoptions.xhtml59
-rw-r--r--comm/mail/components/preferences/downloads.js132
-rw-r--r--comm/mail/components/preferences/extensionControlled.js129
-rw-r--r--comm/mail/components/preferences/findInPage.js641
-rw-r--r--comm/mail/components/preferences/fonts.js196
-rw-r--r--comm/mail/components/preferences/fonts.xhtml337
-rw-r--r--comm/mail/components/preferences/general.inc.xhtml1096
-rw-r--r--comm/mail/components/preferences/general.js2962
-rw-r--r--comm/mail/components/preferences/jar.mn55
-rw-r--r--comm/mail/components/preferences/messagestyle.js259
-rw-r--r--comm/mail/components/preferences/messengerLanguages.js632
-rw-r--r--comm/mail/components/preferences/messengerLanguages.xhtml93
-rw-r--r--comm/mail/components/preferences/moz.build18
-rw-r--r--comm/mail/components/preferences/notifications.js25
-rw-r--r--comm/mail/components/preferences/notifications.xhtml71
-rw-r--r--comm/mail/components/preferences/offline.js31
-rw-r--r--comm/mail/components/preferences/offline.xhtml77
-rw-r--r--comm/mail/components/preferences/passwordManager.js819
-rw-r--r--comm/mail/components/preferences/passwordManager.xhtml186
-rw-r--r--comm/mail/components/preferences/permissions.js501
-rw-r--r--comm/mail/components/preferences/permissions.xhtml128
-rw-r--r--comm/mail/components/preferences/preferences.js453
-rw-r--r--comm/mail/components/preferences/preferences.xhtml256
-rw-r--r--comm/mail/components/preferences/preferencesTab.js162
-rw-r--r--comm/mail/components/preferences/privacy.inc.xhtml597
-rw-r--r--comm/mail/components/preferences/privacy.js562
-rw-r--r--comm/mail/components/preferences/receipts.js38
-rw-r--r--comm/mail/components/preferences/receipts.xhtml120
-rw-r--r--comm/mail/components/preferences/searchResults.inc.xhtml24
-rw-r--r--comm/mail/components/preferences/sync.inc.xhtml239
-rw-r--r--comm/mail/components/preferences/sync.js377
-rw-r--r--comm/mail/components/preferences/syncDialog.js38
-rw-r--r--comm/mail/components/preferences/syncDialog.xhtml210
-rw-r--r--comm/mail/components/preferences/tagDialog.xhtml26
-rw-r--r--comm/mail/components/preferences/test/browser/browser.ini20
-rw-r--r--comm/mail/components/preferences/test/browser/browser_chat.js74
-rw-r--r--comm/mail/components/preferences/test/browser/browser_cloudfile.js796
-rw-r--r--comm/mail/components/preferences/test/browser/browser_compose.js87
-rw-r--r--comm/mail/components/preferences/test/browser/browser_general.js380
-rw-r--r--comm/mail/components/preferences/test/browser/browser_openPreferences.js37
-rw-r--r--comm/mail/components/preferences/test/browser/browser_privacy.js454
-rw-r--r--comm/mail/components/preferences/test/browser/browser_sync.js419
-rw-r--r--comm/mail/components/preferences/test/browser/files/avatar.pngbin0 -> 11019 bytes
-rw-r--r--comm/mail/components/preferences/test/browser/files/icon.svg7
-rw-r--r--comm/mail/components/preferences/test/browser/files/management.html10
-rw-r--r--comm/mail/components/preferences/test/browser/head.js314
-rw-r--r--comm/mail/components/prompts/PromptCollection.jsm100
-rw-r--r--comm/mail/components/prompts/components.conf12
-rw-r--r--comm/mail/components/prompts/moz.build11
-rw-r--r--comm/mail/components/search/SearchIntegration.jsm871
-rw-r--r--comm/mail/components/search/components.conf16
-rw-r--r--comm/mail/components/search/content/SpotlightIntegration.js240
-rw-r--r--comm/mail/components/search/content/WinSearchIntegration.js346
-rw-r--r--comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.icobin0 -> 668 bytes
-rw-r--r--comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/allegro-pl/favicon.icobin0 -> 1150 bytes
-rw-r--r--comm/mail/components/search/extensions/allegro-pl/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/au/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/ca/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/de/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/france/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/in/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/it/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/jp/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/mx/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/_locales/nl/messages.json17
-rw-r--r--comm/mail/components/search/extensions/amazon/favicon.icobin0 -> 1407 bytes
-rw-r--r--comm/mail/components/search/extensions/amazon/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/amazondotcn/favicon.icobin0 -> 1407 bytes
-rw-r--r--comm/mail/components/search/extensions/amazondotcn/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json20
-rw-r--r--comm/mail/components/search/extensions/amazondotcom/favicon.icobin0 -> 1407 bytes
-rw-r--r--comm/mail/components/search/extensions/amazondotcom/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/atlas-sk/favicon.icobin0 -> 818 bytes
-rw-r--r--comm/mail/components/search/extensions/atlas-sk/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/azerdict/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/azerdict/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/azet-sk/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/azet-sk/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/baidu/favicon.icobin0 -> 5686 bytes
-rw-r--r--comm/mail/components/search/extensions/baidu/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/bbc-alba/favicon.icobin0 -> 958 bytes
-rw-r--r--comm/mail/components/search/extensions/bbc-alba/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/bing/favicon.icobin0 -> 3638 bytes
-rw-r--r--comm/mail/components/search/extensions/bing/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/bok-NO/favicon.pngbin0 -> 530 bytes
-rw-r--r--comm/mail/components/search/extensions/bok-NO/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json14
-rw-r--r--comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json14
-rw-r--r--comm/mail/components/search/extensions/bolcom/favicon.icobin0 -> 1406 bytes
-rw-r--r--comm/mail/components/search/extensions/bolcom/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/ceneji/favicon.pngbin0 -> 283 bytes
-rw-r--r--comm/mail/components/search/extensions/ceneji/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/chambers-en-GB/favicon.icobin0 -> 1425 bytes
-rw-r--r--comm/mail/components/search/extensions/chambers-en-GB/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/coccoc/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/coccoc/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/daum-kr/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/daum-kr/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/ddg/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/ddg/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/diec2/favicon.pngbin0 -> 4070 bytes
-rw-r--r--comm/mail/components/search/extensions/diec2/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/drae/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/drae/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/ecosia/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/ecosia/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/eki-ee/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/eki-ee/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/eudict/favicon.icobin0 -> 1785 bytes
-rw-r--r--comm/mail/components/search/extensions/eudict/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/faclair-beag/favicon.icobin0 -> 1091 bytes
-rw-r--r--comm/mail/components/search/extensions/faclair-beag/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/flip/favicon.pngbin0 -> 342 bytes
-rw-r--r--comm/mail/components/search/extensions/flip/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/freelang/favicon.icobin0 -> 2280 bytes
-rw-r--r--comm/mail/components/search/extensions/freelang/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/google/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/google/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/gulesider-NO/favicon.icobin0 -> 1150 bytes
-rw-r--r--comm/mail/components/search/extensions/gulesider-NO/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/heureka-cz/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/heureka-cz/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/hotline-ua/favicon.icobin0 -> 1376 bytes
-rw-r--r--comm/mail/components/search/extensions/hotline-ua/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/kannadastore/favicon.pngbin0 -> 827 bytes
-rw-r--r--comm/mail/components/search/extensions/kannadastore/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/leo_ende_de/favicon.pngbin0 -> 749 bytes
-rw-r--r--comm/mail/components/search/extensions/leo_ende_de/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/list-am/favicon.gifbin0 -> 303 bytes
-rw-r--r--comm/mail/components/search/extensions/list-am/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/list.json1223
-rw-r--r--comm/mail/components/search/extensions/longdo/favicon.icobin0 -> 252 bytes
-rw-r--r--comm/mail/components/search/extensions/longdo/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/mailru/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/mailru/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/mapy-cz/favicon.icobin0 -> 1812 bytes
-rw-r--r--comm/mail/components/search/extensions/mapy-cz/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json17
-rw-r--r--comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json17
-rw-r--r--comm/mail/components/search/extensions/marktplaats/favicon.icobin0 -> 3054 bytes
-rw-r--r--comm/mail/components/search/extensions/marktplaats/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json17
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json17
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json17
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/mercadolibre/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/mercadolivre/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/mercadolivre/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/morfix-dic/favicon.icobin0 -> 2286 bytes
-rw-r--r--comm/mail/components/search/extensions/morfix-dic/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/najdi-si/favicon.pngbin0 -> 683 bytes
-rw-r--r--comm/mail/components/search/extensions/najdi-si/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/naver-kr/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/naver-kr/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/neti-ee/favicon.icobin0 -> 2519 bytes
-rw-r--r--comm/mail/components/search/extensions/neti-ee/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/odpiralni/favicon.pngbin0 -> 2639 bytes
-rw-r--r--comm/mail/components/search/extensions/odpiralni/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/olx/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/olx/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/oshiete-goo/favicon.icobin0 -> 8348 bytes
-rw-r--r--comm/mail/components/search/extensions/oshiete-goo/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/osta-ee/favicon.pngbin0 -> 328 bytes
-rw-r--r--comm/mail/components/search/extensions/osta-ee/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/ozonru/favicon.icobin0 -> 3638 bytes
-rw-r--r--comm/mail/components/search/extensions/ozonru/manifest.json27
-rw-r--r--comm/mail/components/search/extensions/palasprint/favicon.icobin0 -> 1406 bytes
-rw-r--r--comm/mail/components/search/extensions/palasprint/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/pazaruvaj/favicon.icobin0 -> 2584 bytes
-rw-r--r--comm/mail/components/search/extensions/pazaruvaj/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/pogodak/favicon.icobin0 -> 1150 bytes
-rw-r--r--comm/mail/components/search/extensions/pogodak/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/priberam/favicon.pngbin0 -> 790 bytes
-rw-r--r--comm/mail/components/search/extensions/priberam/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/priceru/favicon.icobin0 -> 468 bytes
-rw-r--r--comm/mail/components/search/extensions/priceru/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.icobin0 -> 1406 bytes
-rw-r--r--comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/pwn-pl/favicon.pngbin0 -> 1055 bytes
-rw-r--r--comm/mail/components/search/extensions/pwn-pl/manifest.json23
-rw-r--r--comm/mail/components/search/extensions/qwant/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/qwant/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/qxl-NO/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/qxl-NO/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/rakuten/favicon.icobin0 -> 2053 bytes
-rw-r--r--comm/mail/components/search/extensions/rakuten/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/readmoo/favicon.icobin0 -> 2468 bytes
-rw-r--r--comm/mail/components/search/extensions/readmoo/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/salidzinilv/favicon.icobin0 -> 3638 bytes
-rw-r--r--comm/mail/components/search/extensions/salidzinilv/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/seznam-cz/favicon.icobin0 -> 1743 bytes
-rw-r--r--comm/mail/components/search/extensions/seznam-cz/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/sslv/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/sslv/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/tearma/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/tearma/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/tyda-sv-SE/favicon.icobin0 -> 379 bytes
-rw-r--r--comm/mail/components/search/extensions/tyda-sv-SE/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/vatera/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/vatera/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wikipedia/favicon.icobin0 -> 884 bytes
-rw-r--r--comm/mail/components/search/extensions/wikipedia/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json20
-rw-r--r--comm/mail/components/search/extensions/wiktionary/favicon.icobin0 -> 318 bytes
-rw-r--r--comm/mail/components/search/extensions/wiktionary/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/wolnelektury-pl/favicon.pngbin0 -> 304 bytes
-rw-r--r--comm/mail/components/search/extensions/wolnelektury-pl/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.icobin0 -> 2672 bytes
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json25
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp/favicon.icobin0 -> 5430 bytes
-rw-r--r--comm/mail/components/search/extensions/yahoo-jp/manifest.json24
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/az/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/by/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/en/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/kk/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/ru/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/_locales/tr/messages.json23
-rw-r--r--comm/mail/components/search/extensions/yandex/manifest.json26
-rw-r--r--comm/mail/components/search/extensions/yandex/yandex-en.icobin0 -> 1691 bytes
-rw-r--r--comm/mail/components/search/extensions/yandex/yandex-ru.icobin0 -> 2034 bytes
-rw-r--r--comm/mail/components/search/extensions/zoznam-sk/favicon.pngbin0 -> 222 bytes
-rw-r--r--comm/mail/components/search/extensions/zoznam-sk/manifest.json25
-rw-r--r--comm/mail/components/search/jar.mn15
-rw-r--r--comm/mail/components/search/mdimporter/English.lproj/InfoPlist.stringsbin0 -> 456 bytes
-rw-r--r--comm/mail/components/search/mdimporter/English.lproj/schema.stringsbin0 -> 1276 bytes
-rw-r--r--comm/mail/components/search/mdimporter/GetMetadataForFile.c76
-rw-r--r--comm/mail/components/search/mdimporter/Info.plist53
-rw-r--r--comm/mail/components/search/mdimporter/Makefile.in26
-rw-r--r--comm/mail/components/search/mdimporter/main.c208
-rw-r--r--comm/mail/components/search/mdimporter/moz.build22
-rw-r--r--comm/mail/components/search/mdimporter/schema.xml32
-rw-r--r--comm/mail/components/search/moz.build23
-rw-r--r--comm/mail/components/search/nsMailWinSearchHelper.cpp254
-rw-r--r--comm/mail/components/search/nsMailWinSearchHelper.h34
-rw-r--r--comm/mail/components/search/public/moz.build10
-rw-r--r--comm/mail/components/search/public/nsIMailWinSearchHelper.idl58
-rw-r--r--comm/mail/components/search/wsenable/Makefile.in6
-rw-r--r--comm/mail/components/search/wsenable/WSEnable.cpp141
-rw-r--r--comm/mail/components/search/wsenable/WSEnable.exe.manifest37
-rw-r--r--comm/mail/components/search/wsenable/WSEnable.rc6
-rw-r--r--comm/mail/components/search/wsenable/module.ver1
-rw-r--r--comm/mail/components/search/wsenable/moz.build21
-rw-r--r--comm/mail/components/shell/components.conf37
-rw-r--r--comm/mail/components/shell/moz.build43
-rw-r--r--comm/mail/components/shell/nsGNOMEShellService.cpp341
-rw-r--r--comm/mail/components/shell/nsGNOMEShellService.h49
-rw-r--r--comm/mail/components/shell/nsIShellService.idl52
-rw-r--r--comm/mail/components/shell/nsMacShellService.cpp156
-rw-r--r--comm/mail/components/shell/nsMacShellService.h36
-rw-r--r--comm/mail/components/shell/nsToolkitShellService.h23
-rw-r--r--comm/mail/components/shell/nsWindowsShellService.cpp329
-rw-r--r--comm/mail/components/shell/nsWindowsShellService.h51
-rw-r--r--comm/mail/components/shell/test/unit/test_shellService.js22
-rw-r--r--comm/mail/components/shell/test/unit/xpcshell.ini2
-rw-r--r--comm/mail/components/storybook/.storybook/main.js47
-rw-r--r--comm/mail/components/storybook/.storybook/preview-head.html5
-rw-r--r--comm/mail/components/storybook/.storybook/preview.mjs43
-rw-r--r--comm/mail/components/storybook/README.md39
-rw-r--r--comm/mail/components/storybook/mach_commands.py42
-rw-r--r--comm/mail/components/storybook/package-lock.json37747
-rw-r--r--comm/mail/components/storybook/package.json28
-rw-r--r--comm/mail/components/storybook/stories/colors.stories.mjs89
-rw-r--r--comm/mail/components/storybook/stories/pane-splitter.stories.mjs60
-rw-r--r--comm/mail/components/storybook/stories/search-bar.stories.mjs37
-rw-r--r--comm/mail/components/telemetry/Events.yaml25
-rw-r--r--comm/mail/components/telemetry/Histograms.json40
-rw-r--r--comm/mail/components/telemetry/README.md175
-rw-r--r--comm/mail/components/telemetry/Scalars.yaml591
-rw-r--r--comm/mail/components/test/unit/head_mailcomponents.js20
-rw-r--r--comm/mail/components/test/unit/test_about_support.js219
-rw-r--r--comm/mail/components/test/unit/test_telemetry_buildconfig.js151
-rw-r--r--comm/mail/components/test/unit/xpcshell.ini6
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customizable-element.mjs299
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customization-palette.mjs243
-rw-r--r--comm/mail/components/unifiedtoolbar/content/customization-target.mjs333
-rw-r--r--comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs148
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs38
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs19
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs40
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs44
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs81
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs223
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs183
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs32
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs15
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/space-button.mjs41
-rw-r--r--comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs40
-rw-r--r--comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs549
-rw-r--r--comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs153
-rw-r--r--comm/mail/components/unifiedtoolbar/content/search-bar.mjs121
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs240
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs264
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs414
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs119
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs540
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml366
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml133
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml137
-rw-r--r--comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css53
-rw-r--r--comm/mail/components/unifiedtoolbar/jar.mn29
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs23
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs134
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs445
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs55
-rw-r--r--comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs419
-rw-r--r--comm/mail/components/unifiedtoolbar/moz.build22
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser.ini16
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js173
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js263
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js99
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js285
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml21
-rw-r--r--comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml22
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js40
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js123
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js103
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js64
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js431
-rw-r--r--comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini8
1156 files changed, 265965 insertions, 0 deletions
diff --git a/comm/mail/components/AboutRedirector.jsm b/comm/mail/components/AboutRedirector.jsm
new file mode 100644
index 0000000000..a652ba5a6d
--- /dev/null
+++ b/comm/mail/components/AboutRedirector.jsm
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["AboutRedirector"];
+
+function AboutRedirector() {}
+AboutRedirector.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]),
+
+ // Each entry in the map has the key as the part after the "about:" and the
+ // value as a record with url and flags entries. Note that each addition here
+ // should be coupled with a corresponding addition in mailComponents.manifest.
+ _redirMap: {
+ newserror: {
+ url: "chrome://messenger/content/newsError.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ rights: {
+ url: "chrome://messenger/content/aboutRights.xhtml",
+ flags:
+ Ci.nsIAboutModule.ALLOW_SCRIPT |
+ Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT,
+ },
+ support: {
+ url: "chrome://messenger/content/about-support/aboutSupport.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ preferences: {
+ url: "chrome://messenger/content/preferences/preferences.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ downloads: {
+ url: "chrome://messenger/content/downloads/aboutDownloads.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ policies: {
+ url: "chrome://messenger/content/policies/aboutPolicies.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ accountsettings: {
+ url: "chrome://messenger/content/AccountManager.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ accountsetup: {
+ url: "chrome://messenger/content/accountcreation/accountSetup.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ accountprovisioner: {
+ url: "chrome://messenger/content/newmailaccount/accountProvisioner.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ addressbook: {
+ url: "chrome://messenger/content/addressbook/aboutAddressBook.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ "3pane": {
+ url: "chrome://messenger/content/about3Pane.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ message: {
+ url: "chrome://messenger/content/aboutMessage.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ import: {
+ url: "chrome://messenger/content/aboutImport.xhtml",
+ flags: Ci.nsIAboutModule.ALLOW_SCRIPT,
+ },
+ profiling: {
+ url: "chrome://devtools/content/performance-new/aboutprofiling/index.xhtml",
+ flags:
+ Ci.nsIAboutModule.ALLOW_SCRIPT | Ci.nsIAboutModule.IS_SECURE_CHROME_UI,
+ },
+ },
+
+ /**
+ * Gets the module name from the given URI.
+ */
+ _getModuleName(aURI) {
+ // Strip out the first ? or #, and anything following it
+ let name = /[^?#]+/.exec(aURI.pathQueryRef)[0];
+ return name.toLowerCase();
+ },
+
+ getURIFlags(aURI) {
+ let name = this._getModuleName(aURI);
+ if (!(name in this._redirMap)) {
+ throw Components.Exception(`no about:${name}`, Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ return this._redirMap[name].flags;
+ },
+
+ newChannel(aURI, aLoadInfo) {
+ let name = this._getModuleName(aURI);
+ if (!(name in this._redirMap)) {
+ throw Components.Exception(`no about:${name}`, Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ let newURI = Services.io.newURI(this._redirMap[name].url);
+ let channel = Services.io.newChannelFromURIWithLoadInfo(newURI, aLoadInfo);
+ channel.originalURI = aURI;
+
+ if (
+ this._redirMap[name].flags &
+ Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT
+ ) {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aURI,
+ {}
+ );
+ channel.owner = principal;
+ }
+
+ return channel;
+ },
+
+ getChromeURI(aURI) {
+ let name = this._getModuleName(aURI);
+ if (!(name in this._redirMap)) {
+ throw Components.Exception(`no about:${name}`, Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ return Services.io.newURI(this._redirMap[name].url);
+ },
+};
diff --git a/comm/mail/components/AppIdleManager.jsm b/comm/mail/components/AppIdleManager.jsm
new file mode 100644
index 0000000000..cc42c23178
--- /dev/null
+++ b/comm/mail/components/AppIdleManager.jsm
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["appIdleManager"];
+
+// This module provides a mechanism to turn window focus and blur events
+// into app idle notifications. If we get a blur notification that is not
+// followed by a focus notification in less than some small number of seconds,
+// then we send a begin app idle notification.
+// If we get a focus event, and we're app idle, then we send an end app idle
+// notification.
+// The notification topic is "mail:appIdle", the values are "idle", and "back"
+
+var appIdleManager = {
+ _appIdle: false,
+ _timerInterval: 5000, // 5 seconds ought to be plenty
+ get _timer() {
+ delete this._timer;
+ return (this._timer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ ));
+ },
+
+ _timerCallback() {
+ appIdleManager._appIdle = true;
+ Services.obs.notifyObservers(null, "mail:appIdle", "idle");
+ },
+
+ onBlur() {
+ appIdleManager._timer.initWithCallback(
+ appIdleManager._timerCallback,
+ appIdleManager._timerInterval,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ onFocus() {
+ appIdleManager._timer.cancel();
+ if (appIdleManager._appIdle) {
+ appIdleManager._appIdle = false;
+ Services.obs.notifyObservers(null, "mail:appIdle", "back");
+ }
+ },
+};
diff --git a/comm/mail/components/MailComponents.manifest b/comm/mail/components/MailComponents.manifest
new file mode 100644
index 0000000000..f35c67e263
--- /dev/null
+++ b/comm/mail/components/MailComponents.manifest
@@ -0,0 +1,9 @@
+# MailGlue.jsm
+
+# This component must restrict its registration for the app-startup category
+# to the specific list of apps that use it so it doesn't get loaded in xpcshell.
+# Thus we restrict it to these apps:
+#
+# mail: {3550f703-e582-4d05-9a08-453d09bdfdc6}
+
+category app-startup MailGlue @mozilla.org/mail/mailglue;1 application={3550f703-e582-4d05-9a08-453d09bdfdc6}
diff --git a/comm/mail/components/MailGlue.jsm b/comm/mail/components/MailGlue.jsm
new file mode 100644
index 0000000000..f14237a55a
--- /dev/null
+++ b/comm/mail/components/MailGlue.jsm
@@ -0,0 +1,1380 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["MailGlue", "MailTelemetryForTests"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+// lazy module getter
+
+XPCOMUtils.defineLazyGetter(lazy, "gMailBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+});
+
+if (AppConstants.NIGHTLY_BUILD) {
+ XPCOMUtils.defineLazyGetter(
+ lazy,
+ "WeaveService",
+ () => Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject
+ );
+}
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ChatCore: "resource:///modules/chatHandler.sys.mjs",
+
+ LightweightThemeConsumer:
+ "resource://gre/modules/LightweightThemeConsumer.sys.mjs",
+
+ OsEnvironment: "resource://gre/modules/OsEnvironment.sys.mjs",
+ PdfJs: "resource://pdf.js/PdfJs.sys.mjs",
+
+ RemoteSecuritySettings:
+ "resource://gre/modules/psm/RemoteSecuritySettings.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ ExtensionSupport: "resource:///modules/ExtensionSupport.jsm",
+ MailMigrator: "resource:///modules/MailMigrator.jsm",
+ MailServices: "resource:///modules/MailServices.jsm",
+ MailUsageTelemetry: "resource:///modules/MailUsageTelemetry.jsm",
+ OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
+ TBDistCustomizer: "resource:///modules/TBDistCustomizer.jsm",
+});
+
+if (AppConstants.MOZ_UPDATER) {
+ ChromeUtils.defineESModuleGetters(lazy, {
+ UpdateListener: "resource://gre/modules/UpdateListener.sys.mjs",
+ });
+}
+
+const listeners = {
+ observers: {},
+
+ observe(subject, topic, data) {
+ for (let module of this.observers[topic]) {
+ try {
+ lazy[module].observe(subject, topic, data);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+
+ init() {
+ for (let observer of Object.keys(this.observers)) {
+ Services.obs.addObserver(this, observer);
+ }
+ },
+};
+if (AppConstants.MOZ_UPDATER) {
+ listeners.observers["update-downloading"] = ["UpdateListener"];
+ listeners.observers["update-staged"] = ["UpdateListener"];
+ listeners.observers["update-downloaded"] = ["UpdateListener"];
+ listeners.observers["update-available"] = ["UpdateListener"];
+ listeners.observers["update-error"] = ["UpdateListener"];
+ listeners.observers["update-swap"] = ["UpdateListener"];
+}
+
+const PREF_PDFJS_ISDEFAULT_CACHE_STATE = "pdfjs.enabledCache.state";
+
+let JSWINDOWACTORS = {
+ ChatAction: {
+ matches: ["chrome://chat/content/conv.html"],
+ parent: {
+ esModuleURI: "resource:///actors/ChatActionParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/ChatActionChild.sys.mjs",
+ events: {
+ contextmenu: { mozSystemGroup: true },
+ },
+ },
+ },
+
+ ContextMenu: {
+ parent: {
+ esModuleURI: "resource:///actors/ContextMenuParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/ContextMenuChild.sys.mjs",
+ events: {
+ contextmenu: { mozSystemGroup: true },
+ },
+ },
+ allFrames: true,
+ },
+
+ // As in ActorManagerParent.sys.mjs, but with single-site and single-page
+ // message manager groups added.
+ FindBar: {
+ parent: {
+ esModuleURI: "resource://gre/actors/FindBarParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://gre/actors/FindBarChild.sys.mjs",
+ events: {
+ keypress: { mozSystemGroup: true },
+ },
+ },
+
+ allFrames: true,
+ messageManagerGroups: [
+ "browsers",
+ "single-site",
+ "single-page",
+ "test",
+ "",
+ ],
+ },
+
+ LinkClickHandler: {
+ parent: {
+ moduleURI: "resource:///actors/LinkClickHandlerParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/LinkClickHandlerChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ messageManagerGroups: ["single-site", "webext-browsers"],
+ allFrames: true,
+ },
+
+ LinkHandler: {
+ parent: {
+ esModuleURI: "resource:///actors/LinkHandlerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/LinkHandlerChild.sys.mjs",
+ events: {
+ DOMHeadElementParsed: {},
+ DOMLinkAdded: {},
+ DOMLinkChanged: {},
+ pageshow: {},
+ // The `pagehide` event is only used to clean up state which will not be
+ // present if the actor hasn't been created.
+ pagehide: { createActor: false },
+ },
+ },
+
+ messageManagerGroups: ["browsers", "single-site", "single-page"],
+ },
+
+ // As in ActorManagerParent.sys.mjs, but with single-site and single-page
+ // message manager groups added.
+ LoginManager: {
+ parent: {
+ esModuleURI: "resource://gre/modules/LoginManagerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://gre/modules/LoginManagerChild.sys.mjs",
+ events: {
+ DOMDocFetchSuccess: {},
+ DOMFormBeforeSubmit: {},
+ DOMFormHasPassword: {},
+ DOMInputPasswordAdded: {},
+ },
+ },
+
+ allFrames: true,
+ messageManagerGroups: [
+ "browsers",
+ "single-site",
+ "single-page",
+ "webext-browsers",
+ "",
+ ],
+ },
+
+ MailLink: {
+ parent: {
+ moduleURI: "resource:///actors/MailLinkParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/MailLinkChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ allFrames: true,
+ },
+
+ Pdfjs: {
+ parent: {
+ esModuleURI: "resource://pdf.js/PdfjsParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://pdf.js/PdfjsChild.sys.mjs",
+ },
+ enablePreference: PREF_PDFJS_ISDEFAULT_CACHE_STATE,
+ allFrames: true,
+ },
+
+ Prompt: {
+ parent: {
+ moduleURI: "resource:///actors/PromptParent.jsm",
+ },
+ includeChrome: true,
+ allFrames: true,
+ },
+
+ StrictLinkClickHandler: {
+ parent: {
+ moduleURI: "resource:///actors/LinkClickHandlerParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/LinkClickHandlerChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ messageManagerGroups: ["single-page"],
+ allFrames: true,
+ },
+
+ VCard: {
+ parent: {
+ moduleURI: "resource:///actors/VCardParent.jsm",
+ },
+ child: {
+ moduleURI: "resource:///actors/VCardChild.jsm",
+ events: {
+ click: {},
+ },
+ },
+ allFrames: true,
+ },
+};
+
+// Seconds of idle time before the late idle tasks will be scheduled.
+const LATE_TASKS_IDLE_TIME_SEC = 20;
+
+// Time after we stop tracking startup crashes.
+const STARTUP_CRASHES_END_DELAY_MS = 30 * 1000;
+
+/**
+ * Glue code that should be executed before any windows are opened. Any
+ * window-independent helper methods (a la nsBrowserGlue.js) should go in
+ * MailUtils.jsm instead.
+ */
+
+function MailGlue() {
+ XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "_userIdleService",
+ "@mozilla.org/widget/useridleservice;1",
+ "nsIUserIdleService"
+ );
+ this._init();
+}
+
+// This should match the constant of the same name in devtools
+// (devtools/client/framework/browser-toolbox/Launcher.sys.mjs). Otherwise the logic
+// in command-line-startup will fail. We have a test to ensure it matches, at
+// mail/base/test/unit/test_devtools_url.js.
+MailGlue.BROWSER_TOOLBOX_WINDOW_URL =
+ "chrome://devtools/content/framework/browser-toolbox/window.html";
+
+// A Promise that is resolved by an idle task after most start-up operations.
+MailGlue.afterStartUp = new Promise(resolve => {
+ MailGlue.resolveAfterStartUp = resolve;
+});
+
+MailGlue.prototype = {
+ _isNewProfile: undefined,
+
+ // init (called at app startup)
+ _init() {
+ // Start-up notifications, in order.
+ // app-startup happens first, registered in components.conf.
+ Services.obs.addObserver(this, "command-line-startup");
+ Services.obs.addObserver(this, "final-ui-startup");
+ Services.obs.addObserver(this, "quit-application-granted");
+ Services.obs.addObserver(this, "mail-startup-done");
+
+ // Shut-down notifications.
+ Services.obs.addObserver(this, "xpcom-shutdown");
+
+ // General notifications.
+ Services.obs.addObserver(this, "intl:app-locales-changed");
+ Services.obs.addObserver(this, "handle-xul-text-link");
+ Services.obs.addObserver(this, "chrome-document-global-created");
+ Services.obs.addObserver(this, "document-element-inserted");
+ Services.obs.addObserver(this, "handlersvc-store-initialized");
+
+ // Call the lazy getter to ensure ActorManagerParent is initialized.
+ lazy.ActorManagerParent;
+
+ // FindBar and LoginManager actors are included in JSWINDOWACTORS as they
+ // also apply to the single-site and single-page message manager groups.
+ // First we must unregister them to avoid errors.
+ ChromeUtils.unregisterWindowActor("FindBar");
+ ChromeUtils.unregisterWindowActor("LoginManager");
+
+ lazy.ActorManagerParent.addJSWindowActors(JSWINDOWACTORS);
+ },
+
+ // cleanup (called at shutdown)
+ _dispose() {
+ Services.obs.removeObserver(this, "command-line-startup");
+ Services.obs.removeObserver(this, "final-ui-startup");
+ Services.obs.removeObserver(this, "quit-application-granted");
+ // mail-startup-done is removed by its handler.
+
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+
+ Services.obs.removeObserver(this, "intl:app-locales-changed");
+ Services.obs.removeObserver(this, "handle-xul-text-link");
+ Services.obs.removeObserver(this, "chrome-document-global-created");
+ Services.obs.removeObserver(this, "document-element-inserted");
+ Services.obs.removeObserver(this, "handlersvc-store-initialized");
+
+ lazy.ExtensionSupport.unregisterWindowListener(
+ "Thunderbird-internal-BrowserConsole"
+ );
+
+ lazy.MailUsageTelemetry.uninit();
+
+ if (this._lateTasksIdleObserver) {
+ this._userIdleService.removeIdleObserver(
+ this._lateTasksIdleObserver,
+ LATE_TASKS_IDLE_TIME_SEC
+ );
+ delete this._lateTasksIdleObserver;
+ }
+ },
+
+ // nsIObserver implementation
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "app-startup":
+ // Record the previously started version. This is used to check for
+ // extensions that were disabled by an application update. We need to
+ // read this pref before the Add-Ons Manager changes it.
+ this.previousVersion = Services.prefs.getCharPref(
+ "extensions.lastAppVersion",
+ "0"
+ );
+ break;
+ case "command-line-startup":
+ // Check if this process is the developer toolbox process, and if it
+ // is, stop MailGlue from doing anything more. Also sets a flag that
+ // can be checked to see if this is the toolbox process.
+ let isToolboxProcess = false;
+ let commandLine = aSubject.QueryInterface(Ci.nsICommandLine);
+ let flagIndex = commandLine.findFlag("chrome", true) + 1;
+ if (
+ flagIndex > 0 &&
+ flagIndex < commandLine.length &&
+ commandLine.getArgument(flagIndex) ===
+ MailGlue.BROWSER_TOOLBOX_WINDOW_URL
+ ) {
+ isToolboxProcess = true;
+ }
+
+ MailGlue.__defineGetter__("isToolboxProcess", () => isToolboxProcess);
+
+ if (isToolboxProcess) {
+ // Clean up all of the listeners.
+ this._dispose();
+ }
+ break;
+ case "final-ui-startup":
+ // Initialise the permission manager. If this happens before telling
+ // the folder service that strings are available, it's a *much* less
+ // expensive operation than if it happens afterwards, because if
+ // strings are available, some types of mail URL go looking for things
+ // in message databases, causing massive amounts of I/O.
+ Services.perms.all;
+
+ Cc["@mozilla.org/msgFolder/msgFolderService;1"]
+ .getService(Ci.nsIMsgFolderService)
+ .initializeFolderStrings();
+ Cc["@mozilla.org/msgDBView/msgDBViewService;1"]
+ .getService(Ci.nsIMsgDBViewService)
+ .initializeDBViewStrings();
+ this._beforeUIStartup();
+ break;
+ case "quit-application-granted":
+ Services.startup.trackStartupCrashEnd();
+ if (AppConstants.MOZ_UPDATER) {
+ lazy.UpdateListener.reset();
+ }
+ break;
+ case "mail-startup-done":
+ this._onFirstWindowLoaded();
+ Services.obs.removeObserver(this, "mail-startup-done");
+ break;
+ case "xpcom-shutdown":
+ this._dispose();
+ break;
+ case "intl:app-locales-changed":
+ Cc["@mozilla.org/msgFolder/msgFolderService;1"]
+ .getService(Ci.nsIMsgFolderService)
+ .initializeFolderStrings();
+ Cc["@mozilla.org/msgDBView/msgDBViewService;1"]
+ .getService(Ci.nsIMsgDBViewService)
+ .initializeDBViewStrings();
+ let windows = Services.wm.getEnumerator("mail:3pane");
+ while (windows.hasMoreElements()) {
+ let win = windows.getNext();
+ win.document.getElementById("threadTree")?.invalidate();
+ }
+ // Refresh the folder tree.
+ let fls = Cc["@mozilla.org/mail/folder-lookup;1"].getService(
+ Ci.nsIFolderLookupService
+ );
+ fls.setPrettyNameFromOriginalAllFolders();
+ break;
+ case "handle-xul-text-link":
+ this._handleLink(aSubject, aData);
+ break;
+ case "chrome-document-global-created":
+ // Set up lwt, but only if the "lightweightthemes" attr is set on the root
+ // (i.e. in messenger.xhtml).
+ aSubject.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ if (
+ aSubject.document.documentElement.hasAttribute(
+ "lightweightthemes"
+ )
+ ) {
+ new lazy.LightweightThemeConsumer(aSubject.document);
+ }
+ },
+ { once: true }
+ );
+ break;
+ case "document-element-inserted":
+ let doc = aSubject;
+ if (
+ doc.nodePrincipal.isSystemPrincipal &&
+ (doc.contentType == "application/xhtml+xml" ||
+ doc.contentType == "text/html") &&
+ // People shouldn't be using our built-in custom elements in
+ // system-principal about:blank anyway, and trying to support that
+ // causes responsiveness regressions. So let's not support it.
+ doc.URL != "about:blank"
+ ) {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/customElements.js",
+ doc.ownerGlobal
+ );
+ }
+ break;
+ case "handlersvc-store-initialized": {
+ // Initialize PdfJs when running in-process and remote. This only
+ // happens once since PdfJs registers global hooks. If the PdfJs
+ // extension is installed the init method below will be overridden
+ // leaving initialization to the extension.
+ // parent only: configure default prefs, set up pref observers, register
+ // pdf content handler, and initializes parent side message manager
+ // shim for privileged api access.
+ lazy.PdfJs.init(this._isNewProfile);
+ break;
+ }
+ }
+ },
+
+ // Runs on startup, before the first command line handler is invoked
+ // (i.e. before the first window is opened).
+ _beforeUIStartup() {
+ lazy.TBDistCustomizer.applyPrefDefaults();
+
+ const UI_VERSION_PREF = "mail.ui-rdf.version";
+ this._isNewProfile = !Services.prefs.prefHasUserValue(UI_VERSION_PREF);
+
+ // handle any migration work that has to happen at profile startup
+ lazy.MailMigrator.migrateAtProfileStartup();
+
+ if (!Services.prefs.prefHasUserValue(PREF_PDFJS_ISDEFAULT_CACHE_STATE)) {
+ lazy.PdfJs.checkIsDefault(this._isNewProfile);
+ }
+
+ // Inject scripts into some devtools windows.
+ function _setupBrowserConsole(domWindow) {
+ // Browser Console is an XHTML document.
+ domWindow.document.title =
+ lazy.gMailBundle.GetStringFromName("errorConsoleTitle");
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/viewSourceUtils.js",
+ domWindow
+ );
+ }
+
+ lazy.ExtensionSupport.registerWindowListener(
+ "Thunderbird-internal-BrowserConsole",
+ {
+ chromeURLs: ["chrome://devtools/content/webconsole/index.html"],
+ onLoadWindow: _setupBrowserConsole,
+ }
+ );
+
+ // check if we're in safe mode
+ if (Services.appinfo.inSafeMode) {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/troubleshootMode.xhtml",
+ "_blank",
+ "chrome,centerscreen,modal,resizable=no",
+ null
+ );
+ }
+
+ lazy.AddonManager.maybeInstallBuiltinAddon(
+ "thunderbird-compact-light@mozilla.org",
+ "1.2",
+ "resource://builtin-themes/light/"
+ );
+ lazy.AddonManager.maybeInstallBuiltinAddon(
+ "thunderbird-compact-dark@mozilla.org",
+ "1.2",
+ "resource://builtin-themes/dark/"
+ );
+
+ if (AppConstants.MOZ_UPDATER) {
+ listeners.init();
+ }
+ },
+
+ _checkForOldBuildUpdates() {
+ // check for update if our build is old
+ if (
+ AppConstants.MOZ_UPDATER &&
+ Services.prefs.getBoolPref("app.update.checkInstallTime")
+ ) {
+ let buildID = Services.appinfo.appBuildID;
+ let today = new Date().getTime();
+ /* eslint-disable no-multi-spaces */
+ let buildDate = new Date(
+ buildID.slice(0, 4), // year
+ buildID.slice(4, 6) - 1, // months are zero-based.
+ buildID.slice(6, 8), // day
+ buildID.slice(8, 10), // hour
+ buildID.slice(10, 12), // min
+ buildID.slice(12, 14)
+ ) // ms
+ .getTime();
+ /* eslint-enable no-multi-spaces */
+
+ const millisecondsIn24Hours = 86400000;
+ let acceptableAge =
+ Services.prefs.getIntPref("app.update.checkInstallTime.days") *
+ millisecondsIn24Hours;
+
+ if (buildDate + acceptableAge < today) {
+ Cc["@mozilla.org/updates/update-service;1"]
+ .getService(Ci.nsIApplicationUpdateService)
+ .checkForBackgroundUpdates();
+ }
+ }
+ },
+
+ _onFirstWindowLoaded() {
+ // Start these services.
+ Cc["@mozilla.org/chat/logger;1"].getService(Ci.imILogger);
+
+ this._checkForOldBuildUpdates();
+
+ // On Windows 7 and above, initialize the jump list module.
+ const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1";
+ if (
+ WINTASKBAR_CONTRACTID in Cc &&
+ Cc[WINTASKBAR_CONTRACTID].getService(Ci.nsIWinTaskbar).available
+ ) {
+ const { WinTaskbarJumpList } = ChromeUtils.import(
+ "resource:///modules/WindowsJumpLists.jsm"
+ );
+ WinTaskbarJumpList.startup();
+ }
+
+ const { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+ );
+ ExtensionsUI.init();
+
+ // If the application has been updated, check all installed extensions for
+ // updates.
+ let currentVersion = Services.appinfo.version;
+ if (this.previousVersion != "0" && this.previousVersion != currentVersion) {
+ let { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+ let { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+ );
+ let addons = XPIDatabase.getAddons();
+ for (let addon of addons) {
+ if (addon.permissions() & AddonManager.PERM_CAN_UPGRADE) {
+ AddonManager.getAddonByID(addon.id).then(addon => {
+ if (!AddonManager.shouldAutoUpdate(addon)) {
+ return;
+ }
+ addon.findUpdates(
+ {
+ onUpdateFinished() {},
+ onUpdateAvailable(addon, install) {
+ install.install();
+ },
+ },
+ AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED
+ );
+ });
+ }
+ }
+ }
+
+ if (AppConstants.ASAN_REPORTER) {
+ var { AsanReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsanReporter.sys.mjs"
+ );
+ AsanReporter.init();
+ }
+
+ // Check if Sync is configured
+ if (
+ AppConstants.NIGHTLY_BUILD &&
+ Services.prefs.prefHasUserValue("services.sync.username")
+ ) {
+ lazy.WeaveService.init();
+ }
+
+ this._scheduleStartupIdleTasks();
+ this._lateTasksIdleObserver = (idleService, topic, data) => {
+ if (topic == "idle") {
+ idleService.removeIdleObserver(
+ this._lateTasksIdleObserver,
+ LATE_TASKS_IDLE_TIME_SEC
+ );
+ delete this._lateTasksIdleObserver;
+ this._scheduleBestEffortUserIdleTasks();
+ }
+ };
+ this._userIdleService.addIdleObserver(
+ this._lateTasksIdleObserver,
+ LATE_TASKS_IDLE_TIME_SEC
+ );
+
+ lazy.MailUsageTelemetry.init();
+ },
+
+ /**
+ * Use this function as an entry point to schedule tasks that
+ * need to run only once after startup, and can be scheduled
+ * by using an idle callback.
+ *
+ * The functions scheduled here will fire from idle callbacks
+ * once every window has finished being restored by session
+ * restore, and it's guaranteed that they will run before
+ * the equivalent per-window idle tasks
+ * (from _schedulePerWindowIdleTasks in browser.js).
+ *
+ * If you have something that can wait even further than the
+ * per-window initialization, and is okay with not being run in some
+ * sessions, please schedule them using
+ * _scheduleBestEffortUserIdleTasks.
+ * Don't be fooled by thinking that the use of the timeout parameter
+ * will delay your function: it will just ensure that it potentially
+ * happens _earlier_ than expected (when the timeout limit has been reached),
+ * but it will not make it happen later (and out of order) compared
+ * to the other ones scheduled together.
+ */
+ _scheduleStartupIdleTasks() {
+ const idleTasks = [
+ {
+ task() {
+ // This module needs to be loaded so it registers to receive
+ // FormAutoComplete:GetSelectedIndex messages and respond
+ // appropriately, otherwise we get error messages like the one
+ // reported in bug 1635422.
+ ChromeUtils.importESModule(
+ "resource://gre/actors/AutoCompleteParent.sys.mjs"
+ );
+ },
+ },
+ {
+ task() {
+ // Make sure Gloda's up and running.
+ ChromeUtils.import("resource:///modules/gloda/GlodaPublic.jsm");
+ },
+ },
+ {
+ task() {
+ MailGlue.resolveAfterStartUp();
+ },
+ },
+ {
+ task() {
+ let { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+ );
+ setTimeout(function () {
+ Services.tm.idleDispatchToMainThread(
+ Services.startup.trackStartupCrashEnd
+ );
+ }, STARTUP_CRASHES_END_DELAY_MS);
+ },
+ },
+ {
+ condition: AppConstants.NIGHTLY_BUILD,
+ task: async () => {
+ // Register our sync engines.
+ await lazy.WeaveService.whenLoaded();
+ let Weave = lazy.WeaveService.Weave;
+
+ for (let [moduleName, engineName] of [
+ ["accounts", "AccountsEngine"],
+ ["addressBooks", "AddressBooksEngine"],
+ ["calendars", "CalendarsEngine"],
+ ["identities", "IdentitiesEngine"],
+ ]) {
+ let ns = ChromeUtils.importESModule(
+ `resource://services-sync/engines/${moduleName}.sys.mjs`
+ );
+ await Weave.Service.engineManager.register(ns[engineName]);
+ Weave.Service.engineManager
+ .get(moduleName.toLowerCase())
+ .startTracking();
+ }
+
+ if (lazy.WeaveService.enabled) {
+ // Schedule a sync (if enabled).
+ Weave.Service.scheduler.autoConnect();
+ }
+ },
+ },
+ {
+ condition: Services.prefs.getBoolPref("mail.chat.enabled"),
+ task() {
+ lazy.ChatCore.idleStart();
+ ChromeUtils.importESModule("resource:///modules/index_im.sys.mjs");
+ },
+ },
+ {
+ condition: AppConstants.MOZ_UPDATER,
+ task: () => {
+ lazy.UpdateListener.maybeShowUnsupportedNotification();
+ },
+ },
+ {
+ task() {
+ // Use idleDispatch a second time to run this after the per-window
+ // idle tasks.
+ ChromeUtils.idleDispatch(() => {
+ Services.obs.notifyObservers(
+ null,
+ "mail-startup-idle-tasks-finished"
+ );
+ });
+ },
+ },
+ // Do NOT add anything after idle tasks finished.
+ ];
+
+ for (let task of idleTasks) {
+ if ("condition" in task && !task.condition) {
+ continue;
+ }
+
+ ChromeUtils.idleDispatch(
+ () => {
+ if (!Services.startup.shuttingDown) {
+ let startTime = Cu.now();
+ try {
+ task.task();
+ } catch (ex) {
+ console.error(ex);
+ } finally {
+ ChromeUtils.addProfilerMarker("startupIdleTask", startTime);
+ }
+ }
+ },
+ task.timeout ? { timeout: task.timeout } : undefined
+ );
+ }
+ },
+
+ /**
+ * Use this function as an entry point to schedule tasks that we hope
+ * to run once per session, at any arbitrary point in time, and which we
+ * are okay with sometimes not running at all.
+ *
+ * This function will be called from an idle observer. Check the value of
+ * LATE_TASKS_IDLE_TIME_SEC to see the current value for this idle
+ * observer.
+ *
+ * Note: this function may never be called if the user is never idle for the
+ * requisite time (LATE_TASKS_IDLE_TIME_SEC). Be certain before adding
+ * something here that it's okay that it never be run.
+ */
+ _scheduleBestEffortUserIdleTasks() {
+ const idleTasks = [
+ // Certificates revocation list, etc.
+ () => lazy.RemoteSecuritySettings.init(),
+ // If we haven't already, ensure the address book manager is ready.
+ // This must happen at some point so that CardDAV address books sync.
+ () => lazy.MailServices.ab.directories,
+ // Telemetry.
+ async () => {
+ lazy.OsEnvironment.reportAllowedAppSources();
+ reportAccountTypes();
+ reportAddressBookTypes();
+ reportAccountSizes();
+ await reportCalendars();
+ reportPreferences();
+ reportUIConfiguration();
+ },
+ ];
+
+ for (let task of idleTasks) {
+ ChromeUtils.idleDispatch(async () => {
+ if (!Services.startup.shuttingDown) {
+ let startTime = Cu.now();
+ try {
+ await task();
+ } catch (ex) {
+ console.error(ex);
+ } finally {
+ ChromeUtils.addProfilerMarker("startupLateIdleTask", startTime);
+ }
+ }
+ });
+ }
+ },
+
+ _handleLink(aSubject, aData) {
+ let linkHandled = aSubject.QueryInterface(Ci.nsISupportsPRBool);
+ if (!linkHandled.data) {
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ aData = JSON.parse(aData);
+ let tabParams = { url: aData.href, linkHandler: null };
+ if (win) {
+ let tabmail = win.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.openTab("contentTab", tabParams);
+ win.focus();
+ linkHandled.data = true;
+ return;
+ }
+ }
+
+ // If we didn't have an open 3 pane window, try and open one.
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ {
+ type: "contentTab",
+ tabParams,
+ }
+ );
+ linkHandled.data = true;
+ }
+ },
+
+ // for XPCOM
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * Report account types to telemetry. For im accounts, use `im_protocol` as
+ * scalar key name.
+ */
+function reportAccountTypes() {
+ // Init all count with 0, so that when an account was set up before but
+ // removed now, we reset it in telemetry report.
+ let report = {
+ pop3: 0,
+ imap: 0,
+ nntp: 0,
+ exchange: 0,
+ rss: 0,
+ im_gtalk: 0,
+ im_irc: 0,
+ im_jabber: 0,
+ im_matrix: 0,
+ im_odnoklassniki: 0,
+ };
+
+ const providerReport = {
+ google: 0,
+ microsoft: 0,
+ yahoo_aol: 0,
+ other: 0,
+ };
+
+ for (let account of lazy.MailServices.accounts.accounts) {
+ const incomingServer = account.incomingServer;
+
+ let type = incomingServer.type;
+ if (type == "none") {
+ // Reporting one Local Folders account is not that useful. Skip it.
+ continue;
+ }
+
+ if (type === "im") {
+ let protocol =
+ incomingServer.wrappedJSObject.imAccount.protocol.normalizedName;
+ type = `im_${protocol}`;
+ }
+
+ // It's still possible to report other types not explicitly specified due to
+ // account types that used to exist, but no longer -- e.g. im_yahoo.
+ if (!report[type]) {
+ report[type] = 0;
+ }
+
+ report[type]++;
+
+ // Collect a rough understanding of the frequency of various OAuth
+ // providers.
+ if (incomingServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) {
+ const hostnameDetails = lazy.OAuth2Providers.getHostnameDetails(
+ incomingServer.hostName
+ );
+
+ if (!hostnameDetails || hostnameDetails.length == 0) {
+ // Not a valid OAuth2 configuration; skip it
+ continue;
+ }
+
+ const host = hostnameDetails[0];
+
+ switch (host) {
+ case "accounts.google.com":
+ providerReport.google++;
+ break;
+ case "login.microsoftonline.com":
+ providerReport.microsoft++;
+ break;
+ case "login.yahoo.com":
+ case "login.aol.com":
+ providerReport.yahoo_aol++;
+ break;
+ default:
+ providerReport.other++;
+ }
+ }
+ }
+
+ for (let [type, count] of Object.entries(report)) {
+ Services.telemetry.keyedScalarSet("tb.account.count", type, count);
+ }
+
+ for (const [provider, count] of Object.entries(providerReport)) {
+ Services.telemetry.keyedScalarSet(
+ "tb.account.oauth2_provider_count",
+ provider,
+ count
+ );
+ }
+}
+
+/**
+ * Report size on disk and messages count of each type of folder to telemetry.
+ */
+function reportAccountSizes() {
+ let keys = [
+ "Inbox",
+ "Drafts",
+ "Trash",
+ "SentMail",
+ "Templates",
+ "Junk",
+ "Archive",
+ "Queue",
+ ];
+ for (let key of keys) {
+ Services.telemetry.keyedScalarSet("tb.account.total_messages", key, 0);
+ }
+ Services.telemetry.keyedScalarSet("tb.account.total_messages", "Other", 0);
+ Services.telemetry.keyedScalarSet("tb.account.total_messages", "Total", 0);
+
+ for (let server of lazy.MailServices.accounts.allServers) {
+ if (
+ server instanceof Ci.nsIPop3IncomingServer &&
+ server.deferredToAccount
+ ) {
+ // Skip deferred accounts
+ continue;
+ }
+
+ for (let folder of server.rootFolder.descendants) {
+ let key =
+ keys.find(x => folder.getFlag(Ci.nsMsgFolderFlags[x])) || "Other";
+ let totalMessages = folder.getTotalMessages(false);
+ if (totalMessages > 0) {
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.total_messages",
+ key,
+ totalMessages
+ );
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.total_messages",
+ "Total",
+ totalMessages
+ );
+ }
+ let sizeOnDisk = folder.sizeOnDisk;
+ if (sizeOnDisk > 0) {
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.size_on_disk",
+ key,
+ sizeOnDisk
+ );
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.size_on_disk",
+ "Total",
+ sizeOnDisk
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Report addressbook count and contact count to telemetry, keyed by addressbook
+ * type. Type is one of ["jsaddrbook", "jscarddav", "moz-abldapdirectory"], see
+ * AddrBookManager.jsm for more details.
+ *
+ * NOTE: We didn't use `dir.dirType` because it's just an integer, instead we
+ * use the scheme of `dir.URI` as the type.
+ */
+function reportAddressBookTypes() {
+ let report = {};
+ for (let dir of lazy.MailServices.ab.directories) {
+ let type = dir.URI.split(":")[0];
+
+ if (!report[type]) {
+ report[type] = { count: 0, contactCount: 0 };
+ }
+ report[type].count++;
+
+ try {
+ report[type].contactCount += dir.childCardCount;
+ } catch (ex) {
+ // Directories may throw NS_ERROR_NOT_IMPLEMENTED.
+ }
+ }
+
+ for (let [type, { count, contactCount }] of Object.entries(report)) {
+ Services.telemetry.keyedScalarSet(
+ "tb.addressbook.addressbook_count",
+ type,
+ count
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.addressbook.contact_count",
+ type,
+ contactCount
+ );
+ }
+}
+
+/**
+ * A telemetry probe to report calendar count and read only calendar count.
+ */
+async function reportCalendars() {
+ let telemetryReport = {};
+ let home = lazy.cal.l10n.getCalString("homeCalendarName");
+
+ for (let calendar of lazy.cal.manager.getCalendars()) {
+ if (calendar.name == home && calendar.type == "storage") {
+ // Ignore the "Home" calendar if it is disabled or unused as it's
+ // automatically added.
+ if (calendar.getProperty("disabled")) {
+ continue;
+ }
+ let items = await calendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 1,
+ null,
+ null
+ );
+ if (!items.length) {
+ continue;
+ }
+ }
+ if (!telemetryReport[calendar.type]) {
+ telemetryReport[calendar.type] = { count: 0, readOnlyCount: 0 };
+ }
+ telemetryReport[calendar.type].count++;
+ if (calendar.readOnly) {
+ telemetryReport[calendar.type].readOnlyCount++;
+ }
+ }
+
+ for (let [type, { count, readOnlyCount }] of Object.entries(
+ telemetryReport
+ )) {
+ Services.telemetry.keyedScalarSet(
+ "tb.calendar.calendar_count",
+ type.toLowerCase(),
+ count
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.calendar.read_only_calendar_count",
+ type.toLowerCase(),
+ readOnlyCount
+ );
+ }
+}
+
+function reportPreferences() {
+ let booleanPrefs = [
+ // General
+ "browser.cache.disk.smart_size.enabled",
+ "privacy.clearOnShutdown.cache",
+ "general.autoScroll",
+ "general.smoothScroll",
+ "intl.regional_prefs.use_os_locales",
+ "layers.acceleration.disabled",
+ "mail.biff.play_sound",
+ "mail.close_message_window.on_delete",
+ "mail.delete_matches_sort_order",
+ "mail.display_glyph",
+ "mail.mailnews.scroll_to_new_message",
+ "mail.prompt_purge_threshhold",
+ "mail.purge.ask",
+ "mail.showCondensedAddresses",
+ "mailnews.database.global.indexer.enabled",
+ "mailnews.mark_message_read.auto",
+ "mailnews.mark_message_read.delay",
+ "mailnews.reuse_message_window",
+ "mailnews.start_page.enabled",
+ "searchintegration.enable",
+
+ // Fonts
+ "mail.fixed_width_messages",
+
+ // Colors
+ "browser.display.use_system_colors",
+ "browser.underline_anchors",
+
+ // Read receipts
+ "mail.mdn.report.enabled",
+ "mail.receipt.request_return_receipt_on",
+
+ // Connection
+ "network.proxy.share_proxy_settings",
+ "network.proxy.socks_remote_dns",
+ "pref.advanced.proxies.disable_button.reload",
+ "signon.autologin.proxy",
+
+ // Offline
+ "offline.autoDetect",
+
+ // Compose
+ "ldap_2.autoComplete.useDirectory",
+ "mail.collect_email_address_outgoing",
+ "mail.compose.attachment_reminder",
+ "mail.compose.autosave",
+ "mail.compose.big_attachments.notify",
+ "mail.compose.default_to_paragraph",
+ "mail.e2ee.auto_enable",
+ "mail.e2ee.auto_disable",
+ "mail.e2ee.notify_on_auto_disable",
+ "mail.enable_autocomplete",
+ "mail.forward_add_extension",
+ "mail.SpellCheckBeforeSend",
+ "mail.spellcheck.inline",
+ "mail.warn_on_send_accel_key",
+ "msgcompose.default_colors",
+ "pref.ldap.disable_button.edit_directories",
+
+ // Send options
+ "mailnews.sendformat.auto_downgrade",
+
+ // Privacy
+ "browser.safebrowsing.enabled",
+ "mail.phishing.detection.enabled",
+ "mail.spam.logging.enabled",
+ "mail.spam.manualMark",
+ "mail.spam.markAsReadOnSpam",
+ "mailnews.downloadToTempFile",
+ "mailnews.message_display.disable_remote_image",
+ "network.cookie.blockFutureCookies",
+ "places.history.enabled",
+ "pref.privacy.disable_button.cookie_exceptions",
+ "pref.privacy.disable_button.view_cookies",
+ "pref.privacy.disable_button.view_passwords",
+ "privacy.donottrackheader.enabled",
+ "security.disable_button.openCertManager",
+ "security.disable_button.openDeviceManager",
+
+ // Chat
+ "messenger.options.getAttentionOnNewMessages",
+ "messenger.status.reportIdle",
+ "messenger.status.awayWhenIdle",
+ "mail.chat.enabled",
+ "mail.chat.play_sound",
+ "mail.chat.show_desktop_notifications",
+ "purple.conversations.im.send_typing",
+ "purple.logging.log_chats",
+ "purple.logging.log_ims",
+ "purple.logging.log_system",
+
+ // Calendar views
+ "calendar.view.showLocation",
+ "calendar.view-minimonth.showWeekNumber",
+ "calendar.week.d0sundaysoff",
+ "calendar.week.d1mondaysoff",
+ "calendar.week.d2tuesdaysoff",
+ "calendar.week.d3wednesdaysoff",
+ "calendar.week.d4thursdaysoff",
+ "calendar.week.d5fridaysoff",
+ "calendar.week.d6saturdaysoff",
+
+ // Calendar general
+ "calendar.item.editInTab",
+ "calendar.item.promptDelete",
+ "calendar.timezone.useSystemTimezone",
+
+ // Alarms
+ "calendar.alarms.playsound",
+ "calendar.alarms.show",
+ "calendar.alarms.showmissed",
+
+ // Unlisted
+ "mail.operate_on_msgs_in_collapsed_threads",
+ ];
+
+ let integerPrefs = [
+ // Mail UI
+ "mail.pane_config.dynamic",
+ "mail.ui.display.dateformat.default",
+ "mail.ui.display.dateformat.thisweek",
+ "mail.ui.display.dateformat.today",
+ ];
+
+ // Platform-specific preferences
+ if (AppConstants.platform === "win") {
+ booleanPrefs.push("mail.biff.show_tray_icon", "mail.minimizeToTray");
+ }
+
+ if (AppConstants.platform !== "macosx") {
+ booleanPrefs.push(
+ "mail.biff.show_alert",
+ "mail.biff.use_system_alert",
+
+ // Notifications
+ "mail.biff.alert.show_preview",
+ "mail.biff.alert.show_sender",
+ "mail.biff.alert.show_subject"
+ );
+ }
+
+ // Compile-time flag-dependent preferences
+ if (AppConstants.HAVE_SHELL_SERVICE) {
+ booleanPrefs.push("mail.shell.checkDefaultClient");
+ }
+
+ if (AppConstants.MOZ_WIDGET_GTK) {
+ booleanPrefs.push("widget.gtk.overlay-scrollbars.enabled");
+ }
+
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ booleanPrefs.push("app.update.service.enabled");
+ }
+
+ if (AppConstants.MOZ_DATA_REPORTING) {
+ booleanPrefs.push("datareporting.healthreport.uploadEnabled");
+ }
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ booleanPrefs.push("browser.crashReports.unsubmittedCheck.autoSubmit2");
+ }
+
+ // Fetch and report preference values
+ for (let prefName of booleanPrefs) {
+ let prefValue = Services.prefs.getBoolPref(prefName, false);
+
+ Services.telemetry.keyedScalarSet(
+ "tb.preferences.boolean",
+ prefName,
+ prefValue
+ );
+ }
+
+ for (let prefName of integerPrefs) {
+ let prefValue = Services.prefs.getIntPref(prefName, 0);
+
+ Services.telemetry.keyedScalarSet(
+ "tb.preferences.integer",
+ prefName,
+ prefValue
+ );
+ }
+}
+
+function reportUIConfiguration() {
+ let docURL = "chrome://messenger/content/messenger.xhtml";
+
+ let folderTreeMode = Services.xulStore.getValue(docURL, "folderTree", "mode");
+ if (folderTreeMode) {
+ let folderTreeCompact = Services.xulStore.getValue(
+ docURL,
+ "folderTree",
+ "compact"
+ );
+ if (folderTreeCompact === "true") {
+ folderTreeMode += " (compact)";
+ }
+ Services.telemetry.scalarSet(
+ "tb.ui.configuration.folder_tree_modes",
+ folderTreeMode
+ );
+ }
+
+ let headerLayout = Services.xulStore.getValue(
+ docURL,
+ "messageHeader",
+ "layout"
+ );
+ if (headerLayout) {
+ headerLayout = JSON.parse(headerLayout);
+ for (let [key, value] of Object.entries(headerLayout)) {
+ if (key == "buttonStyle") {
+ value = { default: 0, "only-icons": 1, "only-text": 2 }[value];
+ }
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.message_header",
+ key,
+ value
+ );
+ }
+ }
+}
+
+/**
+ * Export these functions so we can test them. This object shouldn't be
+ * accessed outside of a test (hence the name).
+ */
+var MailTelemetryForTests = {
+ reportAccountTypes,
+ reportAccountSizes,
+ reportAddressBookTypes,
+ reportCalendars,
+ reportPreferences,
+ reportUIConfiguration,
+};
diff --git a/comm/mail/components/MessengerContentHandler.jsm b/comm/mail/components/MessengerContentHandler.jsm
new file mode 100644
index 0000000000..06d37a0811
--- /dev/null
+++ b/comm/mail/components/MessengerContentHandler.jsm
@@ -0,0 +1,793 @@
+/* -*- indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = [
+ "MessengerContentHandler",
+ "MessageDisplayContentHandler",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MimeParser: "resource:///modules/mimeParser.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+function resolveURIInternal(aCmdLine, aArgument) {
+ var uri = aCmdLine.resolveURI(aArgument);
+
+ if (!(uri instanceof Ci.nsIFileURL)) {
+ return uri;
+ }
+
+ try {
+ if (uri.file.exists()) {
+ return uri;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // We have interpreted the argument as a relative file URI, but the file
+ // doesn't exist. Try URI fixup heuristics: see bug 290782.
+
+ try {
+ uri = Services.uriFixup.getFixupURIInfo(aArgument, 0).preferredURI;
+ } catch (e) {
+ console.error(e);
+ }
+
+ return uri;
+}
+
+function handleIndexerResult(aFile) {
+ // Do this here because xpcshell isn't too happy with this at startup
+ // Make sure the folder tree is initialized
+ lazy.MailUtils.discoverFolders();
+
+ // Use the search integration module to convert the indexer result into a
+ // message header
+ const { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+ );
+ let msgHdr = SearchIntegration.handleResult(aFile);
+
+ // If we found a message header, open it, otherwise throw an exception
+ if (msgHdr) {
+ getOrOpen3PaneWindow().then(win => {
+ lazy.MailUtils.displayMessage(msgHdr);
+ });
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+}
+
+async function getOrOpen3PaneWindow() {
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+
+ if (!win) {
+ const startupPromise = new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe(subject) {
+ if (subject == win) {
+ Services.obs.removeObserver(this, "mail-startup-done");
+ resolve();
+ }
+ },
+ },
+ "mail-startup-done"
+ );
+ });
+
+ // Bug 277798 - we have to pass an argument to openWindow(), or
+ // else it won't honor the dialog=no instruction.
+ const argstring = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ win = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ argstring
+ );
+ await startupPromise;
+ }
+
+ await win.delayedStartupPromise;
+ return win;
+}
+
+/**
+ * Open the given uri.
+ * @param {nsIURI} uri - The uri to open.
+ */
+function openURI(uri) {
+ if (
+ !Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .isExposedProtocol(uri.scheme)
+ ) {
+ throw Components.Exception(`Can't open: ${uri.spec}`, Cr.NS_ERROR_FAILURE);
+ }
+
+ var channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ var loader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader);
+
+ // We cannot load a URI on startup asynchronously without protecting
+ // the startup
+
+ var loadgroup = Cc["@mozilla.org/network/load-group;1"].createInstance(
+ Ci.nsILoadGroup
+ );
+
+ var loadlistener = {
+ onStartRequest(aRequest) {
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ loadgroup.groupObserver = loadlistener;
+
+ var listener = {
+ doContent(ctype, preferred, request, handler) {
+ var newHandler = Cc[
+ "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display"
+ ].createInstance(Ci.nsIContentHandler);
+ newHandler.handleContent("application/x-message-display", this, request);
+ return true;
+ },
+ isPreferred(ctype, desired) {
+ if (ctype == "message/rfc822") {
+ return true;
+ }
+ return false;
+ },
+ canHandleContent(ctype, preferred, desired) {
+ return false;
+ },
+ loadCookie: null,
+ parentContentListener: null,
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIURIContentListener)) {
+ return this;
+ }
+
+ if (iid.equals(Ci.nsILoadGroup)) {
+ return loadgroup;
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ };
+ loader.openURI(channel, true, listener);
+}
+
+function MailDefaultHandler() {}
+
+MailDefaultHandler.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICommandLineHandler",
+ "nsICommandLineValidator",
+ "nsIFactory",
+ ]),
+
+ /* nsICommandLineHandler */
+
+ handle(cmdLine) {
+ if (
+ cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH &&
+ Services.startup.wasSilentlyStarted
+ ) {
+ // If we are starting up in silent mode, don't open a window. We also need
+ // to make sure that the application doesn't immediately exit, so stay in
+ // a LastWindowClosingSurvivalArea until a window opens.
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ Services.obs.addObserver(function windowOpenObserver() {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ Services.obs.removeObserver(windowOpenObserver, "domwindowopened");
+ }, "domwindowopened");
+ return;
+ }
+
+ try {
+ var remoteCommand = cmdLine.handleFlagWithParam("remote", true);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+
+ if (remoteCommand != null) {
+ try {
+ var a = /^\s*(\w+)\(([^\)]*)\)\s*$/.exec(remoteCommand);
+ var remoteVerb = a[1].toLowerCase();
+ var remoteParams = a[2].split(",");
+
+ switch (remoteVerb) {
+ case "openurl": {
+ let xuri = cmdLine.resolveURI(remoteParams[0]);
+ openURI(xuri);
+ break;
+ }
+ case "mailto": {
+ let xuri = cmdLine.resolveURI("mailto:" + remoteParams[0]);
+ openURI(xuri);
+ break;
+ }
+ case "xfedocommand":
+ // xfeDoCommand(openBrowser)
+ switch (remoteParams[0].toLowerCase()) {
+ case "openinbox": {
+ getOrOpen3PaneWindow().then(win => win.focus());
+ break;
+ }
+ case "composemessage": {
+ let argstring = Cc[
+ "@mozilla.org/supports-string;1"
+ ].createInstance(Ci.nsISupportsString);
+ remoteParams.shift();
+ argstring.data = remoteParams.join(",");
+ let args = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ args.appendElement(argstring);
+ args.appendElement(cmdLine);
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ args
+ )
+ );
+ break;
+ }
+ default:
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+ break;
+
+ default:
+ // Somebody sent us a remote command we don't know how to process:
+ // just abort.
+ throw Components.Exception(
+ `Unrecognized command: ${remoteParams[0]}`,
+ Cr.NS_ERROR_ABORT
+ );
+ }
+
+ cmdLine.preventDefault = true;
+ } catch (e) {
+ // If we had a -remote flag but failed to process it, throw
+ // NS_ERROR_ABORT so that the xremote code knows to return a failure
+ // back to the handling code.
+ dump(e);
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+ }
+
+ var chromeParam = cmdLine.handleFlagWithParam("chrome", false);
+ if (chromeParam) {
+ // The parameter specifies the window to open. This code should *not*
+ // open messenger.xhtml as well.
+ try {
+ let argstring = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ let _uri = resolveURIInternal(cmdLine, chromeParam);
+ // only load URIs which do not inherit chrome privs
+ if (
+ !Services.io.URIChainHasFlags(
+ _uri,
+ Ci.nsIProtocolHandler.URI_INHERITS_SECURITY_CONTEXT
+ )
+ ) {
+ Services.ww.openWindow(
+ null,
+ _uri.spec,
+ "_blank",
+ "chrome,dialog=no,all",
+ argstring
+ );
+ cmdLine.preventDefault = true;
+ }
+ } catch (e) {
+ dump(e);
+ }
+ }
+
+ if (cmdLine.handleFlag("silent", false)) {
+ cmdLine.preventDefault = true;
+ }
+
+ // -MapiStartup
+ // indicates that this startup is due to MAPI. Don't do anything for now.
+ cmdLine.handleFlag("MapiStartup", false);
+
+ if (cmdLine.handleFlag("mail", false)) {
+ getOrOpen3PaneWindow().then(win => win.focusOnMail(0));
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("addressbook", false)) {
+ getOrOpen3PaneWindow().then(win => win.toAddressBook());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("options", false)) {
+ getOrOpen3PaneWindow().then(win => win.openPreferencesTab());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("calendar", false)) {
+ getOrOpen3PaneWindow().then(win => win.toCalendar());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("keymanager", false)) {
+ getOrOpen3PaneWindow().then(win => win.openKeyManager());
+ cmdLine.preventDefault = true;
+ }
+
+ if (cmdLine.handleFlag("setDefaultMail", false)) {
+ var shell = Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+ shell.setDefaultClient(true, Ci.nsIShellService.MAIL);
+ }
+
+ // The URI might be passed as the argument to the file parameter
+ let uri = cmdLine.handleFlagWithParam("file", false);
+ // macOS passes `-url mid:<msgid>` into the command line, drop the -url flag.
+ cmdLine.handleFlag("url", false);
+
+ var count = cmdLine.length;
+ if (count) {
+ var i = 0;
+ while (i < count) {
+ var curarg = cmdLine.getArgument(i);
+ if (!curarg.startsWith("-")) {
+ break;
+ }
+
+ dump("Warning: unrecognized command line flag " + curarg + "\n");
+ // To emulate the pre-nsICommandLine behavior, we ignore the
+ // argument after an unrecognized flag.
+ i += 2;
+ // xxxbsmedberg: make me use the console service!
+ }
+
+ if (i < count) {
+ uri = cmdLine.getArgument(i);
+
+ // mailto: URIs are frequently passed with spaces in them. They should be
+ // escaped into %20, but we hack around bad clients, see bug 231032
+ if (uri.startsWith("mailto:")) {
+ while (++i < count) {
+ var testarg = cmdLine.getArgument(i);
+ if (testarg.startsWith("-")) {
+ break;
+ }
+
+ uri += " " + testarg;
+ }
+ }
+ }
+ }
+
+ if (!uri && cmdLine.preventDefault) {
+ return;
+ }
+
+ if (!uri && cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH) {
+ try {
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ window.focus();
+ return;
+ }
+ } catch (e) {
+ dump(e);
+ }
+ }
+ if (uri) {
+ if (/^file:/i.test(uri)) {
+ // Turn file URL into a file path so `resolveFile()` will work.
+ let fileURL = cmdLine.resolveURI(uri);
+ uri = fileURL.QueryInterface(Ci.nsIFileURL).file.path;
+ }
+ // Check for protocols first then look at the file ending.
+ // Protocols are able to contain file endings like '.ics'.
+ if (/^https?:/i.test(uri) || /^feed:/i.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ lazy.FeedUtils.subscribeToFeed(uri, null);
+ });
+ } else if (/^webcals?:\/\//i.test(uri)) {
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://calendar/content/calendar-creation.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,centerscreen",
+ Services.io.newURI(uri)
+ )
+ );
+ } else if (/^mid:/i.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ lazy.MailUtils.openMessageByMessageId(uri.slice(4));
+ });
+ } else if (/^(mailbox|imap|news)-message:\/\//.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ lazy.MailUtils.displayMessage(messenger.msgHdrFromURI(uri));
+ });
+ } else if (/^imap:/i.test(uri) || /^s?news:/i.test(uri)) {
+ getOrOpen3PaneWindow().then(win => {
+ openURI(cmdLine.resolveURI(uri));
+ });
+ } else if (
+ // While the leading web+ and ext+ identifiers may be case insensitive,
+ // the protocol identifiers must be lowercase.
+ /^(web|ext)\+[a-z]+:/i.test(uri) &&
+ /^[a-z]+:/.test(uri.split("+")[1])
+ ) {
+ getOrOpen3PaneWindow().then(win => {
+ win.gTabmail.openTab("contentTab", {
+ url: uri,
+ linkHandler: "single-site",
+ background: false,
+ duplicate: true,
+ });
+ });
+ } else if (
+ uri.toLowerCase().endsWith(".mozeml") ||
+ uri.toLowerCase().endsWith(".wdseml")
+ ) {
+ handleIndexerResult(cmdLine.resolveFile(uri));
+ cmdLine.preventDefault = true;
+ } else if (uri.toLowerCase().endsWith(".eml")) {
+ // Open this eml in a new message window
+ let file = cmdLine.resolveFile(uri);
+ // No point in trying to open a file if it doesn't exist or is empty
+ if (file.exists() && file.fileSize > 0) {
+ // Read this eml and extract its headers to check for X-Unsent.
+ let fstream = null;
+ let headers = new Map();
+ try {
+ fstream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ fstream.init(file, -1, 0, 0);
+ let data = lazy.NetUtil.readInputStreamToString(
+ fstream,
+ fstream.available()
+ );
+ headers = lazy.MimeParser.extractHeaders(data);
+ } catch (e) {
+ // Ignore errors on reading the eml or extracting its headers. The
+ // test for the X-Unsent header below will fail and the message
+ // window will take care of any error handling.
+ } finally {
+ if (fstream) {
+ fstream.close();
+ }
+ }
+
+ // Get the URL for this file
+ let fileURL = Services.io
+ .newFileURI(file)
+ .QueryInterface(Ci.nsIFileURL);
+ fileURL = fileURL
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ if (headers.get("X-Unsent") == "1") {
+ getOrOpen3PaneWindow().then(win => {
+ const msgWindow = Cc[
+ "@mozilla.org/messenger/msgwindow;1"
+ ].createInstance(Ci.nsIMsgWindow);
+ MailServices.compose.OpenComposeWindow(
+ win,
+ {},
+ fileURL.spec,
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ null,
+ headers.get("from"),
+ msgWindow
+ );
+ });
+ } else {
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ )
+ );
+ }
+ cmdLine.preventDefault = true;
+ } else {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let title, message;
+ if (!file.exists()) {
+ title = bundle.GetStringFromName("fileNotFoundTitle");
+ message = bundle.formatStringFromName("fileNotFoundMsg", [
+ file.path,
+ ]);
+ } else {
+ // The file is empty
+ title = bundle.GetStringFromName("fileEmptyTitle");
+ message = bundle.formatStringFromName("fileEmptyMsg", [file.path]);
+ }
+
+ Services.prompt.alert(null, title, message);
+ }
+ } else if (uri.toLowerCase().endsWith(".ics")) {
+ // An .ics calendar file! Open the ics file dialog.
+ let file = cmdLine.resolveFile(uri);
+ if (file.exists() && file.fileSize > 0) {
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://calendar/content/calendar-ics-file-dialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,centerscreen",
+ file
+ )
+ );
+ }
+ } else if (uri.toLowerCase().endsWith(".vcf")) {
+ // A VCard! Be smart and open the "add contact" dialog.
+ let file = cmdLine.resolveFile(uri);
+ if (file.exists() && file.fileSize > 0) {
+ let winPromise = getOrOpen3PaneWindow();
+ let uriSpec = Services.io.newFileURI(file).spec;
+ lazy.NetUtil.asyncFetch(
+ { uri: uriSpec, loadUsingSystemPrincipal: true },
+ function (inputStream, status) {
+ if (!Components.isSuccessCode(status)) {
+ return;
+ }
+
+ let data = lazy.NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available()
+ );
+ // Try to detect the character set and decode. Only UTF-8 is
+ // valid from vCard 4.0, but we support older versions, so other
+ // charsets are possible.
+ let charset = Cc["@mozilla.org/messengercompose/computils;1"]
+ .createInstance(Ci.nsIMsgCompUtils)
+ .detectCharset(data);
+ let buffer = new Uint8Array(
+ Array.from(data, c => c.charCodeAt(0))
+ );
+ data = new TextDecoder(charset).decode(buffer);
+
+ winPromise.then(win =>
+ win.toAddressBook({
+ action: "create",
+ vCard: decodeURIComponent(data),
+ })
+ );
+ }
+ );
+ }
+ } else {
+ getOrOpen3PaneWindow().then(win => {
+ // This must be a regular filename. Use it to create a new message
+ // with attachment.
+ let msgParams = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ let localFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ try {
+ // Unescape the URI so that we work with clients that escape spaces.
+ localFile.initWithPath(unescape(uri));
+ attachment.url = fileHandler.getURLSpecFromActualFile(localFile);
+ composeFields.addAttachment(attachment);
+
+ msgParams.type = Ci.nsIMsgCompType.New;
+ msgParams.format = Ci.nsIMsgCompFormat.Default;
+ msgParams.composeFields = composeFields;
+
+ MailServices.compose.OpenComposeWindowWithParams(win, msgParams);
+ } catch (e) {
+ // Let protocol handlers try to take care.
+ openURI(cmdLine.resolveURI(uri));
+ }
+ });
+ }
+ } else {
+ getOrOpen3PaneWindow();
+ }
+ },
+
+ /* nsICommandLineValidator */
+ validate(cmdLine) {
+ var osintFlagIdx = cmdLine.findFlag("osint", false);
+ if (osintFlagIdx == -1) {
+ return;
+ }
+
+ // Other handlers may use osint so only handle the osint flag if the mail
+ // or compose flag is also present and the command line is valid.
+ var mailFlagIdx = cmdLine.findFlag("mail", false);
+ var composeFlagIdx = cmdLine.findFlag("compose", false);
+ if (mailFlagIdx == -1 && composeFlagIdx == -1) {
+ return;
+ }
+
+ // If both flags are present use the first flag found so the command line
+ // length test will fail.
+ if (mailFlagIdx > -1 && composeFlagIdx > -1) {
+ var actionFlagIdx =
+ mailFlagIdx > composeFlagIdx ? composeFlagIdx : mailFlagIdx;
+ } else {
+ actionFlagIdx = mailFlagIdx > -1 ? mailFlagIdx : composeFlagIdx;
+ }
+
+ if (actionFlagIdx && osintFlagIdx > -1) {
+ var param = cmdLine.getArgument(actionFlagIdx + 1);
+ if (
+ cmdLine.length != actionFlagIdx + 2 ||
+ /thunderbird.url.(mailto|news):/.test(param)
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ABORT);
+ }
+ cmdLine.handleFlag("osint", false);
+ }
+ },
+
+ openInExternal(uri) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ },
+
+ handleContent(aContentType, aWindowContext, aRequest) {
+ try {
+ if (
+ !Cc["@mozilla.org/webnavigation-info;1"]
+ .getService(Ci.nsIWebNavigationInfo)
+ .isTypeSupported(aContentType, null)
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ aRequest.QueryInterface(Ci.nsIChannel);
+
+ // For internal protocols (e.g. imap, mailbox, mailto), we want to handle
+ // them internally as we know what to do. For http and https we don't
+ // actually deal with external windows very well, so we redirect them to
+ // the external browser.
+ if (!aRequest.URI.schemeIs("http") && !aRequest.URI.schemeIs("https")) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ this.openInExternal(aRequest.URI);
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ },
+
+ helpInfo:
+ " -mail Go to the mail tab.\n" +
+ " -addressbook Go to the address book tab.\n" +
+ " -calendar Go to the calendar tab.\n" +
+ " -options Go to the settings tab.\n" +
+ " -file Open the specified email file or ICS calendar file.\n" +
+ " -setDefaultMail Set this app as the default mail client.\n" +
+ " -keymanager Open the OpenPGP Key Manager.\n",
+
+ /* nsIFactory */
+
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ },
+};
+
+function MessengerContentHandler() {
+ if (!gMessengerContentHandler) {
+ gMessengerContentHandler = this;
+ }
+ return gMessengerContentHandler;
+}
+
+MessengerContentHandler.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIContentHandler"]),
+};
+
+var gMessengerContentHandler = new MailDefaultHandler();
+
+/**
+ * Open a message/rfc822 or eml file in a new msg window.
+ *
+ * @implements {nsIContentHandler}
+ */
+class MessageDisplayContentHandler {
+ QueryInterface = ChromeUtils.generateQI(["nsIContentHandler"]);
+
+ handleContent(contentType, windowContext, request) {
+ let channel = request.QueryInterface(Ci.nsIChannel);
+ if (!channel) {
+ throw Components.Exception(
+ "Expecting an nsIChannel",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let uri = channel.URI;
+ let mailnewsUrl;
+ try {
+ mailnewsUrl = uri.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ } catch (e) {}
+ if (mailnewsUrl) {
+ let queryPart = mailnewsUrl.query.replace(
+ "type=message/rfc822",
+ "type=application/x-message-display"
+ );
+ uri = mailnewsUrl.mutate().setQuery(queryPart).finalize();
+ } else if (uri.scheme == "file") {
+ uri = uri
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+ }
+ getOrOpen3PaneWindow().then(win =>
+ Services.ww.openWindow(
+ win,
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ uri
+ )
+ );
+ }
+}
diff --git a/comm/mail/components/StartupRecorder.jsm b/comm/mail/components/StartupRecorder.jsm
new file mode 100644
index 0000000000..f7443b6c57
--- /dev/null
+++ b/comm/mail/components/StartupRecorder.jsm
@@ -0,0 +1,229 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["StartupRecorder"];
+
+const Cm = Components.manager;
+Cm.QueryInterface(Ci.nsIServiceManager);
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+let firstPaintNotification = "widget-first-paint";
+// widget-first-paint fires much later than expected on Linux.
+if (AppConstants.platform == "linux") {
+ firstPaintNotification = "xul-window-visible";
+}
+
+let win, canvas;
+let paints = [];
+let afterPaintListener = () => {
+ let width, height;
+ canvas.width = width = win.innerWidth;
+ canvas.height = height = win.innerHeight;
+ if (width < 1 || height < 1) {
+ return;
+ }
+ let ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
+
+ ctx.drawWindow(
+ win,
+ 0,
+ 0,
+ width,
+ height,
+ "white",
+ ctx.DRAWWINDOW_DO_NOT_FLUSH |
+ ctx.DRAWWINDOW_DRAW_VIEW |
+ ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES |
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS
+ );
+ paints.push({
+ data: ctx.getImageData(0, 0, width, height).data,
+ width,
+ height,
+ });
+};
+
+/**
+ * The StartupRecorder component observes notifications at various stages of
+ * startup and records the set of JS modules that were already loaded at
+ * each of these points.
+ * The records are meant to be used by startup tests in
+ * browser/base/content/test/performance
+ * This component only exists in nightly and debug builds, it doesn't ship in
+ * our release builds.
+ */
+function StartupRecorder() {
+ this.wrappedJSObject = this;
+ this.data = {
+ images: {
+ "image-drawing": new Set(),
+ "image-loading": new Set(),
+ },
+ code: {},
+ extras: {},
+ prefStats: {},
+ };
+ this.done = new Promise(resolve => {
+ this._resolve = resolve;
+ });
+}
+StartupRecorder.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ record(name) {
+ ChromeUtils.addProfilerMarker("startupRecorder:" + name);
+ this.data.code[name] = {
+ modules: Cu.loadedJSModules.concat(Cu.loadedESModules),
+ services: Object.keys(Cc).filter(c => {
+ try {
+ return Cm.isServiceInstantiatedByContractID(c, Ci.nsISupports);
+ } catch (e) {
+ return false;
+ }
+ }),
+ };
+ this.data.extras[name] = {
+ hiddenWindowLoaded: Services.appShell.hasHiddenWindow,
+ };
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "app-startup" || topic == "content-process-ready-for-script") {
+ // Don't do anything in xpcshell.
+ if (Services.appinfo.ID != "{3550f703-e582-4d05-9a08-453d09bdfdc6}") {
+ return;
+ }
+
+ if (
+ !Services.prefs.getBoolPref("browser.startup.record", false) &&
+ !Services.prefs.getBoolPref("browser.startup.recordImages", false)
+ ) {
+ this._resolve();
+ this._resolve = null;
+ return;
+ }
+
+ // We can't ensure our observer will be called first or last, so the list of
+ // topics we observe here should avoid the topics used to trigger things
+ // during startup (eg. the topics observed by BrowserGlue.jsm).
+ let topics = [
+ "profile-do-change", // This catches stuff loaded during app-startup
+ "toplevel-window-ready", // Catches stuff from final-ui-startup
+ firstPaintNotification,
+ "mail-startup-done",
+ "mail-startup-idle-tasks-finished",
+ ];
+
+ if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) {
+ // For code simplicify, recording images excludes the other startup
+ // recorder behaviors, so we can observe only the image topics.
+ topics = [
+ "image-loading",
+ "image-drawing",
+ "mail-startup-idle-tasks-finished",
+ ];
+ }
+ for (let t of topics) {
+ Services.obs.addObserver(this, t);
+ }
+ return;
+ }
+
+ // We only care about the first paint notification for browser windows, and
+ // not other types (for example, the gfx sanity test window)
+ if (topic == firstPaintNotification) {
+ // In the case we're handling xul-window-visible, we'll have been handed
+ // an nsIAppWindow instead of an nsIDOMWindow.
+ if (subject instanceof Ci.nsIAppWindow) {
+ subject = subject
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindow);
+ }
+
+ if (
+ subject.document.documentElement.getAttribute("windowtype") !=
+ "mail:3pane"
+ ) {
+ return;
+ }
+ }
+
+ if (topic == "image-drawing" || topic == "image-loading") {
+ this.data.images[topic].add(data);
+ return;
+ }
+
+ Services.obs.removeObserver(this, topic);
+
+ if (topic == firstPaintNotification) {
+ // Because of the check for mail:3pane we made earlier, we know
+ // that if we got here, then the subject must be the first browser window.
+ win = subject;
+ canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.mozOpaque = true;
+ afterPaintListener();
+ win.addEventListener("MozAfterPaint", afterPaintListener);
+ }
+
+ // TODO: Figure out what can replace this section.
+ if (topic == "mail-startup-done") {
+ // We use idleDispatchToMainThread here to record the set of
+ // loaded scripts after we are fully done with startup and ready
+ // to react to user events.
+ Services.tm.dispatchToMainThread(
+ this.record.bind(this, "before handling user events")
+ );
+ } else if (topic == "mail-startup-idle-tasks-finished") {
+ if (Services.prefs.getBoolPref("browser.startup.recordImages", false)) {
+ Services.obs.removeObserver(this, "image-drawing");
+ Services.obs.removeObserver(this, "image-loading");
+ this._resolve();
+ this._resolve = null;
+ return;
+ }
+
+ this.record("before becoming idle");
+ win.removeEventListener("MozAfterPaint", afterPaintListener);
+ win = null;
+ this.data.frames = paints;
+ this.data.prefStats = {};
+ if (AppConstants.DEBUG) {
+ Services.prefs.readStats(
+ (key, value) => (this.data.prefStats[key] = value)
+ );
+ }
+ paints = null;
+
+ if (!Services.env.exists("MOZ_PROFILER_STARTUP_PERFORMANCE_TEST")) {
+ this._resolve();
+ this._resolve = null;
+ return;
+ }
+
+ Services.profiler.getProfileDataAsync().then(profileData => {
+ this.data.profile = profileData;
+ // There's no equivalent StartProfiler call in this file because the
+ // profiler is started using the MOZ_PROFILER_STARTUP environment
+ // variable in browser/base/content/test/performance/browser.ini
+ Services.profiler.StopProfiler();
+
+ this._resolve();
+ this._resolve = null;
+ });
+ } else {
+ const topicsToNames = {
+ "profile-do-change": "before profile selection",
+ "toplevel-window-ready": "before opening first browser window",
+ };
+ topicsToNames[firstPaintNotification] = "before first paint";
+ this.record(topicsToNames[topic]);
+ }
+ },
+};
diff --git a/comm/mail/components/about-support/AboutSupportMac.jsm b/comm/mail/components/about-support/AboutSupportMac.jsm
new file mode 100644
index 0000000000..16f1c258f8
--- /dev/null
+++ b/comm/mail/components/about-support/AboutSupportMac.jsm
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["AboutSupportPlatform"];
+
+var AboutSupportPlatform = {
+ /**
+ * Given an nsIFile, gets the file system type. The type is returned as a
+ * string. Possible values are "network", "local", "unknown" and null.
+ */
+ getFileSystemType(aFile) {
+ // Not implemented
+ return null;
+ },
+};
diff --git a/comm/mail/components/about-support/AboutSupportUnix.jsm b/comm/mail/components/about-support/AboutSupportUnix.jsm
new file mode 100644
index 0000000000..a27b3c99c5
--- /dev/null
+++ b/comm/mail/components/about-support/AboutSupportUnix.jsm
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["AboutSupportPlatform"];
+
+// JS ctypes are needed to get at the data we need
+var { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+);
+var GFile = ctypes.StructType("GFile");
+var GFileInfo = ctypes.StructType("GFileInfo");
+var GError = ctypes.StructType("GError");
+var GCancellable = ctypes.StructType("GCancellable");
+
+var G_FILE_ATTRIBUTE_FILESYSTEM_TYPE = "filesystem::type";
+
+var kNetworkFilesystems = ["afs", "cifs", "nfs", "smb"];
+
+// These libraries might not be available on all systems.
+var gLibsExist = false;
+try {
+ // GC is responsible for closing these libraries if they exist.
+ var glib = ctypes.open("libglib-2.0.so.0");
+ var gobject = ctypes.open("libgobject-2.0.so.0");
+ var gio = ctypes.open("libgio-2.0.so.0");
+ gLibsExist = true;
+} catch (ex) {}
+
+if (gLibsExist) {
+ var g_free = glib.declare(
+ "g_free",
+ ctypes.default_abi,
+ ctypes.void_t,
+ ctypes.voidptr_t
+ );
+ var g_object_unref = gobject.declare(
+ "g_object_unref",
+ ctypes.default_abi,
+ ctypes.void_t,
+ ctypes.voidptr_t
+ );
+}
+
+var AboutSupportPlatform = {
+ /**
+ * Given an nsIFile, gets the file system type. The type is returned as a
+ * string. Possible values are "network", "local", "unknown" and null.
+ */
+ getFileSystemType(aFile) {
+ // Check if the libs exist.
+ if (!gLibsExist) {
+ return "unknown";
+ }
+
+ try {
+ // Given a UTF-8 string, converts it to the current Glib locale.
+ let g_filename_from_utf8 = glib.declare(
+ "g_filename_from_utf8",
+ ctypes.default_abi,
+ ctypes.char.ptr, // return type: glib locale string
+ ctypes.char.ptr, // in: utf8string
+ ctypes.ssize_t, // in: len
+ ctypes.size_t.ptr, // out: bytes_read
+ ctypes.size_t.ptr, // out: bytes_written
+ GError.ptr // out: error
+ );
+ // Yes, we want function scoping for variables we need to free in the
+ // finally block. I think this is better than declaring lots of variables
+ // on top.
+ var filePath = g_filename_from_utf8(aFile.path, -1, null, null, null);
+ if (filePath.isNull()) {
+ throw new Error(
+ "Unable to convert " + aFile.path + " into GLib encoding"
+ );
+ }
+
+ // Given a path, creates a new GFile for it.
+ let g_file_new_for_path = gio.declare(
+ "g_file_new_for_path",
+ ctypes.default_abi,
+ GFile.ptr, // return type: a newly-allocated GFile
+ ctypes.char.ptr // in: path
+ );
+ var glibFile = g_file_new_for_path(filePath);
+
+ // Given a GFile, queries the given attributes and returns them
+ // as a GFileInfo.
+ let g_file_query_filesystem_info = gio.declare(
+ "g_file_query_filesystem_info",
+ ctypes.default_abi,
+ GFileInfo.ptr, // return type
+ GFile.ptr, // in: file
+ ctypes.char.ptr, // in: attributes
+ GCancellable.ptr, // in: cancellable
+ GError.ptr // out: error
+ );
+ var glibFileInfo = g_file_query_filesystem_info(
+ glibFile,
+ G_FILE_ATTRIBUTE_FILESYSTEM_TYPE,
+ null,
+ null
+ );
+ if (glibFileInfo.isNull()) {
+ throw new Error("Unabled to retrieve GLib file info for " + aFile.path);
+ }
+
+ let g_file_info_get_attribute_string = gio.declare(
+ "g_file_info_get_attribute_string",
+ ctypes.default_abi,
+ ctypes.char.ptr, // return type: file system type (do not free)
+ GFileInfo.ptr, // in: info
+ ctypes.char.ptr // in: attribute
+ );
+ let fsType = g_file_info_get_attribute_string(
+ glibFileInfo,
+ G_FILE_ATTRIBUTE_FILESYSTEM_TYPE
+ );
+ if (fsType.isNull()) {
+ return "unknown";
+ } else if (kNetworkFilesystems.includes(fsType.readString())) {
+ return "network";
+ }
+ return "local";
+ } finally {
+ if (filePath) {
+ g_free(filePath);
+ }
+ if (glibFile && !glibFile.isNull()) {
+ g_object_unref(glibFile);
+ }
+ if (glibFileInfo && !glibFileInfo.isNull()) {
+ g_object_unref(glibFileInfo);
+ }
+ }
+ },
+};
diff --git a/comm/mail/components/about-support/AboutSupportWin32.jsm b/comm/mail/components/about-support/AboutSupportWin32.jsm
new file mode 100644
index 0000000000..4c5e36c5bd
--- /dev/null
+++ b/comm/mail/components/about-support/AboutSupportWin32.jsm
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["AboutSupportPlatform"];
+
+// JS ctypes are needed to get at the data we need
+var { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+);
+
+var BOOL = ctypes.int32_t;
+var DRIVE_UNKNOWN = 0;
+var DRIVE_NETWORK = 4;
+
+var AboutSupportPlatform = {
+ /**
+ * Given an nsIFile, gets the file system type. The type is returned as a
+ * string. Possible values are "network", "local", "unknown" and null.
+ */
+ getFileSystemType(aFile) {
+ let kernel32 = ctypes.open("kernel32.dll");
+
+ try {
+ // Returns the path of the volume a file is on.
+ let GetVolumePathName = kernel32.declare(
+ "GetVolumePathNameW",
+ ctypes.winapi_abi,
+ BOOL, // return type: 1 indicates success, 0 failure
+ ctypes.char16_t.ptr, // in: lpszFileName
+ ctypes.char16_t.ptr, // out: lpszVolumePathName
+ ctypes.uint32_t // in: cchBufferLength
+ );
+
+ // Returns the last error.
+ let GetLastError = kernel32.declare(
+ "GetLastError",
+ ctypes.winapi_abi,
+ ctypes.uint32_t // return type: the last error
+ );
+
+ let filePath = aFile.path;
+ // The volume path should be at most 1 greater than than the length of the
+ // path -- add 1 for a trailing backslash if necessary, and 1 for the
+ // terminating null character. Note that the parentheses around the type are
+ // necessary for new to apply correctly.
+ let volumePath = new (ctypes.char16_t.array(filePath.length + 2))();
+
+ if (!GetVolumePathName(filePath, volumePath, volumePath.length)) {
+ throw new Error(
+ "Unable to get volume path for " +
+ filePath +
+ ", error " +
+ GetLastError()
+ );
+ }
+
+ // Returns the type of the drive.
+ let GetDriveType = kernel32.declare(
+ "GetDriveTypeW",
+ ctypes.winapi_abi,
+ ctypes.uint32_t, // return type: the drive type
+ ctypes.char16_t.ptr // in: lpRootPathName
+ );
+ let type = GetDriveType(volumePath);
+ // http://msdn.microsoft.com/en-us/library/aa364939
+ if (type == DRIVE_UNKNOWN) {
+ return "unknown";
+ } else if (type == DRIVE_NETWORK) {
+ return "network";
+ }
+ return "local";
+ } finally {
+ kernel32.close();
+ }
+ },
+};
diff --git a/comm/mail/components/about-support/content/aboutSupport.js b/comm/mail/components/about-support/content/aboutSupport.js
new file mode 100644
index 0000000000..fc73b59029
--- /dev/null
+++ b/comm/mail/components/about-support/content/aboutSupport.js
@@ -0,0 +1,1729 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* This file is a copy of mozilla/toolkit/content/aboutSupport.js with
+ modifications for TB. */
+
+/* globals AboutSupportPlatform, populateAccountsSection, sendViaEmail
+ populateCalendarsSection, populateChatSection, populateLibrarySection */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { Troubleshoot } = ChromeUtils.importESModule(
+ "resource://gre/modules/Troubleshoot.sys.mjs"
+);
+var { ResetProfile } = ChromeUtils.importESModule(
+ "resource://gre/modules/ResetProfile.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+ ProcessType: "resource://gre/modules/ProcessType.sys.mjs",
+});
+
+// added for TB
+/* Node classes. All of these are mutually exclusive. */
+
+// Any nodes marked with this class will be considered part of the UI only,
+// and therefore will not be copied.
+var CLASS_DATA_UIONLY = "data-uionly";
+
+// Any nodes marked with this class will be considered private and will be
+// hidden if the user requests only public data to be shown or copied.
+var CLASS_DATA_PRIVATE = "data-private";
+
+// Any nodes marked with this class will only be displayed when the user chooses
+// to not display private data.
+var CLASS_DATA_PUBLIC = "data-public";
+// end of TB addition
+window.addEventListener("load", function onload(event) {
+ try {
+ window.removeEventListener("load", onload);
+ Troubleshoot.snapshot().then(async snapshot => {
+ for (let prop in snapshotFormatters) {
+ try {
+ await snapshotFormatters[prop](snapshot[prop]);
+ } catch (e) {
+ console.error(
+ "stack of snapshot error for about:support: ",
+ e,
+ ": ",
+ e.stack
+ );
+ }
+ }
+ }, console.error);
+ populateActionBox();
+ setupEventListeners();
+
+ let hasWinPackageId = false;
+ try {
+ hasWinPackageId = Services.sysinfo.getProperty("hasWinPackageId");
+ } catch (_ex) {
+ // The hasWinPackageId property doesn't exist; assume it would be false.
+ }
+ if (hasWinPackageId) {
+ $("update-dir-row").hidden = true;
+ $("update-history-row").hidden = true;
+ }
+ } catch (e) {
+ console.error(
+ "stack of load error for about:support: " + e + ": " + e.stack
+ );
+ }
+ // added for TB
+ populateAccountsSection();
+ populateCalendarsSection();
+ populateChatSection();
+ populateLibrarySection();
+ document
+ .getElementById("check-show-private-data")
+ .addEventListener("change", () => onShowPrivateDataChange());
+});
+
+function prefsTable(data) {
+ return sortedArrayFromObject(data).map(function ([name, value]) {
+ return $.new("tr", [
+ $.new("td", name, "pref-name"),
+ // Very long preference values can cause users problems when they
+ // copy and paste them into some text editors. Long values generally
+ // aren't useful anyway, so truncate them to a reasonable length.
+ $.new("td", String(value).substr(0, 120), "pref-value"),
+ ]);
+ });
+}
+
+// Fluent uses lisp-case IDs so this converts
+// the SentenceCase info IDs to lisp-case.
+const FLUENT_IDENT_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
+function toFluentID(str) {
+ if (!FLUENT_IDENT_REGEX.test(str)) {
+ return null;
+ }
+ return str
+ .toString()
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .toLowerCase();
+}
+
+// Each property in this object corresponds to a property in Troubleshoot.jsm's
+// snapshot data. Each function is passed its property's corresponding data,
+// and it's the function's job to update the page with it.
+var snapshotFormatters = {
+ async application(data) {
+ $("application-box").textContent = data.name;
+ $("useragent-box").textContent = data.userAgent;
+ $("os-box").textContent = data.osVersion;
+ if (data.osTheme) {
+ $("os-theme-box").textContent = data.osTheme;
+ } else {
+ $("os-theme-row").hidden = true;
+ }
+ if (AppConstants.platform == "macosx") {
+ $("rosetta-box").textContent = data.rosetta;
+ }
+ $("binary-box").textContent = Services.dirsvc.get(
+ "XREExeF",
+ Ci.nsIFile
+ ).path;
+ $("supportLink").href = data.supportURL;
+ let version = AppConstants.MOZ_APP_VERSION_DISPLAY;
+ if (data.vendor) {
+ version += " (" + data.vendor + ")";
+ }
+ $("version-box").textContent = version;
+ $("buildid-box").textContent = data.buildID;
+ $("distributionid-box").textContent = data.distributionID;
+ if (data.updateChannel) {
+ $("updatechannel-box").textContent = data.updateChannel;
+ }
+ if (AppConstants.MOZ_UPDATER) {
+ $("update-dir-box").textContent = Services.dirsvc.get(
+ "UpdRootD",
+ Ci.nsIFile
+ ).path;
+ }
+
+ try {
+ let launcherStatusTextId = "launcher-process-status-unknown";
+ switch (data.launcherProcessState) {
+ case 0:
+ case 1:
+ case 2:
+ launcherStatusTextId =
+ "launcher-process-status-" + data.launcherProcessState;
+ break;
+ }
+
+ document.l10n.setAttributes(
+ $("launcher-process-box"),
+ launcherStatusTextId
+ );
+ } catch (e) {}
+
+ const STATUS_STRINGS = {
+ experimentControl: "fission-status-experiment-control",
+ experimentTreatment: "fission-status-experiment-treatment",
+ disabledByE10sEnv: "fission-status-disabled-by-e10s-env",
+ enabledByEnv: "fission-status-enabled-by-env",
+ enabledByDefault: "fission-status-enabled-by-default",
+ disabledByDefault: "fission-status-disabled-by-default",
+ enabledByUserPref: "fission-status-enabled-by-user-pref",
+ disabledByUserPref: "fission-status-disabled-by-user-pref",
+ disabledByE10sOther: "fission-status-disabled-by-e10s-other",
+ };
+
+ let statusTextId = STATUS_STRINGS[data.fissionDecisionStatus];
+
+ document.l10n.setAttributes(
+ $("multiprocess-box-process-count"),
+ "multi-process-windows",
+ {
+ remoteWindows: data.numRemoteWindows,
+ totalWindows: data.numTotalWindows,
+ }
+ );
+ document.l10n.setAttributes(
+ $("fission-box-process-count"),
+ "fission-windows",
+ {
+ fissionWindows: data.numFissionWindows,
+ totalWindows: data.numTotalWindows,
+ }
+ );
+ document.l10n.setAttributes($("fission-box-status"), statusTextId);
+
+ if (Services.policies) {
+ let policiesStrId = "";
+ let aboutPolicies = "about:policies";
+ switch (data.policiesStatus) {
+ case Services.policies.INACTIVE:
+ policiesStrId = "policies-inactive";
+ break;
+
+ case Services.policies.ACTIVE:
+ policiesStrId = "policies-active";
+ aboutPolicies += "#active";
+ break;
+
+ default:
+ policiesStrId = "policies-error";
+ aboutPolicies += "#errors";
+ break;
+ }
+
+ if (data.policiesStatus != Services.policies.INACTIVE) {
+ let activePolicies = $.new("a", null, null, {
+ href: aboutPolicies,
+ });
+ document.l10n.setAttributes(activePolicies, policiesStrId);
+ $("policies-status").appendChild(activePolicies);
+ } else {
+ document.l10n.setAttributes($("policies-status"), policiesStrId);
+ }
+ } else {
+ $("policies-status-row").hidden = true;
+ }
+
+ let keyLocationServiceGoogleFound = data.keyLocationServiceGoogleFound
+ ? "found"
+ : "missing";
+ document.l10n.setAttributes(
+ $("key-location-service-google-box"),
+ keyLocationServiceGoogleFound
+ );
+
+ let keySafebrowsingGoogleFound = data.keySafebrowsingGoogleFound
+ ? "found"
+ : "missing";
+ document.l10n.setAttributes(
+ $("key-safebrowsing-google-box"),
+ keySafebrowsingGoogleFound
+ );
+
+ let keyMozillaFound = data.keyMozillaFound ? "found" : "missing";
+ document.l10n.setAttributes($("key-mozilla-box"), keyMozillaFound);
+
+ $("safemode-box").textContent = data.safeMode;
+
+ const formatHumanReadableBytes = (elem, bytes) => {
+ let size = DownloadUtils.convertByteUnits(bytes);
+ document.l10n.setAttributes(elem, "app-basics-data-size", {
+ value: size[0],
+ unit: size[1],
+ });
+ };
+
+ formatHumanReadableBytes($("memory-size-box"), data.memorySizeBytes);
+ formatHumanReadableBytes($("disk-available-box"), data.diskAvailableBytes);
+
+ // added for TB
+ // Add profile path as private info into the page.
+ let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let profElem = document.getElementById("profile-dir-button").parentNode;
+ let profDirNode = document.getElementById("profile-dir-box");
+ profDirNode.setAttribute("class", CLASS_DATA_PRIVATE);
+ let profLinkNode = document.createElement("a");
+ profLinkNode.setAttribute("href", Services.io.newFileURI(currProfD).spec);
+ profLinkNode.addEventListener("click", function (event) {
+ openProfileDirectory();
+ event.preventDefault();
+ });
+ let profPathNode = document.createTextNode(currProfD.path);
+ profLinkNode.appendChild(profPathNode);
+ profDirNode.appendChild(profLinkNode);
+ profElem.appendChild(document.createTextNode(" "));
+
+ // Show type of filesystem detected.
+ let fsType;
+ try {
+ fsType = AboutSupportPlatform.getFileSystemType(currProfD);
+ if (fsType) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutSupportMail.properties"
+ );
+ let fsText = bundle.GetStringFromName("fsType." + fsType);
+ let fsTextNode = document.createElement("span");
+ fsTextNode.textContent = fsText;
+ profElem.appendChild(fsTextNode);
+ }
+ } catch (x) {
+ console.error(x);
+ }
+ // end of TB addition
+ },
+
+ crashes(data) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ let daysRange = Troubleshoot.kMaxCrashAge / (24 * 60 * 60 * 1000);
+ document.l10n.setAttributes($("crashes-title"), "report-crash-for-days", {
+ days: daysRange,
+ });
+ let reportURL;
+ try {
+ reportURL = Services.prefs.getCharPref("breakpad.reportURL");
+ // Ignore any non http/https urls
+ if (!/^https?:/i.test(reportURL)) {
+ reportURL = null;
+ }
+ } catch (e) {}
+ if (!reportURL) {
+ $("crashes-noConfig").style.display = "block";
+ $("crashes-noConfig").classList.remove("no-copy");
+ return;
+ }
+ $("crashes-allReports").style.display = "block";
+
+ if (data.pending > 0) {
+ document.l10n.setAttributes(
+ $("crashes-allReportsWithPending"),
+ "pending-reports",
+ { reports: data.pending }
+ );
+ }
+
+ let dateNow = new Date();
+ $.append(
+ $("crashes-tbody"),
+ data.submitted.map(function (crash) {
+ let date = new Date(crash.date);
+ let timePassed = dateNow - date;
+ let formattedDateStrId;
+ let formattedDateStrArgs;
+ if (timePassed >= 24 * 60 * 60 * 1000) {
+ let daysPassed = Math.round(timePassed / (24 * 60 * 60 * 1000));
+ formattedDateStrId = "crashes-time-days";
+ formattedDateStrArgs = { days: daysPassed };
+ } else if (timePassed >= 60 * 60 * 1000) {
+ let hoursPassed = Math.round(timePassed / (60 * 60 * 1000));
+ formattedDateStrId = "crashes-time-hours";
+ formattedDateStrArgs = { hours: hoursPassed };
+ } else {
+ let minutesPassed = Math.max(Math.round(timePassed / (60 * 1000)), 1);
+ formattedDateStrId = "crashes-time-minutes";
+ formattedDateStrArgs = { minutes: minutesPassed };
+ }
+ return $.new("tr", [
+ $.new("td", [
+ $.new("a", crash.id, null, { href: reportURL + crash.id }),
+ ]),
+ $.new("td", null, null, {
+ "data-l10n-id": formattedDateStrId,
+ "data-l10n-args": formattedDateStrArgs,
+ }),
+ ]);
+ })
+ );
+ },
+
+ addons(data) {
+ $.append(
+ $("addons-tbody"),
+ data.map(function (addon) {
+ return $.new("tr", [
+ $.new("td", addon.name),
+ $.new("td", addon.type),
+ $.new("td", addon.version),
+ $.new("td", addon.isActive),
+ $.new("td", addon.id),
+ ]);
+ })
+ );
+ },
+
+ securitySoftware(data) {
+ if (!AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ $("security-software-title").hidden = true;
+ $("security-software-table").hidden = true;
+ return;
+ }
+
+ $("security-software-antivirus").textContent = data.registeredAntiVirus;
+ $("security-software-antispyware").textContent = data.registeredAntiSpyware;
+ $("security-software-firewall").textContent = data.registeredFirewall;
+ },
+
+ /* Not used by TB
+ features(data) {
+ $.append($("features-tbody"), data.map(function(feature) {
+ return $.new("tr", [
+ $.new("td", feature.name),
+ $.new("td", feature.version),
+ $.new("td", feature.id),
+ ]);
+ }));
+ },
+*/
+
+ async processes(data) {
+ async function buildEntry(name, value) {
+ const fluentName = ProcessType.fluentNameFromProcessTypeString(name);
+ let entryName = (await document.l10n.formatValue(fluentName)) || name;
+ $("processes-tbody").appendChild(
+ $.new("tr", [$.new("td", entryName), $.new("td", value)])
+ );
+ }
+
+ let remoteProcessesCount = Object.values(data.remoteTypes).reduce(
+ (a, b) => a + b,
+ 0
+ );
+ document.querySelector("#remoteprocesses-row a").textContent =
+ remoteProcessesCount;
+
+ // Display the regular "web" process type first in the list,
+ // and with special formatting.
+ if (data.remoteTypes.web) {
+ await buildEntry(
+ "web",
+ `${data.remoteTypes.web} / ${data.maxWebContentProcesses}`
+ );
+ delete data.remoteTypes.web;
+ }
+
+ for (let remoteProcessType in data.remoteTypes) {
+ await buildEntry(remoteProcessType, data.remoteTypes[remoteProcessType]);
+ }
+ },
+
+ environmentVariables(data) {
+ if (!data) {
+ return;
+ }
+ $.append(
+ $("environment-variables-tbody"),
+ Object.entries(data).map(([name, value]) => {
+ return $.new("tr", [
+ $.new("td", name, "pref-name"),
+ $.new("td", value, "pref-value"),
+ ]);
+ })
+ );
+ },
+
+ modifiedPreferences(data) {
+ $.append($("prefs-tbody"), prefsTable(data));
+ },
+
+ lockedPreferences(data) {
+ $.append($("locked-prefs-tbody"), prefsTable(data));
+ },
+
+ printingPreferences(data) {
+ if (AppConstants.platform == "android") {
+ return;
+ }
+ const tbody = $("support-printing-prefs-tbody");
+ $.append(tbody, prefsTable(data));
+ $("support-printing-clear-settings-button").addEventListener(
+ "click",
+ function () {
+ for (let name in data) {
+ Services.prefs.clearUserPref(name);
+ }
+ tbody.textContent = "";
+ }
+ );
+ },
+
+ /* eslint-disable complexity */
+ async graphics(data) {
+ function localizedMsg(msg) {
+ if (typeof msg == "object" && msg.key) {
+ return document.l10n.formatValue(msg.key, msg.args);
+ }
+ let msgId = toFluentID(msg);
+ if (msgId) {
+ return document.l10n.formatValue(msgId);
+ }
+ return "";
+ }
+
+ // Read APZ info out of data.info, stripping it out in the process.
+ let apzInfo = [];
+ let formatApzInfo = function (info) {
+ let out = [];
+ for (let type of [
+ "Wheel",
+ "Touch",
+ "Drag",
+ "Keyboard",
+ "Autoscroll",
+ "Zooming",
+ ]) {
+ let key = "Apz" + type + "Input";
+
+ if (!(key in info)) {
+ continue;
+ }
+
+ delete info[key];
+
+ out.push(toFluentID(type.toLowerCase() + "Enabled"));
+ }
+
+ return out;
+ };
+
+ // Create a <tr> element with key and value columns.
+ //
+ // @key Text in the key column. Localized automatically, unless starts with "#".
+ // @value Fluent ID for text in the value column, or array of children.
+ function buildRow(key, value) {
+ let title = key[0] == "#" ? key.substr(1) : key;
+ let keyStrId = toFluentID(key);
+ let valueStrId = Array.isArray(value) ? null : toFluentID(value);
+ let td = $.new("td", value);
+ td.style["white-space"] = "pre-wrap";
+ if (valueStrId) {
+ document.l10n.setAttributes(td, valueStrId);
+ }
+
+ let th = $.new("th", title, "column");
+ if (!key.startsWith("#")) {
+ document.l10n.setAttributes(th, keyStrId);
+ }
+ return $.new("tr", [th, td]);
+ }
+
+ // @where The name in "graphics-<name>-tbody", of the element to append to.
+ // @trs Array of row elements.
+ function addRows(where, trs) {
+ $.append($("graphics-" + where + "-tbody"), trs);
+ }
+
+ // Build and append a row.
+ //
+ // @where The name in "graphics-<name>-tbody", of the element to append to.
+ function addRow(where, key, value) {
+ addRows(where, [buildRow(key, value)]);
+ }
+ if ("info" in data) {
+ apzInfo = formatApzInfo(data.info);
+
+ let trs = sortedArrayFromObject(data.info).map(function ([prop, val]) {
+ let td = $.new("td", String(val));
+ td.style["word-break"] = "break-all";
+ return $.new("tr", [$.new("th", prop, "column"), td]);
+ });
+ addRows("diagnostics", trs);
+
+ delete data.info;
+ }
+
+ let windowUtils = window.windowUtils;
+ let gpuProcessPid = windowUtils.gpuProcessPid;
+
+ if (gpuProcessPid != -1) {
+ let gpuProcessKillButton = null;
+ if (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_DEV_EDITION) {
+ gpuProcessKillButton = $.new("button");
+
+ gpuProcessKillButton.addEventListener("click", function () {
+ windowUtils.terminateGPUProcess();
+ });
+
+ document.l10n.setAttributes(
+ gpuProcessKillButton,
+ "gpu-process-kill-button"
+ );
+ }
+
+ addRow("diagnostics", "gpu-process-pid", [new Text(gpuProcessPid)]);
+ if (gpuProcessKillButton) {
+ addRow("diagnostics", "gpu-process", [gpuProcessKillButton]);
+ }
+ }
+
+ if (
+ (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_DEV_EDITION) &&
+ AppConstants.platform != "macosx"
+ ) {
+ let gpuDeviceResetButton = $.new("button");
+
+ gpuDeviceResetButton.addEventListener("click", function () {
+ windowUtils.triggerDeviceReset();
+ });
+
+ document.l10n.setAttributes(
+ gpuDeviceResetButton,
+ "gpu-device-reset-button"
+ );
+ addRow("diagnostics", "gpu-device-reset", [gpuDeviceResetButton]);
+ }
+
+ // graphics-failures-tbody tbody
+ if ("failures" in data) {
+ // If indices is there, it should be the same length as failures,
+ // (see Troubleshoot.jsm) but we check anyway:
+ if ("indices" in data && data.failures.length == data.indices.length) {
+ let combined = [];
+ for (let i = 0; i < data.failures.length; i++) {
+ let assembled = assembleFromGraphicsFailure(i, data);
+ combined.push(assembled);
+ }
+ combined.sort(function (a, b) {
+ if (a.index < b.index) {
+ return -1;
+ }
+ if (a.index > b.index) {
+ return 1;
+ }
+ return 0;
+ });
+ $.append(
+ $("graphics-failures-tbody"),
+ combined.map(function (val) {
+ return $.new("tr", [
+ $.new("th", val.header, "column"),
+ $.new("td", val.message),
+ ]);
+ })
+ );
+ delete data.indices;
+ } else {
+ $.append($("graphics-failures-tbody"), [
+ $.new("tr", [
+ $.new("th", "LogFailure", "column"),
+ $.new(
+ "td",
+ data.failures.map(function (val) {
+ return $.new("p", val);
+ })
+ ),
+ ]),
+ ]);
+ }
+ delete data.failures;
+ } else {
+ $("graphics-failures-tbody").style.display = "none";
+ }
+
+ // Add a new row to the table, and take the key (or keys) out of data.
+ //
+ // @where Table section to add to.
+ // @key Data key to use.
+ // @colKey The localization key to use, if different from key.
+ async function addRowFromKey(where, key, colKey) {
+ if (!(key in data)) {
+ return;
+ }
+ colKey = colKey || key;
+
+ let value;
+ let messageKey = key + "Message";
+ if (messageKey in data) {
+ value = await localizedMsg(data[messageKey]);
+ delete data[messageKey];
+ } else {
+ value = data[key];
+ }
+ delete data[key];
+
+ if (value) {
+ addRow(where, colKey, [new Text(value)]);
+ }
+ }
+
+ // graphics-features-tbody
+ let compositor = "";
+ if (data.windowLayerManagerRemote) {
+ compositor = data.windowLayerManagerType;
+ } else {
+ let noOMTCString = await document.l10n.formatValue("main-thread-no-omtc");
+ compositor = "BasicLayers (" + noOMTCString + ")";
+ }
+ addRow("features", "compositing", [new Text(compositor)]);
+ delete data.windowLayerManagerRemote;
+ delete data.windowLayerManagerType;
+ delete data.numTotalWindows;
+ delete data.numAcceleratedWindows;
+ delete data.numAcceleratedWindowsMessage;
+
+ addRow(
+ "features",
+ "asyncPanZoom",
+ apzInfo.length
+ ? [
+ new Text(
+ (
+ await document.l10n.formatValues(
+ apzInfo.map(id => {
+ return { id };
+ })
+ )
+ ).join("; ")
+ ),
+ ]
+ : "apz-none"
+ );
+ let featureKeys = [
+ "webgl1WSIInfo",
+ "webgl1Renderer",
+ "webgl1Version",
+ "webgl1DriverExtensions",
+ "webgl1Extensions",
+ "webgl2WSIInfo",
+ "webgl2Renderer",
+ "webgl2Version",
+ "webgl2DriverExtensions",
+ "webgl2Extensions",
+ ["supportsHardwareH264", "hardware-h264"],
+ ["direct2DEnabled", "#Direct2D"],
+ ["windowProtocol", "graphics-window-protocol"],
+ ["desktopEnvironment", "graphics-desktop-environment"],
+ "usesTiling",
+ "targetFrameRate",
+ ];
+ for (let feature of featureKeys) {
+ if (Array.isArray(feature)) {
+ await addRowFromKey("features", feature[0], feature[1]);
+ continue;
+ }
+ await addRowFromKey("features", feature);
+ }
+
+ if ("directWriteEnabled" in data) {
+ let message = data.directWriteEnabled;
+ if ("directWriteVersion" in data) {
+ message += " (" + data.directWriteVersion + ")";
+ }
+ await addRow("features", "#DirectWrite", [new Text(message)]);
+ delete data.directWriteEnabled;
+ delete data.directWriteVersion;
+ }
+
+ // Adapter tbodies.
+ let adapterKeys = [
+ ["adapterDescription", "gpu-description"],
+ ["adapterVendorID", "gpu-vendor-id"],
+ ["adapterDeviceID", "gpu-device-id"],
+ ["driverVendor", "gpu-driver-vendor"],
+ ["driverVersion", "gpu-driver-version"],
+ ["driverDate", "gpu-driver-date"],
+ ["adapterDrivers", "gpu-drivers"],
+ ["adapterSubsysID", "gpu-subsys-id"],
+ ["adapterRAM", "gpu-ram"],
+ ];
+
+ function showGpu(id, suffix) {
+ function get(prop) {
+ return data[prop + suffix];
+ }
+
+ let trs = [];
+ for (let [prop, key] of adapterKeys) {
+ let value = get(prop);
+ if (value === undefined || value === "") {
+ continue;
+ }
+ trs.push(buildRow(key, [new Text(value)]));
+ }
+
+ if (trs.length == 0) {
+ $("graphics-" + id + "-tbody").style.display = "none";
+ return;
+ }
+
+ let active = "yes";
+ if ("isGPU2Active" in data && (suffix == "2") != data.isGPU2Active) {
+ active = "no";
+ }
+
+ addRow(id, "gpu-active", active);
+ addRows(id, trs);
+ }
+ showGpu("gpu-1", "");
+ showGpu("gpu-2", "2");
+
+ // Remove adapter keys.
+ for (let [prop /* key */] of adapterKeys) {
+ delete data[prop];
+ delete data[prop + "2"];
+ }
+ delete data.isGPU2Active;
+
+ let featureLog = data.featureLog;
+ delete data.featureLog;
+
+ if (featureLog.features.length) {
+ for (let feature of featureLog.features) {
+ let trs = [];
+ for (let entry of feature.log) {
+ let contents;
+ if (!entry.hasOwnProperty("message")) {
+ // This is a default entry.
+ contents = entry.status + " by " + entry.type;
+ } else if (entry.message.length && entry.message[0] == "#") {
+ // This is a failure ID. See nsIGfxInfo.idl.
+ let m = /#BLOCKLIST_FEATURE_FAILURE_BUG_(\d+)/.exec(entry.message);
+ if (m) {
+ let bugSpan = $.new("span");
+
+ let bugHref = $.new("a");
+ bugHref.href =
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=" + m[1];
+ bugHref.setAttribute("data-l10n-name", "bug-link");
+ bugSpan.append(bugHref);
+ document.l10n.setAttributes(bugSpan, "support-blocklisted-bug", {
+ bugNumber: m[1],
+ });
+
+ contents = [bugSpan];
+ } else {
+ let unknownFailure = $.new("span");
+ document.l10n.setAttributes(unknownFailure, "unknown-failure", {
+ failureCode: entry.message.substr(1),
+ });
+ contents = [unknownFailure];
+ }
+ } else {
+ contents =
+ entry.status + " by " + entry.type + ": " + entry.message;
+ }
+
+ trs.push($.new("tr", [$.new("td", contents)]));
+ }
+ addRow("decisions", "#" + feature.name, [$.new("table", trs)]);
+ }
+ } else {
+ $("graphics-decisions-tbody").style.display = "none";
+ }
+
+ if (featureLog.fallbacks.length) {
+ for (let fallback of featureLog.fallbacks) {
+ addRow("workarounds", "#" + fallback.name, [
+ new Text(fallback.message),
+ ]);
+ }
+ } else {
+ $("graphics-workarounds-tbody").style.display = "none";
+ }
+
+ let crashGuards = data.crashGuards;
+ delete data.crashGuards;
+
+ if (crashGuards.length) {
+ for (let guard of crashGuards) {
+ let resetButton = $.new("button");
+ let onClickReset = function () {
+ Services.prefs.setIntPref(guard.prefName, 0);
+ resetButton.removeEventListener("click", onClickReset);
+ resetButton.disabled = true;
+ };
+
+ document.l10n.setAttributes(resetButton, "reset-on-next-restart");
+ resetButton.addEventListener("click", onClickReset);
+
+ addRow("crashguards", guard.type + "CrashGuard", [resetButton]);
+ }
+ } else {
+ $("graphics-crashguards-tbody").style.display = "none";
+ }
+
+ // Now that we're done, grab any remaining keys in data and drop them into
+ // the diagnostics section.
+ for (let key in data) {
+ let value = data[key];
+ addRow("diagnostics", key, [new Text(value)]);
+ }
+ },
+ /* eslint-enable complexity */
+
+ media(data) {
+ function insertBasicInfo(key, value) {
+ function createRow(key, value) {
+ let th = $.new("th", null, "column");
+ document.l10n.setAttributes(th, key);
+ let td = $.new("td", value);
+ td.style["white-space"] = "pre-wrap";
+ td.colSpan = 8;
+ return $.new("tr", [th, td]);
+ }
+ $.append($("media-info-tbody"), [createRow(key, value)]);
+ }
+
+ function createDeviceInfoRow(device) {
+ let deviceInfo = Ci.nsIAudioDeviceInfo;
+
+ let states = {};
+ states[deviceInfo.STATE_DISABLED] = "Disabled";
+ states[deviceInfo.STATE_UNPLUGGED] = "Unplugged";
+ states[deviceInfo.STATE_ENABLED] = "Enabled";
+
+ let preferreds = {};
+ preferreds[deviceInfo.PREF_NONE] = "None";
+ preferreds[deviceInfo.PREF_MULTIMEDIA] = "Multimedia";
+ preferreds[deviceInfo.PREF_VOICE] = "Voice";
+ preferreds[deviceInfo.PREF_NOTIFICATION] = "Notification";
+ preferreds[deviceInfo.PREF_ALL] = "All";
+
+ let formats = {};
+ formats[deviceInfo.FMT_S16LE] = "S16LE";
+ formats[deviceInfo.FMT_S16BE] = "S16BE";
+ formats[deviceInfo.FMT_F32LE] = "F32LE";
+ formats[deviceInfo.FMT_F32BE] = "F32BE";
+
+ function toPreferredString(preferred) {
+ if (preferred == deviceInfo.PREF_NONE) {
+ return preferreds[deviceInfo.PREF_NONE];
+ } else if (preferred & deviceInfo.PREF_ALL) {
+ return preferreds[deviceInfo.PREF_ALL];
+ }
+ let str = "";
+ for (let pref of [
+ deviceInfo.PREF_MULTIMEDIA,
+ deviceInfo.PREF_VOICE,
+ deviceInfo.PREF_NOTIFICATION,
+ ]) {
+ if (preferred & pref) {
+ str += " " + preferreds[pref];
+ }
+ }
+ return str;
+ }
+
+ function toFromatString(dev) {
+ let str = "default: " + formats[dev.defaultFormat] + ", support:";
+ for (let fmt of [
+ deviceInfo.FMT_S16LE,
+ deviceInfo.FMT_S16BE,
+ deviceInfo.FMT_F32LE,
+ deviceInfo.FMT_F32BE,
+ ]) {
+ if (dev.supportedFormat & fmt) {
+ str += " " + formats[fmt];
+ }
+ }
+ return str;
+ }
+
+ function toRateString(dev) {
+ return (
+ "default: " +
+ dev.defaultRate +
+ ", support: " +
+ dev.minRate +
+ " - " +
+ dev.maxRate
+ );
+ }
+
+ function toLatencyString(dev) {
+ return dev.minLatency + " - " + dev.maxLatency;
+ }
+
+ return $.new("tr", [
+ $.new("td", device.name),
+ $.new("td", device.groupId),
+ $.new("td", device.vendor),
+ $.new("td", states[device.state]),
+ $.new("td", toPreferredString(device.preferred)),
+ $.new("td", toFromatString(device)),
+ $.new("td", device.maxChannels),
+ $.new("td", toRateString(device)),
+ $.new("td", toLatencyString(device)),
+ ]);
+ }
+
+ function insertDeviceInfo(side, devices) {
+ let rows = [];
+ for (let dev of devices) {
+ rows.push(createDeviceInfoRow(dev));
+ }
+ $.append($("media-" + side + "-devices-tbody"), rows);
+ }
+
+ function insertEnumerateDatabase() {
+ if (
+ !Services.prefs.getBoolPref("media.mediacapabilities.from-database")
+ ) {
+ $("media-capabilities-tbody").style.display = "none";
+ return;
+ }
+ let button = $("enumerate-database-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ let { KeyValueService } = ChromeUtils.importESModule(
+ "resource://gre/modules/kvstore.sys.mjs"
+ );
+ let currProfDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ currProfDir.append("mediacapabilities");
+ let path = currProfDir.path;
+
+ function enumerateDatabase(name) {
+ KeyValueService.getOrCreate(path, name)
+ .then(database => {
+ return database.enumerate();
+ })
+ .then(enumerator => {
+ var logs = [];
+ logs.push(`${name}:`);
+ for (let { key, value } of enumerator) {
+ logs.push(`${key}: ${value}`);
+ }
+ $("enumerate-database-result").textContent +=
+ logs.join("\n") + "\n";
+ })
+ .catch(err => {
+ $("enumerate-database-result").textContent += `${name}:\n`;
+ });
+ }
+
+ $("enumerate-database-result").style.display = "block";
+ $("enumerate-database-result").classList.remove("no-copy");
+ $("enumerate-database-result").textContent = "";
+
+ enumerateDatabase("video/av1");
+ enumerateDatabase("video/vp8");
+ enumerateDatabase("video/vp9");
+ enumerateDatabase("video/avc");
+ enumerateDatabase("video/theora");
+ });
+ }
+ }
+
+ function roundtripAudioLatency() {
+ insertBasicInfo("roundtrip-latency", "...");
+ window.windowUtils
+ .defaultDevicesRoundTripLatency()
+ .then(latency => {
+ var latencyString = `${(latency[0] * 1000).toFixed(2)}ms (${(
+ latency[1] * 1000
+ ).toFixed(2)})`;
+ data.defaultDevicesRoundTripLatency = latencyString;
+ document.querySelector(
+ 'th[data-l10n-id="roundtrip-latency"]'
+ ).nextSibling.textContent = latencyString;
+ })
+ .catch(e => {});
+ }
+
+ // Basic information
+ insertBasicInfo("audio-backend", data.currentAudioBackend);
+ insertBasicInfo("max-audio-channels", data.currentMaxAudioChannels);
+ insertBasicInfo("sample-rate", data.currentPreferredSampleRate);
+
+ if (AppConstants.platform == "macosx") {
+ var micStatus = {};
+ let permission = Cc["@mozilla.org/ospermissionrequest;1"].getService(
+ Ci.nsIOSPermissionRequest
+ );
+ permission.getAudioCapturePermissionState(micStatus);
+ if (micStatus.value == permission.PERMISSION_STATE_AUTHORIZED) {
+ roundtripAudioLatency();
+ }
+ } else {
+ roundtripAudioLatency();
+ }
+
+ // Output devices information
+ insertDeviceInfo("output", data.audioOutputDevices);
+
+ // Input devices information
+ insertDeviceInfo("input", data.audioInputDevices);
+
+ // Media Capabilitites
+ insertEnumerateDatabase();
+ },
+
+ remoteAgent(data) {
+ if (!AppConstants.ENABLE_WEBDRIVER) {
+ return;
+ }
+ $("remote-debugging-accepting-connections").textContent = data.listening;
+ $("remote-debugging-url").textContent = data.url;
+ },
+
+ accessibility(data) {
+ $("a11y-activated").textContent = data.isActive;
+ $("a11y-force-disabled").textContent = data.forceDisabled || 0;
+
+ let a11yHandlerUsed = $("a11y-handler-used");
+ if (a11yHandlerUsed) {
+ a11yHandlerUsed.textContent = data.handlerUsed;
+ }
+
+ let a11yInstantiator = $("a11y-instantiator");
+ if (a11yInstantiator) {
+ a11yInstantiator.textContent = data.instantiator;
+ }
+ },
+
+ startupCache(data) {
+ $("startup-cache-disk-cache-path").textContent = data.DiskCachePath;
+ $("startup-cache-ignore-disk-cache").textContent = data.IgnoreDiskCache;
+ $("startup-cache-found-disk-cache-on-init").textContent =
+ data.FoundDiskCacheOnInit;
+ $("startup-cache-wrote-to-disk-cache").textContent = data.WroteToDiskCache;
+ },
+
+ libraryVersions(data) {
+ let trs = [
+ $.new("tr", [
+ $.new("th", ""),
+ $.new("th", null, null, { "data-l10n-id": "min-lib-versions" }),
+ $.new("th", null, null, { "data-l10n-id": "loaded-lib-versions" }),
+ ]),
+ ];
+ sortedArrayFromObject(data).forEach(function ([name, val]) {
+ trs.push(
+ $.new("tr", [
+ $.new("td", name),
+ $.new("td", val.minVersion),
+ $.new("td", val.version),
+ ])
+ );
+ });
+ $.append($("libversions-tbody"), trs);
+ },
+
+ userJS(data) {
+ if (!data.exists) {
+ return;
+ }
+ let userJSFile = Services.dirsvc.get("PrefD", Ci.nsIFile);
+ userJSFile.append("user.js");
+ $("prefs-user-js-link").href = Services.io.newFileURI(userJSFile).spec;
+ $("prefs-user-js-section").style.display = "";
+ // Clear the no-copy class
+ $("prefs-user-js-section").className = "";
+ },
+
+ sandbox(data) {
+ if (!AppConstants.MOZ_SANDBOX) {
+ return;
+ }
+
+ let tbody = $("sandbox-tbody");
+ for (let key in data) {
+ // Simplify the display a little in the common case.
+ if (
+ key === "hasPrivilegedUserNamespaces" &&
+ data[key] === data.hasUserNamespaces
+ ) {
+ continue;
+ }
+ if (key === "syscallLog") {
+ // Not in this table.
+ continue;
+ }
+ let keyStrId = toFluentID(key);
+ let th = $.new("th", null, "column");
+ document.l10n.setAttributes(th, keyStrId);
+ tbody.appendChild($.new("tr", [th, $.new("td", data[key])]));
+ }
+
+ if ("syscallLog" in data) {
+ let syscallBody = $("sandbox-syscalls-tbody");
+ let argsHead = $("sandbox-syscalls-argshead");
+ for (let syscall of data.syscallLog) {
+ if (argsHead.colSpan < syscall.args.length) {
+ argsHead.colSpan = syscall.args.length;
+ }
+ let procTypeStrId = toFluentID(syscall.procType);
+ let cells = [
+ $.new("td", syscall.index, "integer"),
+ $.new("td", syscall.msecAgo / 1000),
+ $.new("td", syscall.pid, "integer"),
+ $.new("td", syscall.tid, "integer"),
+ $.new("td", null, null, {
+ "data-l10n-id": "sandbox-proc-type-" + procTypeStrId,
+ }),
+ $.new("td", syscall.syscall, "integer"),
+ ];
+ for (let arg of syscall.args) {
+ cells.push($.new("td", arg, "integer"));
+ }
+ syscallBody.appendChild($.new("tr", cells));
+ }
+ }
+ },
+
+ intl(data) {
+ $("intl-locale-requested").textContent = JSON.stringify(
+ data.localeService.requested
+ );
+ $("intl-locale-available").textContent = JSON.stringify(
+ data.localeService.available
+ );
+ $("intl-locale-supported").textContent = JSON.stringify(
+ data.localeService.supported
+ );
+ $("intl-locale-regionalprefs").textContent = JSON.stringify(
+ data.localeService.regionalPrefs
+ );
+ $("intl-locale-default").textContent = JSON.stringify(
+ data.localeService.defaultLocale
+ );
+
+ $("intl-osprefs-systemlocales").textContent = JSON.stringify(
+ data.osPrefs.systemLocales
+ );
+ $("intl-osprefs-regionalprefs").textContent = JSON.stringify(
+ data.osPrefs.regionalPrefsLocales
+ );
+ },
+};
+
+var $ = document.getElementById.bind(document);
+
+// eslint-disable-next-line func-names
+$.new = function $_new(tag, textContentOrChildren, className, attributes) {
+ let elt = document.createElement(tag);
+ if (className) {
+ elt.className = className;
+ }
+ if (attributes) {
+ if (attributes["data-l10n-id"]) {
+ let args = attributes.hasOwnProperty("data-l10n-args")
+ ? attributes["data-l10n-args"]
+ : undefined;
+ document.l10n.setAttributes(elt, attributes["data-l10n-id"], args);
+ delete attributes["data-l10n-id"];
+ if (args) {
+ delete attributes["data-l10n-args"];
+ }
+ }
+
+ for (let attrName in attributes) {
+ elt.setAttribute(attrName, attributes[attrName]);
+ }
+ }
+ if (Array.isArray(textContentOrChildren)) {
+ this.append(elt, textContentOrChildren);
+ } else if (!attributes || !attributes["data-l10n-id"]) {
+ elt.textContent = String(textContentOrChildren);
+ }
+ return elt;
+};
+
+// eslint-disable-next-line func-names
+$.append = function $_append(parent, children) {
+ children.forEach(c => parent.appendChild(c));
+};
+
+function assembleFromGraphicsFailure(i, data) {
+ // Only cover the cases we have today; for example, we do not have
+ // log failures that assert and we assume the log level is 1/error.
+ let message = data.failures[i];
+ let index = data.indices[i];
+ let what = "";
+ if (message.search(/\[GFX1-\]: \(LF\)/) == 0) {
+ // Non-asserting log failure - the message is substring(14)
+ what = "LogFailure";
+ message = message.substring(14);
+ } else if (message.search(/\[GFX1-\]: /) == 0) {
+ // Non-asserting - the message is substring(9)
+ what = "Error";
+ message = message.substring(9);
+ } else if (message.search(/\[GFX1\]: /) == 0) {
+ // Asserting - the message is substring(8)
+ what = "Assert";
+ message = message.substring(8);
+ }
+ let assembled = {
+ index,
+ header: "(#" + index + ") " + what,
+ message,
+ };
+ return assembled;
+}
+
+function sortedArrayFromObject(obj) {
+ let tuples = [];
+ for (let prop in obj) {
+ tuples.push([prop, obj[prop]]);
+ }
+ tuples.sort(([prop1, v1], [prop2, v2]) => prop1.localeCompare(prop2));
+ return tuples;
+}
+
+function copyRawDataToClipboard(button) {
+ if (button) {
+ button.disabled = true;
+ }
+ Troubleshoot.snapshot().then(
+ async snapshot => {
+ if (button) {
+ button.disabled = false;
+ }
+ let str = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ str.data = JSON.stringify(snapshot, undefined, 2);
+ let transferable = Cc[
+ "@mozilla.org/widget/transferable;1"
+ ].createInstance(Ci.nsITransferable);
+ transferable.init(getLoadContext());
+ transferable.addDataFlavor("text/plain");
+ transferable.setTransferData("text/plain", str);
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+ },
+ err => {
+ if (button) {
+ button.disabled = false;
+ }
+ console.error(err);
+ }
+ );
+}
+
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+async function copyContentsToClipboard() {
+ // Get the HTML and text representations for the important part of the page.
+ let contentsDiv = $("contents").cloneNode(true);
+ // Remove the items we don't want to copy from the clone:
+ contentsDiv.querySelectorAll(".no-copy, [hidden]").forEach(n => n.remove());
+ let dataHtml = contentsDiv.innerHTML;
+ let dataText = createTextForElement(contentsDiv);
+
+ // We can't use plain strings, we have to use nsSupportsString.
+ let supportsStringClass = Cc["@mozilla.org/supports-string;1"];
+ let ssHtml = supportsStringClass.createInstance(Ci.nsISupportsString);
+ let ssText = supportsStringClass.createInstance(Ci.nsISupportsString);
+
+ let transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transferable.init(getLoadContext());
+
+ // Add the HTML flavor.
+ transferable.addDataFlavor("text/html");
+ ssHtml.data = dataHtml;
+ transferable.setTransferData("text/html", ssHtml);
+
+ // Add the plain text flavor.
+ transferable.addDataFlavor("text/plain");
+ ssText.data = dataText;
+ transferable.setTransferData("text/plain", ssText);
+
+ // Store the data into the clipboard.
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+}
+
+// Return the plain text representation of an element. Do a little bit
+// of pretty-printing to make it human-readable.
+function createTextForElement(elem) {
+ let serializer = new Serializer();
+ let text = serializer.serialize(elem);
+
+ // Actual CR/LF pairs are needed for some Windows text editors.
+ if (AppConstants.platform == "win") {
+ text = text.replace(/\n/g, "\r\n");
+ }
+
+ return text;
+}
+
+function Serializer() {}
+
+Serializer.prototype = {
+ serialize(rootElem) {
+ this._lines = [];
+ this._startNewLine();
+ this._serializeElement(rootElem);
+ this._startNewLine();
+ return this._lines.join("\n").trim() + "\n";
+ },
+
+ // The current line is always the line that writing will start at next. When
+ // an element is serialized, the current line is updated to be the line at
+ // which the next element should be written.
+ get _currentLine() {
+ return this._lines.length ? this._lines[this._lines.length - 1] : null;
+ },
+
+ set _currentLine(val) {
+ this._lines[this._lines.length - 1] = val;
+ },
+
+ _serializeElement(elem) {
+ // table
+ if (elem.localName == "table") {
+ this._serializeTable(elem);
+ return;
+ }
+
+ // all other elements
+
+ let hasText = false;
+ for (let child of elem.childNodes) {
+ if (child.nodeType == Node.TEXT_NODE) {
+ let text = this._nodeText(child);
+ this._appendText(text);
+ hasText = hasText || !!text.trim();
+ } else if (child.nodeType == Node.ELEMENT_NODE) {
+ this._serializeElement(child);
+ }
+ }
+
+ // For headings, draw a "line" underneath them so they stand out.
+ let isHeader = /^h[0-9]+$/.test(elem.localName);
+ if (isHeader) {
+ let headerText = (this._currentLine || "").trim();
+ if (headerText) {
+ this._startNewLine();
+ this._appendText("-".repeat(headerText.length));
+ }
+ }
+
+ // Add a blank line underneath elements but only if they contain text.
+ if (hasText && (isHeader || "p" == elem.localName)) {
+ this._startNewLine();
+ this._startNewLine();
+ }
+ },
+
+ _startNewLine(lines) {
+ let currLine = this._currentLine;
+ if (currLine) {
+ // The current line is not empty. Trim it.
+ this._currentLine = currLine.trim();
+ if (!this._currentLine) {
+ // The current line became empty. Discard it.
+ this._lines.pop();
+ }
+ }
+ this._lines.push("");
+ },
+
+ _appendText(text, lines) {
+ this._currentLine += text;
+ },
+
+ _isHiddenSubHeading(th) {
+ return th.parentNode.parentNode.style.display == "none";
+ },
+
+ _serializeTable(table) {
+ // Collect the table's column headings if in fact there are any. First
+ // check thead. If there's no thead, check the first tr.
+ let colHeadings = {};
+ let tableHeadingElem = table.querySelector("thead");
+ if (!tableHeadingElem) {
+ tableHeadingElem = table.querySelector("tr");
+ }
+ if (tableHeadingElem) {
+ let tableHeadingCols = tableHeadingElem.querySelectorAll("th,td");
+ // If there's a contiguous run of th's in the children starting from the
+ // rightmost child, then consider them to be column headings.
+ for (let i = tableHeadingCols.length - 1; i >= 0; i--) {
+ let col = tableHeadingCols[i];
+ if (col.localName != "th" || col.classList.contains("title-column")) {
+ break;
+ }
+ colHeadings[i] = this._nodeText(col).trim();
+ }
+ }
+ let hasColHeadings = Object.keys(colHeadings).length > 0;
+ if (!hasColHeadings) {
+ tableHeadingElem = null;
+ }
+
+ let trs = table.querySelectorAll("table > tr, tbody > tr");
+ let startRow =
+ tableHeadingElem && tableHeadingElem.localName == "tr" ? 1 : 0;
+
+ if (startRow >= trs.length) {
+ // The table's empty.
+ return;
+ }
+
+ if (hasColHeadings) {
+ // Use column headings. Print each tr as a multi-line chunk like:
+ // Heading 1: Column 1 value
+ // Heading 2: Column 2 value
+ for (let i = startRow; i < trs.length; i++) {
+ let children = trs[i].querySelectorAll("td");
+ for (let j = 0; j < children.length; j++) {
+ let text = "";
+ if (colHeadings[j]) {
+ text += colHeadings[j] + ": ";
+ }
+ text += this._nodeText(children[j]).trim();
+ this._appendText(text);
+ this._startNewLine();
+ }
+ this._startNewLine();
+ }
+ return;
+ }
+
+ // Don't use column headings. Assume the table has only two columns and
+ // print each tr in a single line like:
+ // Column 1 value: Column 2 value
+ for (let i = startRow; i < trs.length; i++) {
+ let children = trs[i].querySelectorAll("th,td");
+ let rowHeading = this._nodeText(children[0]).trim();
+ if (children[0].classList.contains("title-column")) {
+ if (!this._isHiddenSubHeading(children[0])) {
+ this._appendText(rowHeading);
+ }
+ } else if (children.length == 1) {
+ // This is a single-cell row.
+ this._appendText(rowHeading);
+ } else {
+ let childTables = trs[i].querySelectorAll("table");
+ if (childTables.length) {
+ // If we have child tables, don't use nodeText - its trs are already
+ // queued up from querySelectorAll earlier.
+ this._appendText(rowHeading + ": ");
+ } else {
+ this._appendText(
+ rowHeading + ": " + this._nodeText(children[1]).trim()
+ );
+ }
+ }
+ this._startNewLine();
+ }
+ this._startNewLine();
+ },
+
+ _nodeText(node) {
+ return node.textContent.replace(/\s+/g, " ");
+ },
+};
+
+function openProfileDirectory() {
+ // Get the profile directory.
+ let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let profileDir = currProfD.path;
+
+ // Show the profile directory.
+ let nsLocalFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+ );
+ new nsLocalFile(profileDir).reveal();
+}
+
+/**
+ * Profile reset is only supported for the default profile if the appropriate migrator exists.
+ */
+function populateActionBox() {
+ if (ResetProfile.resetSupported()) {
+ $("reset-box").style.display = "block";
+ }
+ if (!Services.appinfo.inSafeMode && AppConstants.platform !== "android") {
+ $("safe-mode-box").style.display = "block";
+
+ if (Services.policies && !Services.policies.isAllowed("safeMode")) {
+ $("restart-in-safe-mode-button").setAttribute("disabled", "true");
+ }
+ }
+}
+
+// Prompt user to restart the browser in safe mode
+function safeModeRestart() {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (!cancelQuit.data) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ }
+}
+
+// Added for TB.
+function onShowPrivateDataChange() {
+ document
+ .getElementById("contents")
+ .classList.toggle(
+ "show-private-data",
+ document.getElementById("check-show-private-data").checked
+ );
+}
+
+/**
+ * Set up event listeners for buttons.
+ */
+function setupEventListeners() {
+ /* not used by TB
+ let button = $("reset-box-button");
+ if (button) {
+ button.addEventListener("click", function(event) {
+ ResetProfile.openConfirmationDialog(window);
+ });
+ }
+*/
+ let button = $("clear-startup-cache-button");
+ if (button) {
+ button.addEventListener("click", async function (event) {
+ const [promptTitle, promptBody, restartButtonLabel] =
+ await document.l10n.formatValues([
+ { id: "startup-cache-dialog-title2" },
+ { id: "startup-cache-dialog-body2" },
+ { id: "restart-button-label" },
+ ]);
+ const buttonFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_0_DEFAULT;
+ const result = Services.prompt.confirmEx(
+ window.docShell.chromeEventHandler.ownerGlobal,
+ promptTitle,
+ promptBody,
+ buttonFlags,
+ restartButtonLabel,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (result !== 0) {
+ return;
+ }
+ Services.appinfo.invalidateCachesOnRestart();
+ Services.startup.quit(
+ Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit
+ );
+ });
+ }
+ button = $("restart-in-safe-mode-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ if (
+ Services.obs
+ .enumerateObservers("restart-in-safe-mode")
+ .hasMoreElements()
+ ) {
+ Services.obs.notifyObservers(null, "restart-in-safe-mode");
+ } else {
+ safeModeRestart();
+ }
+ });
+ }
+ if (AppConstants.MOZ_UPDATER) {
+ button = $("update-dir-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ // Get the update directory.
+ let updateDir = Services.dirsvc.get("UpdRootD", Ci.nsIFile);
+ if (!updateDir.exists()) {
+ updateDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ let updateDirPath = updateDir.path;
+ // Show the update directory.
+ let nsLocalFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+ );
+ new nsLocalFile(updateDirPath).reveal();
+ });
+ }
+ button = $("show-update-history-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://mozapps/content/update/history.xhtml",
+ "Update:History",
+ "centerscreen,resizable=no,titlebar,modal"
+ );
+ });
+ }
+ }
+ button = $("verify-place-integrity-button");
+ if (button) {
+ button.addEventListener("click", function (event) {
+ PlacesDBUtils.checkAndFixDatabase().then(tasksStatusMap => {
+ let logs = [];
+ for (let [key, value] of tasksStatusMap) {
+ logs.push(`> Task: ${key}`);
+ let prefix = value.succeeded ? "+ " : "- ";
+ logs = logs.concat(value.logs.map(m => `${prefix}${m}`));
+ }
+ $("verify-place-result").style.display = "block";
+ $("verify-place-result").classList.remove("no-copy");
+ $("verify-place-result").textContent = logs.join("\n");
+ });
+ });
+ }
+
+ // added for TB
+ $("send-via-email").addEventListener("click", function (event) {
+ sendViaEmail();
+ });
+ // end of TB addition
+ /* not used by TB
+ $("copy-raw-data-to-clipboard").addEventListener("click", function(event) {
+ copyRawDataToClipboard(this);
+ });
+*/
+ $("copy-to-clipboard").addEventListener("click", function (event) {
+ copyContentsToClipboard();
+ });
+ $("profile-dir-button").addEventListener("click", function (event) {
+ openProfileDirectory();
+ });
+}
diff --git a/comm/mail/components/about-support/content/aboutSupport.xhtml b/comm/mail/components/about-support/content/aboutSupport.xhtml
new file mode 100644
index 0000000000..8b3f5df2f6
--- /dev/null
+++ b/comm/mail/components/about-support/content/aboutSupport.xhtml
@@ -0,0 +1,956 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- This file is a copy of mozilla/toolkit/content/aboutSupport.xhtml with
+ modifications for TB. -->
+
+<!DOCTYPE html [
+ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd"> %htmlDTD;
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> %brandDTD;
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome:; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="page-title"/>
+
+ <link rel="icon" type="image/png" id="favicon"
+ href="chrome://branding/content/icon48.png"/>
+ <link rel="stylesheet" href="chrome://global/skin/aboutSupport.css"
+ type="text/css"/>
+<!-- Added for TB -->
+ <link rel="stylesheet" href="chrome://messenger/skin/aboutSupport.css"
+ type="text/css"/>
+<!-- End of TB addition -->
+ <script src="chrome://messenger/content/about-support/aboutSupport.js"/>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="toolkit/about/aboutSupport.ftl"/>
+ <link rel="localization" href="toolkit/global/resetProfile.ftl"/>
+ <link rel="localization" href="toolkit/global/processTypes.ftl"/>
+<!-- Added for TB -->
+ <link rel="localization" href="messenger/aboutSupportMail.ftl"/>
+ <link rel="localization" href="messenger/aboutSupportCalendar.ftl"/>
+ <link rel="localization" href="messenger/aboutSupportChat.ftl"/>
+ <script src="chrome://messenger/content/about-support/accounts.js"/>
+ <script src="chrome://messenger/content/about-support/calendars.js"/>
+ <script src="chrome://messenger/content/about-support/chat.js"/>
+ <script src="chrome://messenger/content/about-support/libs.js"/>
+ <script src="chrome://messenger/content/about-support/export.js"/>
+<!-- End of TB addition -->
+ </head>
+
+ <body class="wide-container">
+ <h1 data-l10n-id="page-title"/>
+ <div class="header-flex">
+ <div class="content-flex">
+ <div class="page-subtitle" data-l10n-id="page-subtitle">
+ <a id="supportLink" data-l10n-name="support-link"></a>
+ </div>
+ <div id="support-buttons">
+ <!-- Not used on TB
+ <button id="copy-raw-data-to-clipboard" data-l10n-id="copy-raw-data-to-clipboard-label"/>
+ -->
+ <button id="copy-to-clipboard" data-l10n-id="copy-text-to-clipboard-label"/>
+ <!-- Added for TB -->
+ <button id="send-via-email" data-l10n-id="send-via-email"/>
+ <div>
+ <input type="checkbox"
+ id="check-show-private-data"
+ class="data-uionly"
+ role="checkbox"/>
+ <span>
+ <label for="check-show-private-data" data-l10n-id="show-private-data-main-text"/>
+ <span class="gray-text" data-l10n-id="show-private-data-explanation-text"></span>
+ </span>
+ </div>
+ <!-- End of TB addition -->
+ </div>
+ </div>
+
+#ifndef ANDROID
+ <div class="action-box">
+ <div id="reset-box">
+ <h3 data-l10n-id="refresh-profile"/>
+ <button id="reset-box-button" data-l10n-id="refresh-profile-button"/>
+ </div>
+ <div id="safe-mode-box">
+ <h3 data-l10n-id="troubleshoot-mode-title"/>
+ <button id="restart-in-safe-mode-button" data-l10n-id="restart-in-troubleshoot-mode-label"/>
+ </div>
+ <div id="clear-startup-cache-box">
+ <h3 data-l10n-id="clear-startup-cache-title"/>
+ <button id="clear-startup-cache-button" data-l10n-id="clear-startup-cache-label"/>
+ </div>
+ </div>
+#endif
+ </div>
+ <div id="contents">
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="app-basics-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="app-basics-name"/>
+
+ <td id="application-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-version"/>
+
+ <td id="version-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-build-id"/>
+ <td id="buildid-box"></td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-distribution-id"/>
+ <td id="distributionid-box"></td>
+ </tr>
+
+#ifndef ANDROID
+#ifdef MOZ_UPDATER
+ <tr id="update-dir-row" class="no-copy">
+ <th class="column" data-l10n-id="app-basics-update-dir"/>
+
+ <td>
+ <button id="update-dir-button" data-l10n-id="show-dir-label"/>
+ <span id="update-dir-box" dir="ltr">
+ </span>
+ </td>
+ </tr>
+
+ <tr id="update-history-row" class="no-copy">
+ <th class="column" data-l10n-id="app-basics-update-history"/>
+
+ <td>
+ <button id="show-update-history-button" data-l10n-id="app-basics-show-update-history"/>
+ </td>
+ </tr>
+#endif
+#endif
+
+#ifdef MOZ_UPDATER
+ <tr>
+ <th class="column" data-l10n-id="app-basics-update-channel"/>
+ <td id="updatechannel-box"></td>
+ </tr>
+#endif
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-user-agent"/>
+
+ <td id="useragent-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-os"/>
+
+ <td id="os-box">
+ </td>
+ </tr>
+
+ <tr id="os-theme-row">
+ <th class="column" data-l10n-id="app-basics-os-theme"/>
+
+ <td id="os-theme-box">
+ </td>
+ </tr>
+
+#ifdef XP_MACOSX
+ <tr>
+ <th class="column" data-l10n-id="app-basics-rosetta"/>
+
+ <td id="rosetta-box">
+ </td>
+ </tr>
+#endif
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-binary"/>
+
+ <td id="binary-box" dir="ltr">
+ </td>
+ </tr>
+
+ <tr id="profile-row" class="no-copy" dir="ltr">
+ <th class="column" data-l10n-id="app-basics-profile-dir"/>
+
+ <td>
+ <button id="profile-dir-button" data-l10n-id="show-dir-label"/>
+ <span id="profile-dir-box">
+ </span>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-build-config"/>
+
+ <td>
+ <a href="about:buildconfig" target="_blank">about:buildconfig</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-memory-use"/>
+
+ <td>
+ <a href="about:memory" target="_blank">about:memory</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-cache-use"/>
+
+ <td>
+ <a href="about:cache" target="_blank">about:cache</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-performance"/>
+
+ <td>
+ <a href="about:processes" target="_blank">about:processes</a>
+ </td>
+ </tr>
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-service-workers"/>
+
+ <td>
+ <a href="about:serviceworkers" target="_blank">about:serviceworkers</a>
+ </td>
+ </tr>
+
+#if defined(XP_WIN)
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-third-party"/>
+
+ <td>
+ <a href="about:third-party" target="_blank">about:third-party</a>
+ </td>
+ </tr>
+#endif
+
+#if defined(XP_WIN) && defined(MOZ_LAUNCHER_PROCESS)
+ <tr>
+ <th class="column" data-l10n-id="app-basics-launcher-process-status"/>
+
+ <td id="launcher-process-box">
+ </td>
+ </tr>
+#endif
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-multi-process-support"/>
+
+ <td id="multiprocess-box">
+ <span id="multiprocess-box-process-count"/>
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-fission-support"/>
+
+ <td id="fission-box">
+ <span id="fission-box-process-count"/>
+ <span id="fission-box-status"/>
+ </td>
+ </tr>
+
+ <tr id="remoteprocesses-row">
+ <th class="column" data-l10n-id="app-basics-remote-processes-count"/>
+
+ <td>
+ <a href="#remote-processes"></a>
+ </td>
+ </tr>
+
+ <tr id="policies-status-row">
+ <th class="column" data-l10n-id="app-basics-enterprise-policies"/>
+
+ <td id="policies-status">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-location-service-key-google"/>
+
+ <td id="key-location-service-google-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-safebrowsing-key-google"/>
+
+ <td id="key-safebrowsing-google-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-key-mozilla"/>
+
+ <td id="key-mozilla-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-safe-mode"/>
+
+ <td id="safemode-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-memory-size"/>
+
+ <td id="memory-size-box">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="app-basics-disk-available"/>
+
+ <td id="disk-available-box">
+ </td>
+ </tr>
+
+#ifndef ANDROID
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-profiles"/>
+
+ <td>
+ <a href="about:profiles" target="_blank">about:profiles</a>
+ </td>
+ </tr>
+#endif
+
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="app-basics-telemetry"/>
+
+ <td>
+ <a href="about:telemetry" target="_blank">about:telemetry</a>
+ </td>
+ </tr>
+
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+<!-- Added for TB -->
+ <h2 class="major-section" data-l10n-id="accounts-title"/>
+
+ <table id="accounts-table">
+ <thead>
+ <tr>
+ <th rowspan="2" data-l10n-id="accounts-ID"/>
+
+ <th rowspan="2" class="data-private" data-l10n-id="accounts-name"/>
+
+ <th colspan="3" data-l10n-id="accounts-incoming-server"/>
+
+ <th colspan="5" data-l10n-id="accounts-outgoing-servers"/>
+ </tr>
+ <tr class="thead-level2">
+ <!-- Incoming server -->
+ <th data-l10n-id="accounts-server-name"/>
+
+ <th data-l10n-id="accounts-conn-security"/>
+
+ <th data-l10n-id="accounts-auth-method"/>
+
+ <!-- Outgoing servers -->
+ <th class="data-private" data-l10n-id="identity-name"/>
+
+ <th data-l10n-id="accounts-server-name"/>
+
+ <th data-l10n-id="accounts-conn-security"/>
+
+ <th data-l10n-id="accounts-auth-method"/>
+
+ <th data-l10n-id="accounts-default"/>
+ </tr>
+ </thead>
+
+ <tbody id="accounts-tbody">
+ </tbody>
+ </table>
+
+ <h2 class="major-section" data-l10n-id="mail-libs-title"></h2>
+ <table class="mail-libs-table">
+ <caption></caption>
+ <thead>
+ <th data-l10n-id="libs-table-heading-library"></th>
+ <th data-l10n-id="libs-table-heading-status"></th>
+ <th data-l10n-id="libs-table-heading-expected-version"></th>
+ <th data-l10n-id="libs-table-heading-loaded-version"></th>
+ <th data-l10n-id="libs-table-heading-path"></th>
+ </thead>
+
+ <tbody>
+ <tr>
+ <td>RNP (OpenPGP)</td>
+ <td id="rnp-status">
+ </td>
+ <td id="rnp-expected-version">
+ </td>
+ <td id="rnp-loaded-version">
+ </td>
+ <td id="rnp-path">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <h2 class="major-section" data-l10n-id="calendars-title"></h2>
+
+ <div id="calendar-tables"></div>
+
+ <template id="calendars-table-template">
+ <table class="calendar-table">
+ <caption></caption>
+ <thead>
+ <th data-l10n-id="calendars-table-heading-property"></th>
+ <th data-l10n-id="calendars-table-heading-value"></th>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </template>
+
+ <template id="calendars-table-row-template">
+ <tr>
+ <td></td>
+ <td></td>
+ </tr>
+ </template>
+
+ <h2 class="major-section no-copy" data-l10n-id="chat-title"></h2>
+
+ <table class="no-copy" id="chat-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="chat-table-heading-account"></th>
+ <th data-l10n-id="chat-table-heading-protocol"></th>
+ <th data-l10n-id="chat-table-heading-name" class="data-private"></th>
+ <th data-l10n-id="chat-table-heading-actions"></th>
+ </tr>
+ </thead>
+ <tbody id="chat-tbody">
+ </tbody>
+ </table>
+
+ <template id="chat-table-row-template">
+ <tr>
+ <td></td>
+ <td></td>
+ <td class="data-private"></td>
+ <td><button class="button" type="button" data-l10n-id="chat-table-copy-debug-log"></button></td>
+ </tr>
+ </template>
+
+<!-- End of TB addition -->
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+#ifdef MOZ_CRASHREPORTER
+
+ <h2 class="major-section" id="crashes-title" data-l10n-id="crashes-title"/>
+
+ <table id="crashes-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="crashes-id"/>
+ <th data-l10n-id="crashes-send-date"/>
+ </tr>
+ </thead>
+ <tbody id="crashes-tbody">
+ </tbody>
+ </table>
+ <p id="crashes-allReports" class="hidden no-copy">
+ <a href="about:crashes" id="crashes-allReportsWithPending"
+ class="block" data-l10n-id="crashes-all-reports" target="_blank"/>
+ </p>
+ <p id="crashes-noConfig" class="hidden no-copy" data-l10n-id="crashes-no-config"/>
+
+#endif
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+ <!-- Not used by TB
+ <h2 class="major-section" data-l10n-id="features-title"/>
+
+ <table id="features-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="features-name"/>
+ <th data-l10n-id="features-version"/>
+ <th data-l10n-id="features-id"/>
+ </tr>
+ </thead>
+ <tbody id="features-tbody">
+ </tbody>
+ </table>
+ -->
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="processes-title" id="remote-processes"/>
+
+ <table id="remote-processes-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="processes-type"/>
+ <th data-l10n-id="processes-count"/>
+ </tr>
+ </thead>
+ <tbody id="processes-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="support-addons-title"/>
+
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="support-addons-name"/>
+ <th data-l10n-id="support-addons-type"/>
+ <th data-l10n-id="support-addons-version"/>
+ <th data-l10n-id="support-addons-enabled"/>
+ <th data-l10n-id="support-addons-id"/>
+ </tr>
+ </thead>
+ <tbody id="addons-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" id="security-software-title" data-l10n-id="security-software-title"/>
+
+ <table id="security-software-table">
+ <thead>
+ <tr>
+ <th data-l10n-id="security-software-type"/>
+ <th data-l10n-id="security-software-name"/>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="security-software-antivirus"/>
+
+ <td id="security-software-antivirus">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="security-software-antispyware"/>
+
+ <td id="security-software-antispyware">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="security-software-firewall"/>
+
+ <td id="security-software-firewall">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="graphics-title"/>
+
+ <table>
+ <tbody id="graphics-features-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-features-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-gpu-1-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-gpu1-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-gpu-2-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-gpu2-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-diagnostics-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-diagnostics-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-decisions-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-decision-log-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-crashguards-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-crash-guards-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-workarounds-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-workarounds-title"/>
+ </tr>
+ </tbody>
+
+ <tbody id="graphics-failures-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="graphics-failure-log-title"/>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="media-title"/>
+ <table>
+ <tbody id="media-info-tbody">
+ </tbody>
+
+ <tbody id="media-output-devices-tbody">
+ <tr>
+ <th colspan="9" class="title-column" data-l10n-id="media-output-devices-title"/>
+ </tr>
+ <tr>
+ <th data-l10n-id="media-device-name"/>
+ <th data-l10n-id="media-device-group"/>
+ <th data-l10n-id="media-device-vendor"/>
+ <th data-l10n-id="media-device-state"/>
+ <th data-l10n-id="media-device-preferred"/>
+ <th data-l10n-id="media-device-format"/>
+ <th data-l10n-id="media-device-channels"/>
+ <th data-l10n-id="media-device-rate"/>
+ <th data-l10n-id="media-device-latency"/>
+ </tr>
+ </tbody>
+
+ <tbody id="media-input-devices-tbody">
+ <tr>
+ <th colspan="9" class="title-column" data-l10n-id="media-input-devices-title"/>
+ </tr>
+ <tr>
+ <th data-l10n-id="media-device-name"/>
+ <th data-l10n-id="media-device-group"/>
+ <th data-l10n-id="media-device-vendor"/>
+ <th data-l10n-id="media-device-state"/>
+ <th data-l10n-id="media-device-preferred"/>
+ <th data-l10n-id="media-device-format"/>
+ <th data-l10n-id="media-device-channels"/>
+ <th data-l10n-id="media-device-rate"/>
+ <th data-l10n-id="media-device-latency"/>
+ </tr>
+ </tbody>
+
+ <tbody id="media-capabilities-tbody">
+ <tr>
+ <th colspan="9" class="title-column" data-l10n-id="media-capabilities-title"/>
+ </tr>
+ <tr>
+ <td colspan="9">
+ <button id="enumerate-database-button" data-l10n-id="media-capabilities-enumerate"/>
+ <pre id="enumerate-database-result" class="hidden no-copy"></pre>
+ </td>
+ </tr>
+ </tbody>
+
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="environment-variables-title"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="environment-variables-name"/>
+
+ <th class="value" data-l10n-id="environment-variables-value"/>
+ </thead>
+
+ <tbody id="environment-variables-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="modified-key-prefs-title"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="modified-prefs-name"/>
+
+ <th class="value" data-l10n-id="modified-prefs-value"/>
+ </thead>
+
+ <tbody id="prefs-tbody">
+ </tbody>
+ </table>
+
+ <section id="prefs-user-js-section" class="hidden no-copy">
+ <h3 data-l10n-id="user-js-title"/>
+ <p data-l10n-id="user-js-description">
+ <a id="prefs-user-js-link" data-l10n-name="user-js-link"></a>
+ </p>
+ </section>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="locked-key-prefs-title"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="locked-prefs-name"/>
+
+ <th class="value" data-l10n-id="locked-prefs-value"/>
+ </thead>
+
+ <tbody id="locked-prefs-tbody">
+ </tbody>
+ </table>
+
+#ifndef ANDROID
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="place-database-title"/>
+
+ <table>
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="place-database-integrity"/>
+
+ <td>
+ <button id="verify-place-integrity-button" data-l10n-id="place-database-verify-integrity"/>
+ <pre id="verify-place-result" class="hidden no-copy"></pre>
+ </td>
+ </tr>
+ </table>
+#endif
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+ <h2 class="major-section" data-l10n-id="a11y-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="a11y-activated"/>
+
+ <td id="a11y-activated">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="a11y-force-disabled"/>
+
+ <td id="a11y-force-disabled">
+ </td>
+ </tr>
+#if defined(XP_WIN)
+ <tr>
+ <th class="column" data-l10n-id="a11y-handler-used"/>
+
+ <td id="a11y-handler-used">
+ </td>
+ </tr>
+
+ <tr>
+ <th class="column" data-l10n-id="a11y-instantiator"/>
+
+ <td id="a11y-instantiator">
+ </td>
+ </tr>
+#endif
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+ <h2 class="major-section" data-l10n-id="library-version-title"/>
+
+ <table>
+ <tbody id="libversions-tbody">
+ </tbody>
+ </table>
+
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+#if defined(MOZ_SANDBOX)
+ <h2 class="major-section" id="sandbox" data-l10n-id="sandbox-title"/>
+
+ <table>
+ <tbody id="sandbox-tbody">
+ </tbody>
+ </table>
+
+#if defined(XP_LINUX)
+ <h4 data-l10n-id="sandbox-sys-call-log-title"/>
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="sandbox-sys-call-index"/>
+ <th data-l10n-id="sandbox-sys-call-age"/>
+ <th data-l10n-id="sandbox-sys-call-pid"/>
+ <th data-l10n-id="sandbox-sys-call-tid"/>
+ <th data-l10n-id="sandbox-sys-call-proc-type"/>
+ <th data-l10n-id="sandbox-sys-call-number"/>
+ <th id="sandbox-syscalls-argshead" data-l10n-id="sandbox-sys-call-args"/>
+ </tr>
+ </thead>
+ <tbody id="sandbox-syscalls-tbody">
+ </tbody>
+ </table>
+#endif
+#endif
+
+ <h2 class="major-section" data-l10n-id="startup-cache-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-disk-cache-path"/>
+
+ <td id="startup-cache-disk-cache-path">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-ignore-disk-cache"/>
+
+ <td id="startup-cache-ignore-disk-cache">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-found-disk-cache-on-init"/>
+
+ <td id="startup-cache-found-disk-cache-on-init">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="startup-cache-wrote-to-disk-cache"/>
+
+ <td id="startup-cache-wrote-to-disk-cache">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <h2 class="major-section" data-l10n-id="intl-title"/>
+
+ <table>
+ <tbody id="intl-localeservice-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="intl-app-title"/>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-requested"/>
+ <td id="intl-locale-requested">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-available"/>
+ <td id="intl-locale-available">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-supported"/>
+ <td id="intl-locale-supported">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-regional-prefs"/>
+ <td id="intl-locale-regionalprefs">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-locales-default"/>
+ <td id="intl-locale-default">
+ </td>
+ </tr>
+ </tbody>
+ <tbody id="intl-ospreferences-tbody">
+ <tr>
+ <th colspan="2" class="title-column" data-l10n-id="intl-os-title"/>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-os-prefs-system-locales"/>
+ <td id="intl-osprefs-systemlocales">
+ </td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="intl-regional-prefs"/>
+ <td id="intl-osprefs-regionalprefs">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+#if defined(ENABLE_WEBDRIVER)
+ <h2 class="major-section" data-l10n-id="remote-debugging-title"/>
+
+ <table>
+ <tbody>
+ <tr>
+ <th class="column" data-l10n-id="remote-debugging-accepting-connections"/>
+ <td id="remote-debugging-accepting-connections"></td>
+ </tr>
+ <tr>
+ <th class="column" data-l10n-id="remote-debugging-url"/>
+ <td id="remote-debugging-url"></td>
+ </tr>
+ </tbody>
+ </table>
+#endif
+
+#ifndef ANDROID
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
+
+ <h2 class="major-section" data-l10n-id="support-printing-title"/>
+
+ <table>
+ <tr class="no-copy">
+ <th class="column" data-l10n-id="support-printing-troubleshoot"/>
+ <td>
+ <button id="support-printing-clear-settings-button" data-l10n-id="support-printing-clear-settings-button"/>
+ </td>
+ </tr>
+ </table>
+
+ <h3 data-l10n-id="support-printing-modified-settings"/>
+
+ <table class="prefs-table">
+ <thead class="no-copy">
+ <th class="name" data-l10n-id="support-printing-prefs-name"/>
+
+ <th class="value" data-l10n-id="support-printing-prefs-value"/>
+ </thead>
+
+ <tbody id="support-printing-prefs-tbody">
+ </tbody>
+ </table>
+#endif
+
+ </div>
+
+ </body>
+
+</html>
diff --git a/comm/mail/components/about-support/content/accounts.js b/comm/mail/components/about-support/content/accounts.js
new file mode 100644
index 0000000000..33633f4a15
--- /dev/null
+++ b/comm/mail/components/about-support/content/accounts.js
@@ -0,0 +1,339 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals CLASS_DATA_PRIVATE, CLASS_DATA_PUBLIC */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// Platform-specific includes
+var AboutSupportPlatform;
+if ("@mozilla.org/windows-registry-key;1" in Cc) {
+ let temp = ChromeUtils.import("resource:///modules/AboutSupportWin32.jsm");
+ AboutSupportPlatform = temp.AboutSupportPlatform;
+} else if ("nsILocalFileMac" in Ci) {
+ let temp = ChromeUtils.import("resource:///modules/AboutSupportMac.jsm");
+ AboutSupportPlatform = temp.AboutSupportPlatform;
+} else {
+ let temp = ChromeUtils.import("resource:///modules/AboutSupportUnix.jsm");
+ AboutSupportPlatform = temp.AboutSupportPlatform;
+}
+
+var gMessengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+var gSocketTypes = {};
+for (let [str, index] of Object.entries(Ci.nsMsgSocketType)) {
+ gSocketTypes[index] = str;
+}
+
+var gAuthMethods = {};
+for (let [str, index] of Object.entries(Ci.nsMsgAuthMethod)) {
+ gAuthMethods[index] = str;
+}
+
+// l10n properties in messenger.properties corresponding to each auth method
+var gAuthMethodProperties = new Map([
+ [0, "authNo"], // Special value defined to be invalid.
+ // Some accounts without auth report this.
+ [Ci.nsMsgAuthMethod.none, "authNo"],
+ [Ci.nsMsgAuthMethod.old, "authOld"],
+ [Ci.nsMsgAuthMethod.passwordCleartext, "authPasswordCleartextViaSSL"],
+ [Ci.nsMsgAuthMethod.passwordEncrypted, "authPasswordEncrypted"],
+ [Ci.nsMsgAuthMethod.GSSAPI, "authKerberos"],
+ [Ci.nsMsgAuthMethod.NTLM, "authNTLM"],
+ [Ci.nsMsgAuthMethod.External, "authExternal"],
+ [Ci.nsMsgAuthMethod.secure, "authAnySecure"],
+ [Ci.nsMsgAuthMethod.anything, "authAny"],
+ [Ci.nsMsgAuthMethod.OAuth2, "authOAuth2"],
+]);
+
+var AboutSupport = {
+ /**
+ * Gets details about SMTP servers for a given nsIMsgAccount.
+ *
+ * @returns An array of records, each record containing the name and other details
+ * about one SMTP server.
+ */
+ _getSMTPDetails(aAccount) {
+ let defaultIdentity = aAccount.defaultIdentity;
+ let smtpDetails = [];
+
+ for (let identity of aAccount.identities) {
+ let isDefault = identity == defaultIdentity;
+ let smtpServer = MailServices.smtp.getServerByIdentity(identity);
+ if (!smtpServer) {
+ continue;
+ }
+ smtpDetails.push({
+ identityName: identity.identityName,
+ name: smtpServer.displayname,
+ authMethod: smtpServer.authMethod,
+ socketType: smtpServer.socketType,
+ isDefault,
+ });
+ }
+
+ return smtpDetails;
+ },
+
+ /**
+ * Returns account details as an array of records.
+ */
+ getAccountDetails() {
+ let accountDetails = [];
+
+ for (let account of MailServices.accounts.accounts) {
+ let server = account.incomingServer;
+ accountDetails.push({
+ key: account.key,
+ name: server.prettyName,
+ hostDetails:
+ "(" +
+ server.type +
+ ") " +
+ server.hostName +
+ (server.port != -1 ? ":" + server.port : ""),
+ socketType: server.socketType,
+ authMethod: server.authMethod,
+ smtpServers: this._getSMTPDetails(account),
+ });
+ }
+
+ function idCompare(accountA, accountB) {
+ let regex = /^account([0-9]+)$/;
+ let regexA = regex.exec(accountA.key);
+ let regexB = regex.exec(accountB.key);
+ // There's an off chance that the account ID isn't in the standard
+ // accountN form. If so, use the standard string compare against a fixed
+ // string ("account") to avoid correctness issues.
+ if (!regexA || !regexB) {
+ let keyA = regexA ? "account" : accountA.key;
+ let keyB = regexB ? "account" : accountB.key;
+ return keyA.localeCompare(keyB);
+ }
+ let idA = parseInt(regexA[1]);
+ let idB = parseInt(regexB[1]);
+ return idA - idB;
+ }
+
+ // Sort accountDetails by account ID.
+ accountDetails.sort(idCompare);
+ return accountDetails;
+ },
+
+ /**
+ * Returns the corresponding text for a given socket type index. The text is
+ * returned as a record with "localized" and "neutral" entries.
+ */
+ getSocketTypeText(aIndex) {
+ let plainSocketType =
+ aIndex in gSocketTypes ? gSocketTypes[aIndex] : aIndex;
+ let prettySocketType;
+ try {
+ prettySocketType = gMessengerBundle.GetStringFromName(
+ "smtpServer-ConnectionSecurityType-" + aIndex
+ );
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FAILURE) {
+ // The string wasn't found in the bundle. Make do without it.
+ prettySocketType = plainSocketType;
+ } else {
+ throw e;
+ }
+ }
+ return { localized: prettySocketType, neutral: plainSocketType };
+ },
+
+ /**
+ * Returns the corresponding text for a given authentication method index. The
+ * text is returned as a record with "localized" and "neutral" entries.
+ */
+ getAuthMethodText(aIndex) {
+ let prettyAuthMethod;
+ let plainAuthMethod =
+ aIndex in gAuthMethods ? gAuthMethods[aIndex] : aIndex;
+ if (gAuthMethodProperties.has(parseInt(aIndex))) {
+ prettyAuthMethod = gMessengerBundle.GetStringFromName(
+ gAuthMethodProperties.get(parseInt(aIndex))
+ );
+ } else {
+ prettyAuthMethod = plainAuthMethod;
+ }
+ return { localized: prettyAuthMethod, neutral: plainAuthMethod };
+ },
+};
+
+function createParentElement(tagName, childElems) {
+ let elem = document.createElement(tagName);
+ appendChildren(elem, childElems);
+ return elem;
+}
+
+function createElement(tagName, textContent, opt_attributes, opt_copyData) {
+ if (opt_attributes == null) {
+ opt_attributes = {};
+ }
+ let elem = document.createElement(tagName);
+ elem.textContent = textContent;
+ for (let key in opt_attributes) {
+ elem.setAttribute(key, "" + opt_attributes[key]);
+ }
+
+ if (opt_copyData != null) {
+ elem.dataset.copyData = opt_copyData;
+ }
+
+ return elem;
+}
+
+function appendChildren(parentElem, children) {
+ for (let i = 0; i < children.length; i++) {
+ parentElem.appendChild(children[i]);
+ }
+}
+
+/**
+ * Coerces x into a string.
+ */
+function toStr(x) {
+ return "" + x;
+}
+
+/**
+ * Marks x as private (see below).
+ */
+function toPrivate(x) {
+ return { localized: x, neutral: x, isPrivate: true };
+}
+
+/**
+ * A list of fields for the incoming server of an account. Each element of the
+ * list is a pair of [property name, transforming function]. The transforming
+ * function should take the property and return either a string or an object
+ * with the following properties:
+ * - localized: the data in (possibly) localized form
+ * - neutral: the data in language-neutral form
+ * - isPrivate (optional): true if the data is private-only, false if public-only,
+ * not stated otherwise
+ */
+var gIncomingDetails = [
+ ["key", toStr],
+ ["name", toPrivate],
+ ["hostDetails", toStr],
+ ["socketType", AboutSupport.getSocketTypeText.bind(AboutSupport)],
+ ["authMethod", AboutSupport.getAuthMethodText.bind(AboutSupport)],
+];
+
+/**
+ * A list of fields for the outgoing servers associated with an account. This is
+ * similar to gIncomingDetails above.
+ */
+var gOutgoingDetails = [
+ ["identityName", toPrivate],
+ ["name", toStr],
+ ["socketType", AboutSupport.getSocketTypeText.bind(AboutSupport)],
+ ["authMethod", AboutSupport.getAuthMethodText.bind(AboutSupport)],
+ ["isDefault", toStr],
+];
+
+/**
+ * A list of account details.
+ */
+var gAccountDetails = AboutSupport.getAccountDetails();
+
+function populateAccountsSection() {
+ let trAccounts = [];
+
+ function createTD(data, rowSpan) {
+ let text = typeof data == "string" ? data : data.localized;
+ let copyData = typeof data == "string" ? null : data.neutral;
+ let attributes = { rowspan: rowSpan };
+ if (typeof data == "object" && "isPrivate" in data) {
+ attributes.class = data.isPrivate
+ ? CLASS_DATA_PRIVATE
+ : CLASS_DATA_PUBLIC;
+ }
+
+ return createElement("td", text, attributes, copyData);
+ }
+
+ for (let account of gAccountDetails) {
+ // We want a minimum rowspan of 1
+ let rowSpan = account.smtpServers.length || 1;
+ // incomingTDs is an array of TDs
+ let incomingTDs = gIncomingDetails.map(([prop, fn]) =>
+ createTD(fn(account[prop]), rowSpan)
+ );
+ // outgoingTDs is an array of arrays of TDs
+ let outgoingTDs = [];
+ for (let smtp of account.smtpServers) {
+ outgoingTDs.push(
+ gOutgoingDetails.map(([prop, fn]) => createTD(fn(smtp[prop]), 1))
+ );
+ }
+
+ // If there are no SMTP servers, add a dummy element to make life easier below
+ if (outgoingTDs.length == 0) {
+ outgoingTDs = [[]];
+ }
+
+ // Add the first SMTP server to this tr.
+ let tr = createParentElement("tr", incomingTDs.concat(outgoingTDs[0]));
+ trAccounts.push(tr);
+ // Add the remaining SMTP servers as separate trs
+ for (let tds of outgoingTDs.slice(1)) {
+ trAccounts.push(createParentElement("tr", tds));
+ }
+ }
+
+ appendChildren(document.getElementById("accounts-tbody"), trAccounts);
+}
+
+/**
+ * Returns a plaintext representation of the accounts data.
+ */
+function getAccountsText(aHidePrivateData, aIndent) {
+ let accumulator = [];
+
+ // Given a string or object, converts it into a language-neutral form
+ function neutralizer(data) {
+ if (typeof data == "string") {
+ return data;
+ }
+ if ("isPrivate" in data && aHidePrivateData == data.isPrivate) {
+ return "";
+ }
+ return data.neutral;
+ }
+
+ for (let account of gAccountDetails) {
+ accumulator.push(aIndent + account.key + ":");
+ // incomingData is an array of strings
+ let incomingData = gIncomingDetails.map(([prop, fn]) =>
+ neutralizer(fn(account[prop]))
+ );
+ accumulator.push(aIndent + " INCOMING: " + incomingData.join(", "));
+
+ // outgoingData is an array of arrays of strings
+ let outgoingData = [];
+ for (let smtp of account.smtpServers) {
+ outgoingData.push(
+ gOutgoingDetails.map(([prop, fn]) => neutralizer(fn(smtp[prop])))
+ );
+ }
+
+ for (let data of outgoingData) {
+ accumulator.push(aIndent + " OUTGOING: " + data.join(", "));
+ }
+
+ accumulator.push("");
+ }
+
+ return accumulator.join("\n");
+}
diff --git a/comm/mail/components/about-support/content/calendars.js b/comm/mail/components/about-support/content/calendars.js
new file mode 100644
index 0000000000..a55b59572c
--- /dev/null
+++ b/comm/mail/components/about-support/content/calendars.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals CLASS_DATA_PRIVATE */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+let boolean = val => (!val && val !== false ? "" : val);
+let string = val => (val ? String(val) : "");
+
+/**
+ * A list of tuples for each calendar property displayed where each tuple
+ * contains the following elements:
+ * 0 - The name of the property passed to getProperty().
+ * 1 - A function that accepts the property value and attempts it into a string.
+ * 2 - Boolean indicating whether the property is private data (optional).
+ */
+let gCalendarProperties = [
+ ["name", string, true],
+ ["type", string],
+ ["disabled", boolean],
+ ["username", string, true],
+ ["uri", string, true],
+ ["refreshInterval", string],
+ ["readOnly", boolean],
+ ["suppressAlarms", boolean],
+ ["cache.enabled", boolean],
+ ["imip.identity", identity => string(identity && identity.key)],
+ ["imip.identity.disabled", boolean],
+ ["imip.identity.account", account => string(account && account.key)],
+ ["organizerId", string, true],
+ ["forceEmailScheduling", boolean],
+ ["capabilities.alarms.popup.supported", boolean],
+ ["capabilities.alarms.oninviations.supported", boolean],
+ ["capabilities.alarms.maxCount", string],
+ ["capabilities.attachments.supported", boolean],
+ ["capabilities.categories.maxCount", string],
+ ["capabilities.privacy.supported", boolean],
+ ["capabilities.priority.supported", boolean],
+ ["capabilities.events.supported", boolean],
+ ["capabilities.tasks.supported", boolean],
+ ["capabilities.timezones.floating.supported", boolean],
+ ["capabilities.timezones.UTC.supported", boolean],
+ ["capabilities.autoschedule.supported", boolean],
+];
+
+/**
+ * Populates the "Calendars" section of the troubleshooting information page
+ * with the properties of each configured calendar.
+ */
+function populateCalendarsSection() {
+ let container = document.getElementById("calendar-tables");
+ let tableTmpl = document.getElementById("calendars-table-template");
+ let rowTmpl = document.getElementById("calendars-table-row-template");
+
+ for (let calendar of cal.manager.getCalendars()) {
+ let table = tableTmpl.content.cloneNode(true).querySelector("table");
+ table.firstElementChild.textContent = calendar.name;
+
+ let tbody = table.querySelector("tbody");
+ for (let [prop, transform, isPrivate] of gCalendarProperties) {
+ let tr = rowTmpl.content.cloneNode(true).querySelector("tr");
+ let l10nKey = `calendars-table-${prop
+ .toLowerCase()
+ .replaceAll(".", "-")}`;
+
+ tr.cells[0].setAttribute("data-l10n-id", l10nKey);
+ tr.cells[1].textContent = transform(calendar.getProperty(prop));
+ if (isPrivate) {
+ tr.cells[1].setAttribute("class", CLASS_DATA_PRIVATE);
+ }
+ tbody.appendChild(tr);
+ }
+ container.appendChild(table);
+ }
+}
diff --git a/comm/mail/components/about-support/content/chat.js b/comm/mail/components/about-support/content/chat.js
new file mode 100644
index 0000000000..50c34b9ba0
--- /dev/null
+++ b/comm/mail/components/about-support/content/chat.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+/**
+ * Populates the "Chat" section of the troubleshooting information page with
+ * the chat accounts.
+ */
+function populateChatSection() {
+ let table = document.getElementById("chat-table");
+ let rowTmpl = document.getElementById("chat-table-row-template");
+ let dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "long",
+ });
+ let formatDebugMessage = dbgMsg => {
+ let m = dbgMsg.message;
+ let time = new Date(m.timeStamp);
+ time = dateTimeFormatter.format(time);
+ let level = dbgMsg.logLevel;
+ if (!level) {
+ return "(" + m.errorMessage + ")";
+ }
+ if (level == dbgMsg.LEVEL_ERROR) {
+ level = "ERROR";
+ } else if (level == dbgMsg.LEVEL_WARNING) {
+ level = "WARN.";
+ } else if (level == dbgMsg.LEVEL_LOG) {
+ level = "LOG ";
+ } else {
+ level = "DEBUG";
+ }
+ return (
+ "[" +
+ time +
+ "] " +
+ level +
+ " (@ " +
+ m.sourceLine +
+ " " +
+ m.sourceName +
+ ":" +
+ m.lineNumber +
+ ")\n" +
+ m.errorMessage
+ );
+ };
+
+ let chatAccounts = IMServices.accounts.getAccounts();
+ if (!chatAccounts.length) {
+ return;
+ }
+ table.querySelector("tbody").append(
+ ...chatAccounts.map(account => {
+ const row = rowTmpl.content.cloneNode(true).querySelector("tr");
+ row.cells[0].textContent = account.id;
+ row.cells[1].textContent = account.protocol.id;
+ row.cells[2].textContent = account.name;
+ row.cells[3].addEventListener("click", () => {
+ const text = account
+ .getDebugMessages()
+ .map(formatDebugMessage)
+ .join("\n");
+ navigator.clipboard.writeText(text);
+ });
+ return row;
+ })
+ );
+}
diff --git a/comm/mail/components/about-support/content/export.js b/comm/mail/components/about-support/content/export.js
new file mode 100644
index 0000000000..46eb0c6497
--- /dev/null
+++ b/comm/mail/components/about-support/content/export.js
@@ -0,0 +1,288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals CLASS_DATA_PRIVATE, CLASS_DATA_PUBLIC, CLASS_DATA_UIONLY, createElement,
+createParentElement, getAccountsText, getLoadContext, MailServices, Services */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/**
+ * Create warning text to add to any private data.
+ *
+ * @returns A HTML paragraph node containing the warning.
+ */
+function createWarning() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutSupportMail.properties"
+ );
+ return createParentElement("p", [
+ createElement("strong", bundle.GetStringFromName("warningLabel")),
+ // Add some whitespace between the label and the text
+ document.createTextNode(" "),
+ document.createTextNode(bundle.GetStringFromName("warningText")),
+ ]);
+}
+
+function getClipboardTransferable() {
+ // Get the HTML and text representations for the important part of the page.
+ let hidePrivateData = !document.getElementById("check-show-private-data")
+ .checked;
+ let contentsDiv = createCleanedUpContents(hidePrivateData);
+ let dataHtml = contentsDiv.innerHTML;
+ let dataText = createTextForElement(contentsDiv, hidePrivateData);
+
+ // We can't use plain strings, we have to use nsSupportsString.
+ let supportsStringClass = Cc["@mozilla.org/supports-string;1"];
+ let ssHtml = supportsStringClass.createInstance(Ci.nsISupportsString);
+ let ssText = supportsStringClass.createInstance(Ci.nsISupportsString);
+
+ let transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transferable.init(getLoadContext());
+
+ // Add the HTML flavor.
+ transferable.addDataFlavor("text/html");
+ ssHtml.data = dataHtml;
+ transferable.setTransferData("text/html", ssHtml);
+
+ // Add the plain text flavor.
+ transferable.addDataFlavor("text/plain");
+ ssText.data = dataText;
+ transferable.setTransferData("text/plain", ssText);
+
+ return transferable;
+}
+
+// This function intentionally has the same name as the one in aboutSupport.js
+// so that the one here is called.
+function copyContentsToClipboard() {
+ let transferable = getClipboardTransferable();
+ // Store the data into the clipboard.
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+}
+
+function sendViaEmail() {
+ // Get the HTML representation for the important part of the page.
+ let hidePrivateData = !document.getElementById("check-show-private-data")
+ .checked;
+ let contentsDiv = createCleanedUpContents(hidePrivateData);
+ let dataHtml = contentsDiv.innerHTML;
+ // The editor considers whitespace to be significant, so replace all
+ // whitespace with a single space.
+ dataHtml = dataHtml.replace(/\s+/g, " ");
+
+ // Set up parameters and fields to use for the compose window.
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.HTML;
+
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ fields.forcePlainText = false;
+ fields.body = dataHtml;
+ // In general we can have non-ASCII characters, and compose's charset
+ // detection doesn't seem to work when the HTML part is pure ASCII but the
+ // text isn't. So take the easy way out and force UTF-8.
+ fields.bodyIsAsciiOnly = false;
+ params.composeFields = fields;
+
+ // Our params are set up. Now open a compose window.
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
+
+function createCleanedUpContents(aHidePrivateData) {
+ // Get the important part of the page.
+ let contentsDiv = document.getElementById("contents");
+ // Deep-clone the entire div.
+ let clonedDiv = contentsDiv.cloneNode(true);
+ // Go in and replace text with the text we actually want to copy.
+ // (this mutates the cloned node)
+ cleanUpText(clonedDiv, aHidePrivateData);
+ // Insert a warning if we need to
+ if (!aHidePrivateData) {
+ clonedDiv.insertBefore(createWarning(), clonedDiv.firstChild);
+ }
+ return clonedDiv;
+}
+
+function cleanUpText(aElem, aHidePrivateData) {
+ let node = aElem.firstChild;
+ let copyData = aElem.dataset.copyData;
+ delete aElem.dataset.copyData;
+ while (node) {
+ let classList = "classList" in node && node.classList;
+ // Delete uionly and no-copy nodes.
+ if (
+ classList &&
+ (classList.contains(CLASS_DATA_UIONLY) || classList.contains("no-copy"))
+ ) {
+ // Advance to the next node before removing the current node, since
+ // node.nextElementSibling is null after remove()
+ let nextNode = node.nextElementSibling;
+ node.remove();
+ node = nextNode;
+ continue;
+ } else if (
+ aHidePrivateData &&
+ classList &&
+ classList.contains(CLASS_DATA_PRIVATE)
+ ) {
+ // Replace private data with a blank string.
+ node.textContent = "";
+ } else if (
+ !aHidePrivateData &&
+ classList &&
+ classList.contains(CLASS_DATA_PUBLIC)
+ ) {
+ // Replace public data with a blank string.
+ node.textContent = "";
+ } else if (copyData != null) {
+ // Replace localized text with non-localized text.
+ node.textContent = copyData;
+ copyData = null;
+ }
+
+ if (node.nodeType == Node.ELEMENT_NODE) {
+ cleanUpText(node, aHidePrivateData);
+ }
+
+ // Advance!
+ node = node.nextSibling;
+ }
+}
+
+// Return the plain text representation of an element. Do a little bit
+// of pretty-printing to make it human-readable.
+function createTextForElement(elem, aHidePrivateData) {
+ // Generate the initial text.
+ let textFragmentAccumulator = [];
+ generateTextForElement(elem, aHidePrivateData, "", textFragmentAccumulator);
+ let text = textFragmentAccumulator.join("");
+
+ // Trim extraneous whitespace before newlines, then squash extraneous
+ // blank lines.
+ text = text.replace(/[ \t]+\n/g, "\n");
+ text = text.replace(/\n{3,}/g, "\n\n");
+
+ // Actual CR/LF pairs are needed for some Windows text editors.
+ if ("@mozilla.org/windows-registry-key;1" in Cc) {
+ text = text.replace(/\n/g, "\r\n");
+ }
+
+ return text;
+}
+
+/**
+ * Elements to replace entirely with custom text. Keys are element ids, values
+ * are functions that return the text. The functions themselves are defined in
+ * the files for their respective sections.
+ */
+var gElementsToReplace = {
+ "accounts-table": getAccountsText,
+};
+
+function generateTextForElement(
+ elem,
+ aHidePrivateData,
+ indent,
+ textFragmentAccumulator
+) {
+ // Add a little extra spacing around most elements.
+ if (!["td", "th", "span", "a"].includes(elem.tagName)) {
+ textFragmentAccumulator.push("\n");
+ }
+
+ // If this element is one of our elements to replace with text, do it.
+ if (elem.id in gElementsToReplace) {
+ let replaceFn = gElementsToReplace[elem.id];
+ textFragmentAccumulator.push(replaceFn(aHidePrivateData, indent + " "));
+ return;
+ }
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ if (elem.id == "crashes-table") {
+ textFragmentAccumulator.push(getCrashesText(indent));
+ return;
+ }
+ }
+
+ let childCount = elem.childElementCount;
+
+ // We're not going to spread a two-column <tr> across multiple lines, so
+ // handle that separately.
+ if (elem.tagName == "tr" && childCount == 2) {
+ textFragmentAccumulator.push(indent);
+ textFragmentAccumulator.push(
+ elem.children[0].textContent.trim() +
+ ": " +
+ elem.children[1].textContent.trim()
+ );
+ return;
+ }
+
+ // Generate the text representation for each child node.
+ let node = elem.firstChild;
+ while (node) {
+ if (node.nodeType == Node.TEXT_NODE) {
+ // Text belonging to this element uses its indentation level.
+ generateTextForTextNode(node, indent, textFragmentAccumulator);
+ } else if (node.nodeType == Node.ELEMENT_NODE) {
+ // Recurse on the child element with an extra level of indentation (but
+ // only if there's more than one child).
+ generateTextForElement(
+ node,
+ aHidePrivateData,
+ indent + (childCount > 1 ? " " : ""),
+ textFragmentAccumulator
+ );
+ }
+ // Advance!
+ node = node.nextSibling;
+ }
+}
+
+function generateTextForTextNode(node, indent, textFragmentAccumulator) {
+ // If the text node is the first of a run of text nodes, then start
+ // a new line and add the initial indentation.
+ let prevNode = node.previousSibling;
+ if (!prevNode || prevNode.nodeType == Node.TEXT_NODE) {
+ textFragmentAccumulator.push("\n" + indent);
+ }
+
+ // Trim the text node's text content and add proper indentation after
+ // any internal line breaks.
+ let text = node.textContent.trim().replace(/\n/g, "\n" + indent);
+ textFragmentAccumulator.push(text);
+}
+
+/**
+ * Returns a plaintext representation of crashes data.
+ */
+
+function getCrashesText(aIndent) {
+ let crashesData = "";
+ let recentCrashesSubmitted = document.querySelectorAll("#crashes-tbody > tr");
+ for (let i = 0; i < recentCrashesSubmitted.length; i++) {
+ let tds = recentCrashesSubmitted.item(i).querySelectorAll("td");
+ crashesData +=
+ aIndent.repeat(2) +
+ tds.item(0).firstElementChild.href +
+ " (" +
+ tds.item(1).textContent +
+ ")\n";
+ }
+ return crashesData;
+}
diff --git a/comm/mail/components/about-support/content/libs.js b/comm/mail/components/about-support/content/libs.js
new file mode 100644
index 0000000000..1c431596ae
--- /dev/null
+++ b/comm/mail/components/about-support/content/libs.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+});
+
+/**
+ * Populates the "Mail Libraries" section of the troubleshooting information page.
+ */
+function populateLibrarySection() {
+ let { min_version, loaded_version, status, path } =
+ BondOpenPGP.getRNPLibStatus();
+
+ document.getElementById("rnp-expected-version").textContent = min_version;
+ document.getElementById("rnp-loaded-version").textContent = loaded_version;
+ document.getElementById("rnp-path").textContent = path;
+ document.l10n.setAttributes(document.getElementById("rnp-status"), status);
+}
diff --git a/comm/mail/components/about-support/jar.mn b/comm/mail/components/about-support/jar.mn
new file mode 100644
index 0000000000..4dd0375f7f
--- /dev/null
+++ b/comm/mail/components/about-support/jar.mn
@@ -0,0 +1,13 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/about-support/accounts.js (content/accounts.js)
+ content/messenger/about-support/export.js (content/export.js)
+* content/messenger/about-support/aboutSupport.xhtml (content/aboutSupport.xhtml)
+% override chrome://global/content/aboutSupport.xhtml chrome://messenger/content/about-support/aboutSupport.xhtml
+ content/messenger/about-support/aboutSupport.js (content/aboutSupport.js)
+ content/messenger/about-support/calendars.js (content/calendars.js)
+ content/messenger/about-support/chat.js (content/chat.js)
+ content/messenger/about-support/libs.js (content/libs.js)
diff --git a/comm/mail/components/about-support/moz.build b/comm/mail/components/about-support/moz.build
new file mode 100644
index 0000000000..2c16234de4
--- /dev/null
+++ b/comm/mail/components/about-support/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ EXTRA_JS_MODULES += ["AboutSupportWin32.jsm"]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ EXTRA_JS_MODULES += ["AboutSupportMac.jsm"]
+else:
+ EXTRA_JS_MODULES += ["AboutSupportUnix.jsm"]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/accountcreation/AccountConfig.jsm b/comm/mail/components/accountcreation/AccountConfig.jsm
new file mode 100644
index 0000000000..59b9604725
--- /dev/null
+++ b/comm/mail/components/accountcreation/AccountConfig.jsm
@@ -0,0 +1,463 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file creates the class AccountConfig, which is a JS object that holds
+ * a configuration for a certain account. It is *not* created in the backend
+ * yet (use aw-createAccount.js for that), and it may be incomplete.
+ *
+ * Several AccountConfig objects may co-exist, e.g. for autoconfig.
+ * One AccountConfig object is used to prefill and read the widgets
+ * in the Wizard UI.
+ * When we autoconfigure, we autoconfig writes the values into a
+ * new object and returns that, and the caller can copy these
+ * values into the object used by the UI.
+ *
+ * See also
+ * <https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat>
+ * for values stored.
+ */
+
+const EXPORTED_SYMBOLS = ["AccountConfig"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountCreationUtils",
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+function AccountConfig() {
+ this.incoming = this.createNewIncoming();
+ this.incomingAlternatives = [];
+ this.outgoing = this.createNewOutgoing();
+ this.outgoingAlternatives = [];
+ this.identity = {
+ // displayed real name of user
+ realname: "%REALNAME%",
+ // email address of user, as shown in From of outgoing mails
+ emailAddress: "%EMAILADDRESS%",
+ };
+ this.inputFields = [];
+ this.domains = [];
+}
+AccountConfig.prototype = {
+ // @see createNewIncoming()
+ incoming: null,
+ // @see createNewOutgoing()
+ outgoing: null,
+ /**
+ * Other servers which can be used instead of |incoming|,
+ * in order of decreasing preference.
+ * (|incoming| itself should not be included here.)
+ * { Array of incoming/createNewIncoming() }
+ */
+ incomingAlternatives: null,
+ outgoingAlternatives: null,
+ // just an internal string to refer to this. Do not show to user.
+ id: null,
+ // who created the config.
+ // { one of kSource* }
+ source: null,
+ /**
+ * Used for telemetry purposes.
+ * - for kSourceXML, subSource is one of xml-from-{disk, db, isp-https, isp-http}.
+ * - for kSourceExchange, subSource is one of exchange-from-urlN[-guess].
+ */
+ subSource: null,
+ displayName: null,
+ // { Array of { varname (value without %), displayName, exampleValue } }
+ inputFields: null,
+ // email address domains for which this config is applicable
+ // { Array of Strings }
+ domains: null,
+
+ /**
+ * Factory function for incoming and incomingAlternatives
+ */
+ createNewIncoming() {
+ return {
+ // { String-enum: "pop3", "imap", "nntp", "exchange" }
+ type: null,
+ hostname: null,
+ // { Integer }
+ port: null,
+ // May be a placeholder (starts and ends with %). { String }
+ username: null,
+ password: null,
+ // {nsMsgSocketType} @see MailNewsTypes2.idl. -1 means not inited
+ socketType: -1,
+ /**
+ * true when the cert is invalid (and thus SSL useless), because it's
+ * 1) not from an accepted CA (including self-signed certs)
+ * 2) for a different hostname or
+ * 3) expired.
+ * May go back to false when user explicitly accepted the cert.
+ */
+ badCert: false,
+ /**
+ * How to log in to the server: plaintext or encrypted pw, GSSAPI etc.
+ * Defined by Ci.nsMsgAuthMethod
+ * Same as server pref "authMethod".
+ */
+ auth: 0,
+ /**
+ * Other auth methods that we think the server supports.
+ * They are ordered by descreasing preference.
+ * (|auth| itself is not included in |authAlternatives|)
+ * {Array of Ci.nsMsgAuthMethod} (same as .auth)
+ */
+ authAlternatives: null,
+ // in minutes { Integer }
+ checkInterval: 10,
+ loginAtStartup: true,
+ // POP3 only:
+ // Not yet implemented. { Boolean }
+ useGlobalInbox: false,
+ leaveMessagesOnServer: true,
+ daysToLeaveMessagesOnServer: 14,
+ deleteByAgeFromServer: true,
+ // When user hits delete, delete from local store and from server
+ deleteOnServerWhenLocalDelete: true,
+ downloadOnBiff: true,
+ // Override `addThisServer` for a specific incoming server
+ useGlobalPreferredServer: false,
+
+ // OAuth2 configuration, if needed.
+ oauthSettings: null,
+
+ // for Microsoft Exchange servers. Optional.
+ owaURL: null,
+ ewsURL: null,
+ easURL: null,
+ // for when an addon overrides the account type. Optional.
+ addonAccountType: null,
+ };
+ },
+ /**
+ * Factory function for outgoing and outgoingAlternatives
+ */
+ createNewOutgoing() {
+ return {
+ type: "smtp",
+ hostname: null,
+ port: null, // see incoming
+ username: null, // see incoming. may be null, if auth is 0.
+ password: null, // see incoming. may be null, if auth is 0.
+ socketType: -1, // see incoming
+ badCert: false, // see incoming
+ auth: 0, // see incoming
+ authAlternatives: null, // see incoming
+ addThisServer: true, // if we already have an SMTP server, add this
+ // if we already have an SMTP server, use it.
+ useGlobalPreferredServer: false,
+ // we should reuse an already configured SMTP server.
+ // nsISmtpServer.key
+ existingServerKey: null,
+ // user display value for existingServerKey
+ existingServerLabel: null,
+
+ // OAuth2 configuration, if needed.
+ oauthSettings: null,
+ };
+ },
+
+ /**
+ * The configuration needs an addon to handle the account type.
+ * The addon needs to be installed before the account can be created
+ * in the backend.
+ * You can choose one, if there are several addons in the list.
+ * (Optional)
+ *
+ * Array of:
+ * {
+ * id: "owl@example.com" {string},
+ *
+ * // already localized string
+ * name: "Owl" {string},
+ *
+ * // already localized string
+ * description: "A third party addon that allows you to connect to Exchange servers" {string}
+ *
+ * // Minimal version of the addon. Needed in case the addon is already installed,
+ * // to verify that the installed version is sufficient.
+ * // The XPI URL below must satisfy this.
+ * // Must satisfy <https://developer.mozilla.org/en-US/docs/Mozilla/Toolkit_version_format>
+ * minVersion: "0.2" {string}
+ *
+ * xpiURL: "https://live.thunderbird.net/autoconfig/owl.xpi" {URL},
+ * websiteURL: "https://www.beonex.com/owl/" {URL},
+ * icon32: "https://www.beonex.com/owl/owl-32x32.png" {URL},
+ *
+ * useType : {
+ * // Type shown as radio button to user in the config result.
+ * // Users won't understand OWA vs. EWS vs. EAS etc., so this is an abstraction
+ * // from the end user perspective.
+ * generalType: "exchange" {string},
+ *
+ * // Protocol
+ * // Independent of the addon
+ * protocolType: "owa" {string},
+ *
+ * // Account type in the Thunderbird backend.
+ * // What nsIMsgAccount.type will be set to when creating the account.
+ * // This is specific to the addon.
+ * addonAccountType: "owl-owa" {string},
+ * }
+ * }
+ */
+ addons: null,
+
+ /**
+ * Returns a deep copy of this object,
+ * i.e. modifying the copy will not affect the original object.
+ */
+ copy() {
+ // Workaround: deepCopy() fails to preserve base obj (instanceof)
+ let result = new AccountConfig();
+ for (let prop in this) {
+ result[prop] = lazy.AccountCreationUtils.deepCopy(this[prop]);
+ }
+
+ return result;
+ },
+
+ isComplete() {
+ return (
+ !!this.incoming.hostname &&
+ !!this.incoming.port &&
+ this.incoming.socketType != -1 &&
+ !!this.incoming.auth &&
+ !!this.incoming.username &&
+ (!!this.outgoing.existingServerKey ||
+ this.outgoing.useGlobalPreferredServer ||
+ (!!this.outgoing.hostname &&
+ !!this.outgoing.port &&
+ this.outgoing.socketType != -1 &&
+ !!this.outgoing.auth &&
+ !!this.outgoing.username))
+ );
+ },
+
+ toString() {
+ function sslToString(socketType) {
+ switch (socketType) {
+ case 0:
+ return "plain";
+ case 2:
+ return "STARTTLS";
+ case 3:
+ return "SSL";
+ default:
+ return "invalid";
+ }
+ }
+
+ function authToString(authMethod) {
+ switch (authMethod) {
+ case 0:
+ return "undefined";
+ case 1:
+ return "none";
+ case 2:
+ return "old plain";
+ case 3:
+ return "plain";
+ case 4:
+ return "encrypted";
+ case 5:
+ return "Kerberos";
+ case 6:
+ return "NTLM";
+ case 7:
+ return "external/SSL";
+ case 8:
+ return "any secure";
+ case 10:
+ return "OAuth2";
+ default:
+ return "invalid";
+ }
+ }
+
+ function passwordToString(password) {
+ return password ? "set" : "not set";
+ }
+
+ function configToString(config) {
+ return (
+ config.type +
+ ", " +
+ config.hostname +
+ ":" +
+ config.port +
+ ", " +
+ sslToString(config.socketType) +
+ ", auth: " +
+ authToString(config.auth) +
+ ", username: " +
+ (config.username || "(undefined)") +
+ ", password: " +
+ passwordToString(config.password)
+ );
+ }
+
+ let result = "Incoming: " + configToString(this.incoming) + "\nOutgoing: ";
+ if (
+ this.outgoing.useGlobalPreferredServer ||
+ this.incoming.useGlobalPreferredServer
+ ) {
+ result += "Use global server";
+ } else if (this.outgoing.existingServerKey) {
+ result += "Use existing server " + this.outgoing.existingServerKey;
+ } else {
+ result += configToString(this.outgoing);
+ }
+ for (let config of this.incomingAlternatives) {
+ result += "\nIncoming alt: " + configToString(config);
+ }
+ for (let config of this.outgoingAlternatives) {
+ result += "\nOutgoing alt: " + configToString(config);
+ }
+ return result;
+ },
+
+ /**
+ * Sort the config alternatives such that exchange is the last of the
+ * alternatives.
+ */
+ preferStandardProtocols() {
+ let alternatives = this.incomingAlternatives;
+ // Add default incoming as one alternative.
+ alternatives.unshift(this.incoming);
+ alternatives.sort((a, b) => {
+ if (a.type == "exchange") {
+ return 1;
+ }
+ if (b.type == "exchange") {
+ return -1;
+ }
+ return 0;
+ });
+ this.incomingAlternatives = alternatives;
+ this.incoming = alternatives.shift();
+ },
+};
+
+// enum consts
+
+// .source
+AccountConfig.kSourceUser = "user"; // user manually entered the config
+AccountConfig.kSourceXML = "xml"; // config from XML from ISP or Mozilla DB
+AccountConfig.kSourceGuess = "guess"; // guessConfig()
+AccountConfig.kSourceExchange = "exchange"; // from Microsoft Exchange AutoDiscover
+
+/**
+ * Some fields on the account config accept placeholders (when coming from XML).
+ *
+ * These are the predefined ones
+ * %EMAILADDRESS% (full email address of the user, usually entered by user)
+ * %EMAILLOCALPART% (email address, part before @)
+ * %EMAILDOMAIN% (email address, part after @)
+ * %REALNAME%
+ * as well as those defined in account.inputFields.*.varname, with % added
+ * before and after.
+ *
+ * These must replaced with real values, supplied by the user or app,
+ * before the account is created. This is done here. You call this function once
+ * you have all the data - gathered the standard vars mentioned above as well as
+ * all listed in account.inputFields, and pass them in here. This function will
+ * insert them in the fields, returning a fully filled-out account ready to be
+ * created.
+ *
+ * @param account {AccountConfig}
+ * The account data to be modified. It may or may not contain placeholders.
+ * After this function, it should not contain placeholders anymore.
+ * This object will be modified in-place.
+ *
+ * @param emailfull {String}
+ * Full email address of this account, e.g. "joe@example.com".
+ * Empty of incomplete email addresses will/may be rejected.
+ *
+ * @param realname {String}
+ * Real name of user, as will appear in From of outgoing messages
+ *
+ * @param password {String}
+ * The password for the incoming server and (if necessary) the outgoing server
+ */
+AccountConfig.replaceVariables = function (
+ account,
+ realname,
+ emailfull,
+ password
+) {
+ lazy.Sanitizer.nonemptystring(emailfull);
+ let emailsplit = emailfull.split("@");
+ lazy.AccountCreationUtils.assert(
+ emailsplit.length == 2,
+ "email address not in expected format: must contain exactly one @"
+ );
+ let emaillocal = lazy.Sanitizer.nonemptystring(emailsplit[0]);
+ let emaildomain = lazy.Sanitizer.hostname(emailsplit[1]);
+ lazy.Sanitizer.label(realname);
+ lazy.Sanitizer.nonemptystring(realname);
+
+ let otherVariables = {};
+ otherVariables.EMAILADDRESS = emailfull;
+ otherVariables.EMAILLOCALPART = emaillocal;
+ otherVariables.EMAILDOMAIN = emaildomain;
+ otherVariables.REALNAME = realname;
+
+ if (password) {
+ account.incoming.password = password;
+ account.outgoing.password = password; // set member only if auth required?
+ }
+ account.incoming.username = _replaceVariable(
+ account.incoming.username,
+ otherVariables
+ );
+ account.outgoing.username = _replaceVariable(
+ account.outgoing.username,
+ otherVariables
+ );
+ account.incoming.hostname = _replaceVariable(
+ account.incoming.hostname,
+ otherVariables
+ );
+ if (account.outgoing.hostname) {
+ // will be null if user picked existing server.
+ account.outgoing.hostname = _replaceVariable(
+ account.outgoing.hostname,
+ otherVariables
+ );
+ }
+ account.identity.realname = _replaceVariable(
+ account.identity.realname,
+ otherVariables
+ );
+ account.identity.emailAddress = _replaceVariable(
+ account.identity.emailAddress,
+ otherVariables
+ );
+ account.displayName = _replaceVariable(account.displayName, otherVariables);
+};
+
+function _replaceVariable(variable, values) {
+ let str = variable;
+ if (typeof str != "string") {
+ return str;
+ }
+
+ for (let varname in values) {
+ str = str.replace("%" + varname + "%", values[varname]);
+ }
+
+ return str;
+}
diff --git a/comm/mail/components/accountcreation/AccountCreationUtils.jsm b/comm/mail/components/accountcreation/AccountCreationUtils.jsm
new file mode 100644
index 0000000000..f4efb96b2d
--- /dev/null
+++ b/comm/mail/components/accountcreation/AccountCreationUtils.jsm
@@ -0,0 +1,717 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/**
+ * Some common, generic functions
+ */
+
+const EXPORTED_SYMBOLS = ["AccountCreationUtils"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+);
+const { clearInterval, clearTimeout, setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+// --------------------------
+// Low level, basic functions
+
+function assert(test, errorMsg) {
+ if (!test) {
+ throw new NotReached(
+ errorMsg ? errorMsg : "Programming bug. Assertion failed, see log."
+ );
+ }
+}
+
+function makeCallback(obj, func) {
+ return func.bind(obj);
+}
+
+/**
+ * Runs the given function sometime later
+ *
+ * Currently implemented using setTimeout(), but
+ * can later be replaced with an nsITimer impl,
+ * when code wants to use it in a module.
+ *
+ * @see |TimeoutAbortable|
+ */
+function runAsync(func) {
+ return setTimeout(func, 0);
+}
+
+/**
+ * Reads UTF8 data from a URL.
+ *
+ * @param uri {nsIURI} - what you want to read
+ * @returns {Array of String} the contents of the file, one string per line
+ */
+function readURLasUTF8(uri) {
+ assert(uri instanceof Ci.nsIURI, "uri must be an nsIURI");
+ let chan = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let is = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(
+ Ci.nsIConverterInputStream
+ );
+ is.init(
+ chan.open(),
+ "UTF-8",
+ 1024,
+ Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER
+ );
+
+ let content = "";
+ let strOut = {};
+ try {
+ while (is.readString(1024, strOut) != 0) {
+ content += strOut.value;
+ }
+ } finally {
+ is.close();
+ }
+
+ return content;
+ // TODO this has a numeric error message. We need to ship translations
+ // into human language.
+}
+
+/**
+ * @param bundleURI {String} - chrome URL to properties file
+ * @returns nsIStringBundle
+ */
+function getStringBundle(bundleURI) {
+ try {
+ return Services.strings.createBundle(bundleURI);
+ } catch (e) {
+ throw new Exception(
+ "Failed to get stringbundle URI <" + bundleURI + ">. Error: " + e
+ );
+ }
+}
+
+// ---------
+// Exception
+
+function Exception(msg) {
+ this._message = msg;
+ this.stack = Components.stack.formattedStack;
+}
+Exception.prototype = {
+ get message() {
+ return this._message;
+ },
+ toString() {
+ return this._message;
+ },
+};
+
+function NotReached(msg) {
+ Exception.call(this, msg); // call super constructor
+ console.error(this);
+}
+// Make NotReached extend Exception.
+NotReached.prototype = Object.create(Exception.prototype);
+NotReached.prototype.constructor = NotReached;
+
+// ---------
+// Abortable
+
+/**
+ * A handle for an async function which you can cancel.
+ * The async function will return an object of this type (a subtype)
+ * and you can call cancel() when you feel like killing the function.
+ */
+function Abortable() {}
+Abortable.prototype = {
+ cancel(e) {},
+};
+
+function CancelledException(msg) {
+ Exception.call(this, msg);
+}
+CancelledException.prototype = Object.create(Exception.prototype);
+CancelledException.prototype.constructor = CancelledException;
+
+function UserCancelledException(msg) {
+ // The user knows they cancelled so I don't see a need
+ // for a message to that effect.
+ if (!msg) {
+ msg = "User cancelled";
+ }
+ CancelledException.call(this, msg);
+}
+UserCancelledException.prototype = Object.create(CancelledException.prototype);
+UserCancelledException.prototype.constructor = UserCancelledException;
+
+/**
+ * Utility implementation, for waiting for a promise to resolve,
+ * but allowing its result to be cancelled.
+ */
+function PromiseAbortable(promise, successCallback, errorCallback) {
+ Abortable.call(this); // call super constructor
+ let complete = false;
+ this.cancel = function (e) {
+ if (!complete) {
+ complete = true;
+ errorCallback(e || new CancelledException());
+ }
+ };
+ promise
+ .then(function (result) {
+ if (!complete) {
+ successCallback(result);
+ complete = true;
+ }
+ })
+ .catch(function (e) {
+ if (!complete) {
+ complete = true;
+ errorCallback(e);
+ }
+ });
+}
+PromiseAbortable.prototype = Object.create(Abortable.prototype);
+PromiseAbortable.prototype.constructor = PromiseAbortable;
+
+/**
+ * Utility implementation, for allowing to abort a setTimeout.
+ * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0));
+ *
+ * @param setTimeoutID {Integer} - Return value of setTimeout()
+ */
+function TimeoutAbortable(setTimeoutID) {
+ Abortable.call(this); // call super constructor
+ this._id = setTimeoutID;
+}
+TimeoutAbortable.prototype = Object.create(Abortable.prototype);
+TimeoutAbortable.prototype.constructor = TimeoutAbortable;
+TimeoutAbortable.prototype.cancel = function () {
+ clearTimeout(this._id);
+};
+
+/**
+ * Utility implementation, for allowing to abort a setTimeout.
+ * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0));
+ *
+ * @param setIntervalID {Integer} - Return value of setInterval()
+ */
+function IntervalAbortable(setIntervalID) {
+ Abortable.call(this); // call super constructor
+ this._id = setIntervalID;
+}
+IntervalAbortable.prototype = Object.create(Abortable.prototype);
+IntervalAbortable.prototype.constructor = IntervalAbortable;
+IntervalAbortable.prototype.cancel = function () {
+ clearInterval(this._id);
+};
+
+/**
+ * Allows you to make several network calls,
+ * but return only one |Abortable| object.
+ */
+function SuccessiveAbortable() {
+ Abortable.call(this); // call super constructor
+ this._current = null;
+}
+SuccessiveAbortable.prototype = {
+ __proto__: Abortable.prototype,
+ get current() {
+ return this._current;
+ },
+ set current(abortable) {
+ assert(
+ abortable instanceof Abortable || abortable == null,
+ "need an Abortable object (or null)"
+ );
+ this._current = abortable;
+ },
+ cancel(e) {
+ if (this._current) {
+ this._current.cancel(e);
+ }
+ },
+};
+
+/**
+ * Allows you to make several network calls in parallel.
+ */
+function ParallelAbortable() {
+ Abortable.call(this); // call super constructor
+ // { Array of ParallelCall }
+ this._calls = [];
+ // { Array of Function }
+ this._finishedObservers = [];
+}
+ParallelAbortable.prototype = {
+ __proto__: Abortable.prototype,
+ /**
+ * @returns {Array of ParallelCall}
+ */
+ get results() {
+ return this._calls;
+ },
+ /**
+ * @returns {ParallelCall}
+ */
+ addCall() {
+ let call = new ParallelCall(this);
+ call.position = this._calls.length;
+ this._calls.push(call);
+ return call;
+ },
+ /**
+ * Observers will be called once one of the functions
+ * finishes, i.e. returns successfully or fails.
+ *
+ * @param {Function({ParallelCall} call)} func
+ */
+ addOneFinishedObserver(func) {
+ assert(typeof func == "function");
+ this._finishedObservers.push(func);
+ },
+ /**
+ * Will be called once *all* of the functions finished,
+ * It gives you a list of all functions that succeeded or failed,
+ * respectively.
+ *
+ * @param {Function(
+ * {Array of ParallelCall} succeeded,
+ * {Array of ParallelCall} failed
+ * )} func
+ */
+ addAllFinishedObserver(func) {
+ assert(typeof func == "function");
+ this.addOneFinishedObserver(() => {
+ if (this._calls.some(call => !call.finished)) {
+ return;
+ }
+ let succeeded = this._calls.filter(call => call.succeeded);
+ let failed = this._calls.filter(call => !call.succeeded);
+ func(succeeded, failed);
+ });
+ },
+ _notifyFinished(call) {
+ for (let observer of this._finishedObservers) {
+ try {
+ observer(call);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+ cancel(e) {
+ for (let call of this._calls) {
+ if (!call.finished && call.callerAbortable) {
+ call.callerAbortable.cancel(e);
+ }
+ }
+ },
+};
+
+/**
+ * Returned by ParallelAbortable.addCall().
+ * Do not create this object directly
+ *
+ * @param {ParallelAbortable} parallelAbortable - The controlling ParallelAbortable
+ */
+function ParallelCall(parallelAbortable) {
+ assert(parallelAbortable instanceof ParallelAbortable);
+ // {ParallelAbortable} the parent
+ this._parallelAbortable = parallelAbortable;
+ // {Abortable} Abortable of the caller function that should run in parallel
+ this.callerAbortable = null;
+ // {Integer} the order in which the function was added, and its priority
+ this.position = null;
+ // {boolean} false = running, pending, false = success or failure
+ this.finished = false;
+ // {boolean} if finished: true = returned with success, false = returned with error
+ this.succeeded = false;
+ // {Exception} if failed: the error or exception that the caller function returned
+ this.e = null;
+ // {Object} if succeeded: the result of the caller function
+ this.result = null;
+
+ this._time = Date.now();
+}
+ParallelCall.prototype = {
+ /**
+ * Returns a successCallback(result) function that you pass
+ * to your function that runs in parallel.
+ *
+ * @returns {Function(result)} successCallback
+ */
+ successCallback() {
+ return result => {
+ ddump(
+ "call " +
+ this.position +
+ " took " +
+ (Date.now() - this._time) +
+ "ms and succeeded" +
+ (this.callerAbortable && this.callerAbortable._url
+ ? " at <" + this.callerAbortable._url + ">"
+ : "")
+ );
+ this.result = result;
+ this.finished = true;
+ this.succeeded = true;
+ this._parallelAbortable._notifyFinished(this);
+ };
+ },
+ /**
+ * Returns an errorCallback(e) function that you pass
+ * to your function that runs in parallel.
+ *
+ * @returns {Function(e)} errorCallback
+ */
+ errorCallback() {
+ return e => {
+ ddump(
+ "call " +
+ this.position +
+ " took " +
+ (Date.now() - this._time) +
+ "ms and failed with " +
+ (typeof e.code == "number" ? e.code + " " : "") +
+ (e.toString()
+ ? e.toString()
+ : "unknown error, probably no host connection") +
+ (this.callerAbortable && this.callerAbortable._url
+ ? " at <" + this.callerAbortable._url + ">"
+ : "")
+ );
+ this.e = e;
+ this.finished = true;
+ this.succeeded = false;
+ this._parallelAbortable._notifyFinished(this);
+ };
+ },
+ /**
+ * Call your function that needs to run in parallel
+ * and pass the resulting |Abortable| of your function here.
+ *
+ * @param {Abortable} abortable
+ */
+ setAbortable(abortable) {
+ assert(abortable instanceof Abortable);
+ this.callerAbortable = abortable;
+ },
+};
+
+/**
+ * Runs several calls in parallel.
+ * Returns the result of the "highest" priority call that succeeds.
+ * Unlike Promise.race(), does not return the fastest,
+ * but the first in the order they were added.
+ * So, the order in which the calls were added determines their priority,
+ * with the first to be added being the most desirable.
+ *
+ * E.g. the first failed, the second is pending, the third succeeded, and the forth is pending.
+ * It aborts the forth (because the third succeeded), and it waits for the second to return.
+ * If the second succeeds, it is the result, otherwise the third is the result.
+ *
+ * @param {Function(
+ * {Object} result - Result of winner call
+ * {ParallelCall} call - Winner call info
+ * )} successCallback - A call returned successfully
+ * @param {Function(e, allErrors)} errorCallback - All calls failed.
+ * {Exception} e - The first CancelledException, and otherwise
+ * the exception returned by the first call.
+ * This is just to adhere to the standard API of errorCallback(e).
+ * {Array of Exception} allErrors - The exceptions from all calls.
+ */
+function PriorityOrderAbortable(successCallback, errorCallback) {
+ assert(typeof successCallback == "function");
+ assert(typeof errorCallback == "function");
+ ParallelAbortable.call(this); // call super constructor
+ this._successfulCall = null;
+
+ this.addOneFinishedObserver(finishedCall => {
+ for (let call of this._calls) {
+ if (!call.finished) {
+ if (this._successfulCall) {
+ // abort
+ if (call.callerAbortable) {
+ call.callerAbortable.cancel(
+ new NoLongerNeededException("Another higher call succeeded")
+ );
+ }
+ continue;
+ }
+ // It's pending. do nothing and wait for it.
+ return;
+ }
+ if (!call.succeeded) {
+ // it failed. ignore it.
+ continue;
+ }
+ if (this._successfulCall) {
+ // we already have a winner. ignore it.
+ continue;
+ }
+ try {
+ successCallback(call.result, call);
+ // This is the winner.
+ this._successfulCall = call;
+ } catch (e) {
+ console.error(e);
+ // If the handler failed with this data, treat this call as failed.
+ call.e = e;
+ call.succeeded = false;
+ }
+ }
+ if (!this._successfulCall) {
+ // all failed
+ let allErrors = this._calls.map(call => call.e);
+ let e =
+ allErrors.find(e => e instanceof CancelledException) || allErrors[0];
+ errorCallback(e, allErrors); // see docs above
+ }
+ });
+}
+PriorityOrderAbortable.prototype = Object.create(ParallelAbortable.prototype);
+PriorityOrderAbortable.prototype.constructor = PriorityOrderAbortable;
+
+function NoLongerNeededException(msg) {
+ CancelledException.call(this, msg);
+}
+NoLongerNeededException.prototype = Object.create(CancelledException.prototype);
+NoLongerNeededException.prototype.constructor = NoLongerNeededException;
+
+// -------------------
+// High level features
+
+/**
+ * Allows you to install an addon.
+ *
+ * Example:
+ * var installer = new AddonInstaller({ xpiURL : "https://...xpi", id: "...", ...});
+ * installer.install();
+ *
+ * @param {object} args - Contains parameters:
+ * @param {string} name (Optional) - Name of the addon (not important)
+ * @param {string} id (Optional) - Addon ID
+ * If you pass an ID, and the addon is already installed (and the version matches),
+ * then install() will do nothing.
+ * After the XPI is downloaded, the ID will be verified. If it doesn't match, the
+ * install will fail.
+ * If you don't pass an ID, these checks will be skipped and the addon be installed
+ * unconditionally.
+ * It is recommended to pass at least an ID, because it can confuse some addons
+ * to be reloaded at runtime.
+ * @param {string} minVersion (Optional) - Minimum version of the addon
+ * If you pass a minVersion (in addition to ID), and the installed addon is older than this,
+ * the install will be done anyway. If the downloaded addon has a lower version,
+ * the install will fail.
+ * If you do not pass a minVersion, there will be no version check.
+ * @param {URL} xpiURL - Where to download the XPI from
+ */
+function AddonInstaller(args) {
+ Abortable.call(this);
+ this._name = lazy.Sanitizer.label(args.name);
+ this._id = lazy.Sanitizer.string(args.id);
+ this._minVersion = lazy.Sanitizer.string(args.minVersion);
+ this._url = lazy.Sanitizer.url(args.xpiURL);
+}
+AddonInstaller.prototype = Object.create(Abortable.prototype);
+AddonInstaller.prototype.constructor = AddonInstaller;
+
+/**
+ * Checks whether the passed-in addon matches the
+ * id and minVersion requested by the caller.
+ *
+ * @param {nsIAddon} addon
+ * @returns {boolean} is OK
+ */
+AddonInstaller.prototype.matches = function (addon) {
+ return (
+ !this._id ||
+ (this._id == addon.id &&
+ (!this._minVersion ||
+ Services.vc.compare(addon.version, this._minVersion) >= 0))
+ );
+};
+
+/**
+ * Start the installation
+ *
+ * @throws Exception in case of failure
+ */
+AddonInstaller.prototype.install = async function () {
+ if (await this.isInstalled()) {
+ return;
+ }
+ await this._installDirect();
+};
+
+/**
+ * Checks whether we already have an addon installed that matches the
+ * id and minVersion requested by the caller.
+ *
+ * @returns {boolean} is already installed and enabled
+ */
+AddonInstaller.prototype.isInstalled = async function () {
+ if (!this._id) {
+ return false;
+ }
+ var addon = await AddonManager.getAddonByID(this._id);
+ return addon && this.matches(addon) && addon.isActive;
+};
+
+/**
+ * Checks whether we already have an addon but it is disabled.
+ *
+ * @returns {boolean} is already installed but disabled
+ */
+AddonInstaller.prototype.isDisabled = async function () {
+ if (!this._id) {
+ return false;
+ }
+ let addon = await AddonManager.getAddonByID(this._id);
+ return addon && !addon.isActive;
+};
+
+/**
+ * Downloads and installs the addon.
+ * The downloaded XPI will be checked using prompt().
+ */
+AddonInstaller.prototype._installDirect = async function () {
+ var installer = (this._installer = await AddonManager.getInstallForURL(
+ this._url,
+ { name: this._name }
+ ));
+ installer.promptHandler = makeCallback(this, this.prompt);
+ await installer.install(); // throws, if failed
+
+ var addon = await AddonManager.getAddonByID(this._id);
+ await addon.enable();
+
+ // Wait for addon startup code to finish
+ // Fixes: verify password fails with NOT_AVAILABLE in createIncomingServer()
+ if ("startupPromise" in addon) {
+ await addon.startupPromise;
+ }
+ let wait = ms => new Promise(resolve => setTimeout(resolve, ms));
+ await wait(1000);
+};
+
+/**
+ * Install confirmation. You may override this, if needed.
+ *
+ * @throws Exception If you want to cancel install, then throw an exception.
+ */
+AddonInstaller.prototype.prompt = async function (info) {
+ if (!this.matches(info.addon)) {
+ // happens only when we got the wrong XPI
+ throw new Exception(
+ "The downloaded addon XPI does not match the minimum requirements"
+ );
+ }
+};
+
+AddonInstaller.prototype.cancel = function () {
+ if (this._installer) {
+ try {
+ this._installer.cancel();
+ } catch (e) {
+ // if install failed
+ ddump(e);
+ }
+ }
+};
+
+// ------------
+// Debug output
+
+function deepCopy(org) {
+ if (typeof org == "undefined") {
+ return undefined;
+ }
+ if (org == null) {
+ return null;
+ }
+ if (typeof org == "string") {
+ return org;
+ }
+ if (typeof org == "number") {
+ return org;
+ }
+ if (typeof org == "boolean") {
+ return org;
+ }
+ if (typeof org == "function") {
+ return org;
+ }
+ if (typeof org != "object") {
+ throw new Error("can't copy objects of type " + typeof org + " yet");
+ }
+
+ // TODO still instanceof org != instanceof copy
+ // var result = new org.constructor();
+ var result = {};
+ if (typeof org.length != "undefined") {
+ result = [];
+ }
+ for (var prop in org) {
+ result[prop] = deepCopy(org[prop]);
+ }
+ return result;
+}
+
+var gAccountSetupLogger = new ConsoleAPI({
+ prefix: "mail.setup",
+ maxLogLevel: "warn",
+ maxLogLevelPref: "mail.setup.loglevel",
+});
+
+function ddump(text) {
+ gAccountSetupLogger.info(text);
+}
+
+function alertPrompt(alertTitle, alertMsg) {
+ Services.prompt.alert(
+ Services.wm.getMostRecentWindow(""),
+ alertTitle,
+ alertMsg
+ );
+}
+
+var AccountCreationUtils = {
+ Abortable,
+ AddonInstaller,
+ alertPrompt,
+ assert,
+ CancelledException,
+ ddump,
+ deepCopy,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ NotReached,
+ PriorityOrderAbortable,
+ PromiseAbortable,
+ readURLasUTF8,
+ runAsync,
+ SuccessiveAbortable,
+ TimeoutAbortable,
+ UserCancelledException,
+};
diff --git a/comm/mail/components/accountcreation/ConfigVerifier.jsm b/comm/mail/components/accountcreation/ConfigVerifier.jsm
new file mode 100644
index 0000000000..cce934a159
--- /dev/null
+++ b/comm/mail/components/accountcreation/ConfigVerifier.jsm
@@ -0,0 +1,386 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ConfigVerifier"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { OAuth2Providers } = ChromeUtils.import(
+ "resource:///modules/OAuth2Providers.jsm"
+);
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+
+/**
+ * @implements {nsIUrlListener}
+ * @implements {nsIInterfaceRequestor}
+ */
+class ConfigVerifier {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIUrlListener",
+ ]);
+
+ // @see {nsIInterfaceRequestor}
+ getInterface(iid) {
+ return this.QueryInterface(iid);
+ }
+
+ constructor(msgWindow) {
+ this.msgWindow = msgWindow;
+ this._log = console.createInstance({
+ prefix: "mail.setup",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.setup.loglevel",
+ });
+ }
+
+ /**
+ * @param {nsIURI} url - The URL being processed.
+ * @see {nsIUrlListener}
+ */
+ OnStartRunningUrl(url) {
+ this._log.debug(`Starting to verify configuration;
+ email as username=${
+ this.config.incoming.username != this.config.identity.emailAddress
+ }
+ savedUsername=${this.config.usernameSaved ? "true" : "false"},
+ authMethod=${this.server.authMethod}`);
+ }
+
+ /**
+ * @param {nsIURI} url - The URL being processed.
+ * @param {nsresult} status - A result code of URL processing.
+ * @see {nsIUrlListener}
+ */
+ OnStopRunningUrl(url, status) {
+ if (Components.isSuccessCode(status)) {
+ this._log.debug(`Configuration verified successfully!`);
+ this.cleanup();
+ this.successCallback(this.config);
+ return;
+ }
+
+ this._log.debug(`Verifying configuration failed; status=${status}`);
+
+ let certError = false;
+ try {
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ certError = true;
+ }
+ } catch (e) {
+ // It's not an NSS error.
+ }
+
+ if (certError) {
+ let mailNewsUrl = url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ let secInfo = mailNewsUrl.failedSecInfo;
+ this.informUserOfCertError(secInfo, url.asciiHostPort);
+ } else if (this.alter) {
+ // Try other variations.
+ this.server.closeCachedConnections();
+ this.tryNextLogon(url);
+ } else {
+ // Logon failed, and we aren't supposed to try other variations.
+ this._failed(url);
+ }
+ }
+
+ tryNextLogon(aPreviousUrl) {
+ this._log.debug("Trying next logon variation");
+ // check if we tried full email address as username
+ if (this.config.incoming.username != this.config.identity.emailAddress) {
+ this._log.debug("Changing username to email address.");
+ this.config.usernameSaved = this.config.incoming.username;
+ this.config.incoming.username = this.config.identity.emailAddress;
+ this.config.outgoing.username = this.config.identity.emailAddress;
+ this.server.username = this.config.incoming.username;
+ this.server.password = this.config.incoming.password;
+ this.verifyLogon();
+ return;
+ }
+
+ if (this.config.usernameSaved) {
+ this._log.debug("Re-setting username.");
+ // If we tried the full email address as the username, then let's go
+ // back to trying just the username before trying the other cases.
+ this.config.incoming.username = this.config.usernameSaved;
+ this.config.outgoing.username = this.config.usernameSaved;
+ this.config.usernameSaved = null;
+ this.server.username = this.config.incoming.username;
+ this.server.password = this.config.incoming.password;
+ }
+
+ // sec auth seems to have failed, and we've tried both
+ // varieties of user name, sadly.
+ // So fall back to non-secure auth, and
+ // again try the user name and email address as username
+ if (this.server.socketType == Ci.nsMsgSocketType.SSL) {
+ this._log.debug("Using SSL");
+ } else if (this.server.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS) {
+ this._log.debug("Using STARTTLS");
+ }
+ if (
+ this.config.incoming.authAlternatives &&
+ this.config.incoming.authAlternatives.length
+ ) {
+ // We may be dropping back to insecure auth methods here,
+ // which is not good. But then again, we already warned the user,
+ // if it is a config without SSL.
+
+ let brokenAuth = this.config.incoming.auth;
+ // take the next best method (compare chooseBestAuthMethod() in guess)
+ this.config.incoming.auth = this.config.incoming.authAlternatives.shift();
+ this.server.authMethod = this.config.incoming.auth;
+ // Assume that SMTP server has same methods working as incoming.
+ // Broken assumption, but we currently have no SMTP verification.
+ // TODO: implement real SMTP verification
+ if (
+ this.config.outgoing.auth == brokenAuth &&
+ this.config.outgoing.authAlternatives.includes(
+ this.config.incoming.auth
+ )
+ ) {
+ this.config.outgoing.auth = this.config.incoming.auth;
+ }
+ this._log.debug(`Trying next auth method: ${this.server.authMethod}`);
+ this.verifyLogon();
+ return;
+ }
+
+ // Tried all variations we can. Give up.
+ this._log.debug("Have tried all variations. Giving up.");
+ this._failed(aPreviousUrl);
+ }
+
+ /**
+ * Clear out the server we had created for use during testing.
+ */
+ cleanup() {
+ try {
+ if (this.server) {
+ MailServices.accounts.removeIncomingServer(this.server, true);
+ this.server = null;
+ }
+ } catch (e) {
+ this._log.error(e);
+ }
+ }
+
+ /**
+ * @param {nsIURI} url - The URL being processed.
+ */
+ _failed(url) {
+ this.cleanup();
+ url = url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ let code = url.errorCode || "login-error-unknown";
+ let msg = url.errorMessage;
+ // *Only* for known (!) username/password errors, show our message.
+ // But there are 1000 other reasons why it could have failed, e.g.
+ // server not reachable, bad auth method, server hiccups, or even
+ // custom server messages that tell the user to do something,
+ // so show the backend error message, unless we are certain
+ // that it's a wrong username or password.
+ if (
+ !msg || // Normal IMAP login error sets no error msg
+ code == "pop3UsernameFailure" ||
+ code == "pop3PasswordFailed" ||
+ code == "imapOAuth2Error"
+ ) {
+ msg = AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ ).GetStringFromName("cannot_login.error");
+ }
+ this.errorCallback(new Error(msg));
+ }
+
+ /**
+ * Inform users that we got a certificate error for the specified location.
+ * Allow them to add an exception for it.
+ *
+ * @param {nsITransportSecurityInfo} secInfo
+ * @param {string} location - "host:port" that had the problem.
+ */
+ informUserOfCertError(secInfo, location) {
+ this._log.debug(`Informing user about cert error for ${location}`);
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ Services.wm
+ .getMostRecentWindow("mail:3pane")
+ .browsingContext.topChromeWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "exceptionDialog",
+ "chrome,centerscreen,modal",
+ params
+ );
+ if (!params.exceptionAdded) {
+ this._log.debug(`Did not accept exception for ${location}`);
+ this.cleanup();
+ let errorMsg = AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ ).GetStringFromName("cannot_login.error");
+ this.errorCallback(new Error(errorMsg));
+ } else {
+ this._log.debug(`Accept exception for ${location} - will retry logon.`);
+ // Retry the logon now that we've added the cert exception.
+ this.verifyLogon();
+ }
+ }
+
+ /**
+ * This checks a given config, by trying a real connection and login,
+ * with username and password.
+ *
+ * @param {AccountConfig} config - The guessed account config.
+ * username, password, realname, emailaddress etc. are not filled out,
+ * but placeholders to be filled out via replaceVariables().
+ * @param alter {boolean} - Try other usernames and login schemes, until
+ * login works. Warning: Modifies |config|.
+ * @returns {Promise<AccountConfig>} the successful configuration.
+ * @throws {Error} when we could guess not the config, either
+ * because we have not found anything or because there was an error
+ * (e.g. no network connection).
+ * The ex.message will contain a user-presentable message.
+ */
+ async verifyConfig(config, alter) {
+ this.alter = alter;
+ return new Promise((resolve, reject) => {
+ this.config = config;
+ this.successCallback = resolve;
+ this.errorCallback = reject;
+ if (
+ MailServices.accounts.findServer(
+ config.incoming.username,
+ config.incoming.hostname,
+ config.incoming.type,
+ config.incoming.port
+ )
+ ) {
+ reject(new Error("Incoming server exists"));
+ return;
+ }
+
+ // incoming server
+ if (!this.server) {
+ this.server = MailServices.accounts.createIncomingServer(
+ config.incoming.username,
+ config.incoming.hostname,
+ config.incoming.type
+ );
+ }
+ this.server.port = config.incoming.port;
+ this.server.password = config.incoming.password;
+ this.server.socketType = config.incoming.socketType;
+
+ this._log.info(
+ "Setting incoming server authMethod to " + config.incoming.auth
+ );
+ this.server.authMethod = config.incoming.auth;
+
+ try {
+ // Lookup OAuth2 issuer if needed.
+ // -- Incoming.
+ if (
+ config.incoming.auth == Ci.nsMsgAuthMethod.OAuth2 &&
+ (!config.incoming.oauthSettings ||
+ !config.incoming.oauthSettings.issuer ||
+ !config.incoming.oauthSettings.scope)
+ ) {
+ let details = OAuth2Providers.getHostnameDetails(
+ config.incoming.hostname
+ );
+ if (!details) {
+ reject(
+ new Error(
+ `Could not get OAuth2 details for hostname=${config.incoming.hostname}.`
+ )
+ );
+ }
+ config.incoming.oauthSettings = {
+ issuer: details[0],
+ scope: details[1],
+ };
+ }
+ // -- Outgoing.
+ if (
+ config.outgoing.auth == Ci.nsMsgAuthMethod.OAuth2 &&
+ (!config.outgoing.oauthSettings ||
+ !config.outgoing.oauthSettings.issuer ||
+ !config.outgoing.oauthSettings.scope)
+ ) {
+ let details = OAuth2Providers.getHostnameDetails(
+ config.outgoing.hostname
+ );
+ if (!details) {
+ reject(
+ new Error(
+ `Could not get OAuth2 details for hostname=${config.outgoing.hostname}.`
+ )
+ );
+ }
+ config.outgoing.oauthSettings = {
+ issuer: details[0],
+ scope: details[1],
+ };
+ }
+ if (config.incoming.owaURL) {
+ this.server.setUnicharValue("owa_url", config.incoming.owaURL);
+ }
+ if (config.incoming.ewsURL) {
+ this.server.setUnicharValue("ews_url", config.incoming.ewsURL);
+ }
+ if (config.incoming.easURL) {
+ this.server.setUnicharValue("eas_url", config.incoming.easURL);
+ }
+
+ if (
+ this.server.password ||
+ this.server.authMethod == Ci.nsMsgAuthMethod.OAuth2
+ ) {
+ this.verifyLogon();
+ } else {
+ this.cleanup();
+ resolve(config);
+ }
+ } catch (e) {
+ this._log.info("verifyConfig failed: " + e);
+ this.cleanup();
+ reject(e);
+ }
+ });
+ }
+
+ /**
+ * Verify that the provided credentials can log in to the incoming server.
+ */
+ verifyLogon() {
+ this._log.info("verifyLogon for server at " + this.server.hostName);
+ // Save away the old callbacks.
+ let saveCallbacks = this.msgWindow.notificationCallbacks;
+ // Set our own callbacks - this works because verifyLogon will
+ // synchronously create the transport and use the notification callbacks.
+ // Our listener listens both for the url and cert errors.
+ this.msgWindow.notificationCallbacks = this;
+ // try to work around bug where backend is clearing password.
+ try {
+ this.server.password = this.config.incoming.password;
+ let uri = this.server.verifyLogon(this, this.msgWindow);
+ // clear msgWindow so url won't prompt for passwords.
+ uri.QueryInterface(Ci.nsIMsgMailNewsUrl).msgWindow = null;
+ } finally {
+ // restore them
+ this.msgWindow.notificationCallbacks = saveCallbacks;
+ }
+ }
+}
diff --git a/comm/mail/components/accountcreation/CreateInBackend.jsm b/comm/mail/components/accountcreation/CreateInBackend.jsm
new file mode 100644
index 0000000000..c254bbb44b
--- /dev/null
+++ b/comm/mail/components/accountcreation/CreateInBackend.jsm
@@ -0,0 +1,459 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CreateInBackend"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountConfig",
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountCreationUtils",
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/* eslint-disable complexity */
+/**
+ * Takes an |AccountConfig| JS object and creates that account in the
+ * Thunderbird backend (which also writes it to prefs).
+ *
+ * @param {AccountConfig} config - The account to create
+ * @returns {nsIMsgAccount} - the newly created account
+ */
+function createAccountInBackend(config) {
+ // incoming server
+ let inServer = MailServices.accounts.createIncomingServer(
+ config.incoming.username,
+ config.incoming.hostname,
+ config.incoming.type
+ );
+ inServer.port = config.incoming.port;
+ inServer.authMethod = config.incoming.auth;
+ inServer.password = config.incoming.password;
+ // This new CLIENTID is for the outgoing server, and will be applied to the
+ // incoming only if the incoming username and hostname match the outgoing.
+ // We must generate this unconditionally because we cannot determine whether
+ // the outgoing server has clientid enabled yet or not, and we need to do it
+ // here in order to populate the incoming server if the outgoing matches.
+ let newOutgoingClientid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}]/g, "");
+ // Grab the base domain of both incoming and outgoing hostname in order to
+ // compare the two to detect if the base domain is the same.
+ let incomingBaseDomain;
+ let outgoingBaseDomain;
+ try {
+ incomingBaseDomain = Services.eTLD.getBaseDomainFromHost(
+ config.incoming.hostname
+ );
+ } catch (e) {
+ incomingBaseDomain = config.incoming.hostname;
+ }
+ try {
+ outgoingBaseDomain = Services.eTLD.getBaseDomainFromHost(
+ config.outgoing.hostname
+ );
+ } catch (e) {
+ outgoingBaseDomain = config.outgoing.hostname;
+ }
+ if (
+ config.incoming.username == config.outgoing.username &&
+ incomingBaseDomain == outgoingBaseDomain
+ ) {
+ inServer.clientid = newOutgoingClientid;
+ } else {
+ // If the username/hostname are different then generate a new CLIENTID.
+ inServer.clientid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}]/g, "");
+ }
+
+ if (config.rememberPassword && config.incoming.password) {
+ rememberPassword(inServer, config.incoming.password);
+ }
+
+ if (inServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) {
+ inServer.setUnicharValue(
+ "oauth2.scope",
+ config.incoming.oauthSettings.scope
+ );
+ inServer.setUnicharValue(
+ "oauth2.issuer",
+ config.incoming.oauthSettings.issuer
+ );
+ }
+
+ // SSL
+ inServer.socketType = config.incoming.socketType;
+
+ // If we already have an account with an identical name, generate a unique
+ // name for the new account to avoid duplicates.
+ inServer.prettyName = checkAccountNameAlreadyExists(
+ config.identity.emailAddress
+ )
+ ? generateUniqueAccountName(config)
+ : config.identity.emailAddress;
+
+ inServer.doBiff = true;
+ inServer.biffMinutes = config.incoming.checkInterval;
+ inServer.setBoolValue("login_at_startup", config.incoming.loginAtStartup);
+ if (config.incoming.type == "pop3") {
+ inServer.setBoolValue(
+ "leave_on_server",
+ config.incoming.leaveMessagesOnServer
+ );
+ inServer.setIntValue(
+ "num_days_to_leave_on_server",
+ config.incoming.daysToLeaveMessagesOnServer
+ );
+ inServer.setBoolValue(
+ "delete_mail_left_on_server",
+ config.incoming.deleteOnServerWhenLocalDelete
+ );
+ inServer.setBoolValue(
+ "delete_by_age_from_server",
+ config.incoming.deleteByAgeFromServer
+ );
+ inServer.setBoolValue("download_on_biff", config.incoming.downloadOnBiff);
+ }
+ if (config.incoming.owaURL) {
+ inServer.setUnicharValue("owa_url", config.incoming.owaURL);
+ }
+ if (config.incoming.ewsURL) {
+ inServer.setUnicharValue("ews_url", config.incoming.ewsURL);
+ }
+ if (config.incoming.easURL) {
+ inServer.setUnicharValue("eas_url", config.incoming.easURL);
+ }
+ inServer.valid = true;
+
+ let username =
+ config.outgoing.auth != Ci.nsMsgAuthMethod.none
+ ? config.outgoing.username
+ : null;
+ let outServer = MailServices.smtp.findServer(
+ username,
+ config.outgoing.hostname
+ );
+ lazy.AccountCreationUtils.assert(
+ config.outgoing.addThisServer ||
+ config.outgoing.useGlobalPreferredServer ||
+ config.outgoing.existingServerKey,
+ "No SMTP server: inconsistent flags"
+ );
+
+ if (
+ config.outgoing.addThisServer &&
+ !outServer &&
+ !config.incoming.useGlobalPreferredServer
+ ) {
+ outServer = MailServices.smtp.createServer();
+ outServer.hostname = config.outgoing.hostname;
+ outServer.port = config.outgoing.port;
+ outServer.authMethod = config.outgoing.auth;
+ // Populate the clientid if it is enabled for this outgoing server.
+ if (outServer.clientidEnabled) {
+ outServer.clientid = newOutgoingClientid;
+ }
+ if (config.outgoing.auth != Ci.nsMsgAuthMethod.none) {
+ outServer.username = username;
+ outServer.password = config.outgoing.password;
+ if (config.rememberPassword && config.outgoing.password) {
+ rememberPassword(outServer, config.outgoing.password);
+ }
+ }
+
+ if (outServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) {
+ let prefBranch = "mail.smtpserver." + outServer.key + ".";
+ Services.prefs.setCharPref(
+ prefBranch + "oauth2.scope",
+ config.outgoing.oauthSettings.scope
+ );
+ Services.prefs.setCharPref(
+ prefBranch + "oauth2.issuer",
+ config.outgoing.oauthSettings.issuer
+ );
+ }
+
+ outServer.socketType = config.outgoing.socketType;
+ outServer.description = config.displayName;
+
+ // If this is the first SMTP server, set it as default
+ if (
+ !MailServices.smtp.defaultServer ||
+ !MailServices.smtp.defaultServer.hostname
+ ) {
+ MailServices.smtp.defaultServer = outServer;
+ }
+ }
+
+ // identity
+ // TODO accounts without identity?
+ let identity = MailServices.accounts.createIdentity();
+ identity.fullName = config.identity.realname;
+ identity.email = config.identity.emailAddress;
+
+ // for new accounts, default to replies being positioned above the quote
+ // if a default account is defined already, take its settings instead
+ if (config.incoming.type == "imap" || config.incoming.type == "pop3") {
+ identity.replyOnTop = 1;
+ // identity.sigBottom = false; // don't set this until Bug 218346 is fixed
+
+ if (
+ MailServices.accounts.accounts.length &&
+ MailServices.accounts.defaultAccount
+ ) {
+ let defAccount = MailServices.accounts.defaultAccount;
+ let defIdentity = defAccount.defaultIdentity;
+ if (
+ defAccount.incomingServer.canBeDefaultServer &&
+ defIdentity &&
+ defIdentity.valid
+ ) {
+ identity.replyOnTop = defIdentity.replyOnTop;
+ identity.sigBottom = defIdentity.sigBottom;
+ }
+ }
+ }
+
+ // due to accepted conventions, news accounts should default to plain text
+ if (config.incoming.type == "nntp") {
+ identity.composeHtml = false;
+ }
+
+ identity.valid = true;
+
+ if (
+ !config.outgoing.useGlobalPreferredServer &&
+ !config.incoming.useGlobalPreferredServer
+ ) {
+ if (config.outgoing.existingServerKey) {
+ identity.smtpServerKey = config.outgoing.existingServerKey;
+ } else {
+ identity.smtpServerKey = outServer.key;
+ }
+ }
+
+ // account and hook up
+ // Note: Setting incomingServer will cause the AccountManager to refresh
+ // itself, which could be a problem if we came from it and we haven't set
+ // the identity (see bug 521955), so make sure everything else on the
+ // account is set up before you set the incomingServer.
+ let account = MailServices.accounts.createAccount();
+ account.addIdentity(identity);
+ account.incomingServer = inServer;
+ if (
+ inServer.canBeDefaultServer &&
+ (!MailServices.accounts.defaultAccount ||
+ !MailServices.accounts.defaultAccount.incomingServer.canBeDefaultServer)
+ ) {
+ MailServices.accounts.defaultAccount = account;
+ }
+
+ verifyLocalFoldersAccount(MailServices.accounts);
+ setFolders(identity, inServer);
+
+ // save
+ MailServices.accounts.saveAccountInfo();
+ try {
+ Services.prefs.savePrefFile(null);
+ } catch (ex) {
+ lazy.AccountCreationUtils.ddump("Could not write out prefs: " + ex);
+ }
+ return account;
+}
+/* eslint-enable complexity */
+
+function setFolders(identity, server) {
+ // TODO: support for local folders for global inbox (or use smart search
+ // folder instead)
+
+ var baseURI = server.serverURI + "/";
+
+ // Names will be localized in UI, not in folder names on server/disk
+ // TODO allow to override these names in the XML config file,
+ // in case e.g. Google or AOL use different names?
+ // Workaround: Let user fix it :)
+ var fccName = "Sent";
+ var draftName = "Drafts";
+ var templatesName = "Templates";
+
+ identity.draftFolder = baseURI + draftName;
+ identity.stationeryFolder = baseURI + templatesName;
+ identity.fccFolder = baseURI + fccName;
+
+ identity.fccFolderPickerMode = 0;
+ identity.draftsFolderPickerMode = 0;
+ identity.tmplFolderPickerMode = 0;
+}
+
+function rememberPassword(server, password) {
+ let passwordURI;
+ if (server instanceof Ci.nsIMsgIncomingServer) {
+ passwordURI = server.localStoreType + "://" + server.hostName;
+ } else if (server instanceof Ci.nsISmtpServer) {
+ passwordURI = "smtp://" + server.hostname;
+ } else {
+ throw new lazy.AccountCreationUtils.NotReached("Server type not supported");
+ }
+
+ let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ login.init(passwordURI, null, passwordURI, server.username, password, "", "");
+ try {
+ Services.logins.addLogin(login);
+ } catch (e) {
+ if (e.message.includes("This login already exists")) {
+ // TODO modify
+ } else {
+ throw e;
+ }
+ }
+}
+
+/**
+ * Check whether the user's setup already has an incoming server
+ * which matches (hostname, port, username) the primary one
+ * in the config.
+ * (We also check the email address as username.)
+ *
+ * @param config {AccountConfig} filled in (no placeholders)
+ * @returns {nsIMsgIncomingServer} If it already exists, the server
+ * object is returned.
+ * If it's a new server, |null| is returned.
+ */
+function checkIncomingServerAlreadyExists(config) {
+ lazy.AccountCreationUtils.assert(config instanceof lazy.AccountConfig);
+ let incoming = config.incoming;
+ let existing = MailServices.accounts.findServer(
+ incoming.username,
+ incoming.hostname,
+ incoming.type,
+ incoming.port
+ );
+
+ // if username does not have an '@', also check the e-mail
+ // address form of the name.
+ if (!existing && !incoming.username.includes("@")) {
+ existing = MailServices.accounts.findServer(
+ config.identity.emailAddress,
+ incoming.hostname,
+ incoming.type,
+ incoming.port
+ );
+ }
+ return existing;
+}
+
+/**
+ * Check whether the user's setup already has an outgoing server
+ * which matches (hostname, port, username) the primary one
+ * in the config.
+ *
+ * @param config {AccountConfig} filled in (no placeholders)
+ * @returns {nsISmtpServer} If it already exists, the server
+ * object is returned.
+ * If it's a new server, |null| is returned.
+ */
+function checkOutgoingServerAlreadyExists(config) {
+ lazy.AccountCreationUtils.assert(config instanceof lazy.AccountConfig);
+ for (let existingServer of MailServices.smtp.servers) {
+ // TODO check username with full email address, too, like for incoming
+ if (
+ existingServer.hostname == config.outgoing.hostname &&
+ existingServer.port == config.outgoing.port &&
+ existingServer.username == config.outgoing.username
+ ) {
+ return existingServer;
+ }
+ }
+ return null;
+}
+
+/**
+ * Check whether the user's setup already has an account with the same email
+ * address. This might happen if the user uses the same email for different
+ * protocols (eg. IMAP and POP3).
+ *
+ * @param {string} name - The name or email address of the new account.
+ * @returns {boolean} True if an account with the same name is found.
+ */
+function checkAccountNameAlreadyExists(name) {
+ return MailServices.accounts.accounts.some(
+ a => a.incomingServer.prettyName == name
+ );
+}
+
+/**
+ * Generate a unique account name by appending the incoming protocol type, and
+ * a counter if necessary.
+ *
+ * @param {AccountConfig} config - The config data of the account being created.
+ * @returns {string} - The unique account name.
+ */
+function generateUniqueAccountName(config) {
+ // Generate a potential unique name. e.g. "foo@bar.com (POP3)".
+ let name = `${
+ config.identity.emailAddress
+ } (${config.incoming.type.toUpperCase()})`;
+
+ // If this name already exists, append a counter until we find a unique name.
+ if (checkAccountNameAlreadyExists(name)) {
+ let counter = 2;
+ while (checkAccountNameAlreadyExists(`${name}_${counter}`)) {
+ counter++;
+ }
+ // e.g. "foo@bar.com (POP3)_1".
+ name = `${name}_${counter}`;
+ }
+
+ return name;
+}
+
+/**
+ * Check if there already is a "Local Folders". If not, create it.
+ * Copied from AccountWizard.js with minor updates.
+ */
+function verifyLocalFoldersAccount(am) {
+ let localMailServer;
+ try {
+ localMailServer = am.localFoldersServer;
+ } catch (ex) {
+ localMailServer = null;
+ }
+
+ try {
+ if (!localMailServer) {
+ // creates a copy of the identity you pass in
+ am.createLocalMailAccount();
+ try {
+ localMailServer = am.localFoldersServer;
+ } catch (ex) {
+ lazy.AccountCreationUtils.ddump(
+ "Error! we should have found the local mail server " +
+ "after we created it."
+ );
+ }
+ }
+ } catch (ex) {
+ lazy.AccountCreationUtils.ddump("Error in verifyLocalFoldersAccount " + ex);
+ }
+}
+
+var CreateInBackend = {
+ checkIncomingServerAlreadyExists,
+ checkOutgoingServerAlreadyExists,
+ createAccountInBackend,
+};
diff --git a/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm
new file mode 100644
index 0000000000..5813aa0240
--- /dev/null
+++ b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm
@@ -0,0 +1,676 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["fetchConfigFromExchange", "getAddonsList"];
+
+var { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm",
+ FetchHTTP: "resource:///modules/accountcreation/FetchHTTP.jsm",
+ GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm",
+ Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm",
+});
+
+var {
+ Abortable,
+ assert,
+ ddump,
+ deepCopy,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ PriorityOrderAbortable,
+ SuccessiveAbortable,
+ TimeoutAbortable,
+} = AccountCreationUtils;
+
+/**
+ * Tries to get a configuration from an MS Exchange server
+ * using Microsoft AutoDiscover protocol.
+ *
+ * Disclaimers:
+ * - To support domain hosters, we cannot use SSL. That means we
+ * rely on insecure DNS and http, which means the results may be
+ * forged when under attack. The same is true for guessConfig(), though.
+ *
+ * @param {string} domain - The domain part of the user's email address
+ * @param {string} emailAddress - The user's email address
+ * @param {string} username - (Optional) The user's login name.
+ * If null, email address will be used.
+ * @param {string} password - The user's password for that email address
+ * @param {Function(domain, okCallback, cancelCallback)} confirmCallback - A
+ * callback that will be called to confirm redirection to another domain.
+ * @param {Function(config {AccountConfig})} successCallback - A callback that
+ * will be called when we could retrieve a configuration.
+ * The AccountConfig object will be passed in as first parameter.
+ * @param {Function(ex)} errorCallback - A callback that
+ * will be called when we could not retrieve a configuration,
+ * for whatever reason. This is expected (e.g. when there's no config
+ * for this domain at this location),
+ * so do not unconditionally show this to the user.
+ * The first parameter will be an exception object or error string.
+ */
+function fetchConfigFromExchange(
+ domain,
+ emailAddress,
+ username,
+ password,
+ confirmCallback,
+ successCallback,
+ errorCallback
+) {
+ assert(typeof successCallback == "function");
+ assert(typeof errorCallback == "function");
+ if (
+ !Services.prefs.getBoolPref(
+ "mailnews.auto_config.fetchFromExchange.enabled",
+ true
+ )
+ ) {
+ errorCallback("Exchange AutoDiscover disabled per user preference");
+ return new Abortable();
+ }
+
+ // <https://technet.microsoft.com/en-us/library/bb124251(v=exchg.160).aspx#Autodiscover%20services%20in%20Outlook>
+ // <https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638(v%3Dexchg.140)>, search for "The Autodiscover service uses one of these four methods"
+ let url1 =
+ "https://autodiscover." +
+ lazy.Sanitizer.hostname(domain) +
+ "/autodiscover/autodiscover.xml";
+ let url2 =
+ "https://" +
+ lazy.Sanitizer.hostname(domain) +
+ "/autodiscover/autodiscover.xml";
+ let url3 =
+ "http://autodiscover." +
+ lazy.Sanitizer.hostname(domain) +
+ "/autodiscover/autodiscover.xml";
+ let body = `<?xml version="1.0" encoding="utf-8"?>
+ <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
+ <Request>
+ <EMailAddress>${emailAddress}</EMailAddress>
+ <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
+ </Request>
+ </Autodiscover>`;
+ let callArgs = {
+ uploadBody: body,
+ post: true,
+ headers: {
+ // outlook.com needs this exact string, with space and lower case "utf".
+ // Compare bug 1454325 comment 15.
+ "Content-Type": "text/xml; charset=utf-8",
+ },
+ username: username || emailAddress,
+ password,
+ allowAuthPrompt: false,
+ };
+ let call;
+ let fetch;
+ let fetch3;
+
+ let successive = new SuccessiveAbortable();
+ let priority = new PriorityOrderAbortable(function (xml, call) {
+ // success
+ readAutoDiscoverResponse(
+ xml,
+ successive,
+ emailAddress,
+ username,
+ password,
+ confirmCallback,
+ config => {
+ config.subSource = `exchange-from-${call.foundMsg}`;
+ return detectStandardProtocols(config, domain, successCallback);
+ },
+ errorCallback
+ );
+ }, errorCallback); // all failed
+
+ call = priority.addCall();
+ call.foundMsg = "url1";
+ fetch = new lazy.FetchHTTP(
+ url1,
+ callArgs,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ fetch.start();
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ call.foundMsg = "url2";
+ fetch = new lazy.FetchHTTP(
+ url2,
+ callArgs,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ fetch.start();
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ call.foundMsg = "url3";
+ let call3ErrorCallback = call.errorCallback();
+ // url3 is HTTP (not HTTPS), so suppress password. Even MS spec demands so.
+ let call3Args = deepCopy(callArgs);
+ delete call3Args.username;
+ delete call3Args.password;
+ fetch3 = new lazy.FetchHTTP(url3, call3Args, call.successCallback(), ex => {
+ // url3 is an HTTP URL that will redirect to the real one, usually a
+ // HTTPS URL of the hoster. XMLHttpRequest unfortunately loses the call
+ // parameters, drops the auth, drops the body, and turns POST into GET,
+ // which cause the call to fail. For AutoDiscover mechanism to work,
+ // we need to repeat the call with the correct parameters again.
+ let redirectURL = fetch3._request.responseURL;
+ if (!redirectURL.startsWith("https:")) {
+ call3ErrorCallback(ex);
+ return;
+ }
+ let redirectURI = Services.io.newURI(redirectURL);
+ let redirectDomain = Services.eTLD.getBaseDomain(redirectURI);
+ let originalDomain = Services.eTLD.getBaseDomainFromHost(domain);
+
+ function fetchRedirect() {
+ let fetchCall = priority.addCall();
+ let fetch = new lazy.FetchHTTP(
+ redirectURL,
+ callArgs, // now with auth
+ fetchCall.successCallback(),
+ fetchCall.errorCallback()
+ );
+ fetchCall.setAbortable(fetch);
+ fetch.start();
+ }
+
+ const kSafeDomains = ["office365.com", "outlook.com"];
+ if (
+ redirectDomain != originalDomain &&
+ !kSafeDomains.includes(redirectDomain)
+ ) {
+ // Given that we received the redirect URL from an insecure HTTP call,
+ // we ask the user whether he trusts the redirect domain.
+ gAccountSetupLogger.info("AutoDiscover HTTP redirected to other domain");
+ let dialogSuccessive = new SuccessiveAbortable();
+ // Because the dialog implements Abortable, the dialog will cancel and
+ // close automatically, if a slow higher priority call returns late.
+ let dialogCall = priority.addCall();
+ dialogCall.setAbortable(dialogSuccessive);
+ call3ErrorCallback(new Exception("Redirected"));
+ dialogSuccessive.current = new TimeoutAbortable(
+ lazy.setTimeout(() => {
+ dialogSuccessive.current = confirmCallback(
+ redirectDomain,
+ () => {
+ // User agreed.
+ fetchRedirect();
+ // Remove the dialog from the call stack.
+ dialogCall.errorCallback()(new Exception("Proceed to fetch"));
+ },
+ ex => {
+ // User rejected, or action cancelled otherwise.
+ dialogCall.errorCallback()(ex);
+ }
+ );
+ // Account for a slow server response.
+ // This will prevent showing the warning message when not necessary.
+ // The timeout is just for optics. The Abortable ensures that it works.
+ }, 2000)
+ );
+ } else {
+ fetchRedirect();
+ call3ErrorCallback(new Exception("Redirected"));
+ }
+ });
+ fetch3.start();
+ call.setAbortable(fetch3);
+
+ successive.current = priority;
+ return successive;
+}
+
+var gLoopCounter = 0;
+
+/**
+ * @param {JXON} xml - The Exchange server AutoDiscover response
+ * @param {Function(config {AccountConfig})} successCallback - @see accountConfig.js
+ */
+function readAutoDiscoverResponse(
+ autoDiscoverXML,
+ successive,
+ emailAddress,
+ username,
+ password,
+ confirmCallback,
+ successCallback,
+ errorCallback
+) {
+ assert(successive instanceof SuccessiveAbortable);
+ assert(typeof successCallback == "function");
+ assert(typeof errorCallback == "function");
+
+ // redirect to other email address
+ if (
+ "Account" in autoDiscoverXML.Autodiscover.Response &&
+ "RedirectAddr" in autoDiscoverXML.Autodiscover.Response.Account
+ ) {
+ // <https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/49083e77-8dc2-4010-85c6-f40e090f3b17>
+ let redirectEmailAddress = lazy.Sanitizer.emailAddress(
+ autoDiscoverXML.Autodiscover.Response.Account.RedirectAddr
+ );
+ let domain = redirectEmailAddress.split("@").pop();
+ if (++gLoopCounter > 2) {
+ throw new Error("Too many redirects in XML response; domain=" + domain);
+ }
+ successive.current = fetchConfigFromExchange(
+ domain,
+ redirectEmailAddress,
+ // Per spec, need to authenticate with the original email address,
+ // not the redirected address (if not already overridden).
+ username || emailAddress,
+ password,
+ confirmCallback,
+ successCallback,
+ errorCallback
+ );
+ return;
+ }
+
+ let config = readAutoDiscoverXML(autoDiscoverXML, username);
+ if (config.isComplete()) {
+ successCallback(config);
+ } else {
+ errorCallback(new Exception("No valid configs found in AutoDiscover XML"));
+ }
+}
+
+/* eslint-disable complexity */
+/**
+ * @param {JXON} xml - The Exchange server AutoDiscover response
+ * @param {string} username - (Optional) The user's login name
+ * If null, email address placeholder will be used.
+ * @returns {AccountConfig} - @see accountConfig.js
+ *
+ * @see <https://www.msxfaq.de/exchange/autodiscover/autodiscover_xml.htm>
+ */
+function readAutoDiscoverXML(autoDiscoverXML, username) {
+ if (
+ typeof autoDiscoverXML != "object" ||
+ !("Autodiscover" in autoDiscoverXML) ||
+ !("Response" in autoDiscoverXML.Autodiscover) ||
+ !("Account" in autoDiscoverXML.Autodiscover.Response) ||
+ !("Protocol" in autoDiscoverXML.Autodiscover.Response.Account)
+ ) {
+ let stringBundle = getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ );
+ throw new Exception(
+ stringBundle.GetStringFromName("no_autodiscover.error")
+ );
+ }
+ var xml = autoDiscoverXML.Autodiscover.Response.Account;
+
+ function array_or_undef(value) {
+ return value === undefined ? [] : value;
+ }
+
+ var config = new lazy.AccountConfig();
+ config.source = lazy.AccountConfig.kSourceExchange;
+ config.incoming.username = username || "%EMAILADDRESS%";
+ config.incoming.socketType = Ci.nsMsgSocketType.SSL; // only https supported
+ config.incoming.port = 443;
+ config.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ config.incoming.authAlternatives = [Ci.nsMsgAuthMethod.OAuth2];
+ config.outgoing.addThisServer = false;
+ config.outgoing.useGlobalPreferredServer = true;
+
+ for (let protocolX of array_or_undef(xml.$Protocol)) {
+ try {
+ let type = lazy.Sanitizer.enum(
+ protocolX.Type,
+ ["WEB", "EXHTTP", "EXCH", "EXPR", "POP3", "IMAP", "SMTP"],
+ "unknown"
+ );
+ if (type == "WEB") {
+ let urlsX;
+ if ("External" in protocolX) {
+ urlsX = protocolX.External;
+ } else if ("Internal" in protocolX) {
+ urlsX = protocolX.Internal;
+ }
+ if (urlsX) {
+ config.incoming.owaURL = lazy.Sanitizer.url(urlsX.OWAUrl.value);
+ if (
+ !config.incoming.ewsURL &&
+ "Protocol" in urlsX &&
+ "ASUrl" in urlsX.Protocol
+ ) {
+ config.incoming.ewsURL = lazy.Sanitizer.url(urlsX.Protocol.ASUrl);
+ }
+ config.incoming.type = "exchange";
+ let parsedURL = new URL(config.incoming.owaURL);
+ config.incoming.hostname = lazy.Sanitizer.hostname(
+ parsedURL.hostname
+ );
+ if (parsedURL.port) {
+ config.incoming.port = lazy.Sanitizer.integer(parsedURL.port);
+ }
+ }
+ } else if (type == "EXHTTP" || type == "EXCH") {
+ config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.EwsUrl);
+ if (!config.incoming.ewsURL) {
+ config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.ASUrl);
+ }
+ config.incoming.type = "exchange";
+ let parsedURL = new URL(config.incoming.ewsURL);
+ config.incoming.hostname = lazy.Sanitizer.hostname(parsedURL.hostname);
+ if (parsedURL.port) {
+ config.incoming.port = lazy.Sanitizer.integer(parsedURL.port);
+ }
+ } else if (type == "POP3" || type == "IMAP" || type == "SMTP") {
+ let server;
+ if (type == "SMTP") {
+ server = config.createNewOutgoing();
+ } else {
+ server = config.createNewIncoming();
+ }
+
+ server.type = lazy.Sanitizer.translate(type, {
+ POP3: "pop3",
+ IMAP: "imap",
+ SMTP: "smtp",
+ });
+ server.hostname = lazy.Sanitizer.hostname(protocolX.Server);
+ server.port = lazy.Sanitizer.integer(protocolX.Port);
+ server.socketType = Ci.nsMsgSocketType.plain;
+ if (
+ "SSL" in protocolX &&
+ protocolX.SSL.toLowerCase() == "on" // "On" or "Off"
+ ) {
+ // SSL is too unspecific. Do they mean STARTTLS or normal TLS?
+ // For now, assume normal TLS, unless it's a standard plain port.
+ switch (server.port) {
+ case 143: // IMAP standard
+ case 110: // POP3 standard
+ case 25: // SMTP standard
+ case 587: // SMTP standard
+ server.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS;
+ break;
+ case 993: // IMAP SSL
+ case 995: // POP3 SSL
+ case 465: // SMTP SSL
+ default:
+ // if non-standard port, assume normal TLS, not STARTTLS
+ server.socketType = Ci.nsMsgSocketType.SSL;
+ break;
+ }
+ }
+ server.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ if (
+ "SPA" in protocolX &&
+ protocolX.SPA.toLowerCase() == "on" // "On" or "Off"
+ ) {
+ // Secure Password Authentication = NTLM or GSSAPI/Kerberos
+ server.auth = Ci.nsMsgAuthMethod.secure;
+ }
+ if ("LoginName" in protocolX) {
+ server.username = lazy.Sanitizer.nonemptystring(protocolX.LoginName);
+ } else {
+ server.username = username || "%EMAILADDRESS%";
+ }
+
+ if (type == "SMTP") {
+ if (!config.outgoing.hostname) {
+ config.outgoing = server;
+ } else {
+ config.outgoingAlternatives.push(server);
+ }
+ } else if (!config.incoming.hostname) {
+ // eslint-disable-line no-lonely-if
+ config.incoming = server;
+ } else {
+ config.incomingAlternatives.push(server);
+ }
+ }
+
+ // else unknown or unsupported protocol
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ // OAuth2 settings, so that createInBackend() doesn't bail out
+ if (config.incoming.owaURL || config.incoming.ewsURL) {
+ config.incoming.oauthSettings = {
+ issuer: config.incoming.hostname,
+ scope: config.incoming.owaURL || config.incoming.ewsURL,
+ };
+ config.outgoing.oauthSettings = {
+ issuer: config.incoming.hostname,
+ scope: config.incoming.owaURL || config.incoming.ewsURL,
+ };
+ }
+
+ return config;
+}
+/* eslint-enable complexity */
+
+/**
+ * Ask server which addons can handle this config.
+ *
+ * @param {AccountConfig} config
+ * @param {Function(config {AccountConfig})} successCallback
+ * @returns {Abortable}
+ */
+function getAddonsList(config, successCallback, errorCallback) {
+ let incoming = [config.incoming, ...config.incomingAlternatives].find(
+ alt => alt.type == "exchange"
+ );
+ if (!incoming) {
+ successCallback();
+ return new Abortable();
+ }
+ let url = Services.prefs.getCharPref("mailnews.auto_config.addons_url");
+ if (!url) {
+ errorCallback(new Exception("no URL for addons list configured"));
+ return new Abortable();
+ }
+ let fetch = new lazy.FetchHTTP(
+ url,
+ { allowCache: true, timeout: 10000 },
+ function (json) {
+ let addons = readAddonsJSON(json);
+ addons = addons.filter(addon => {
+ // Find types matching the current config.
+ // Pick the first in the list as the preferred one and
+ // tell the UI to use that one.
+ addon.useType = addon.supportedTypes.find(
+ type =>
+ (incoming.owaURL && type.protocolType == "owa") ||
+ (incoming.ewsURL && type.protocolType == "ews") ||
+ (incoming.easURL && type.protocolType == "eas")
+ );
+ return !!addon.useType;
+ });
+ if (addons.length == 0) {
+ errorCallback(
+ new Exception(
+ "Config found, but no addons known to handle the config"
+ )
+ );
+ return;
+ }
+ config.addons = addons;
+ successCallback(config);
+ },
+ errorCallback
+ );
+ fetch.start();
+ return fetch;
+}
+
+/**
+ * This reads the addons list JSON and makes security validations,
+ * e.g. that the URLs are not chrome: URLs, which could lead to exploits.
+ * It also chooses the right language etc..
+ *
+ * @param {JSON} json - the addons.json file contents
+ * @returns {Array of AddonInfo} - @see AccountConfig.addons
+ *
+ * accountTypes are listed in order of decreasing preference.
+ * Languages are 2-letter codes. If a language is not available,
+ * the first name or description will be used.
+ *
+ * Parse e.g.
+[
+ {
+ "id": "owl@beonex.com",
+ "name": {
+ "en": "Owl",
+ "de": "Eule"
+ },
+ "description": {
+ "en": "Owl is a paid third-party addon that allows you to access your email account on Exchange servers. See the website for prices.",
+ "de": "Eule ist eine Erweiterung von einem Drittanbieter, die Ihnen erlaubt, Exchange-Server zu benutzen. Sie ist kostenpflichtig. Die Preise finden Sie auf der Website."
+ },
+ "minVersion": "0.2",
+ "xpiURL": "http://www.beonex.com/owl/latest.xpi",
+ "websiteURL": "http://www.beonex.com/owl/",
+ "icon32": "http://www.beonex.com/owl/owl-32.png",
+ "accountTypes": [
+ {
+ "generalType": "exchange",
+ "protocolType": "owa",
+ "addonAccountType": "owl-owa"
+ },
+ {
+ "generalType": "exchange",
+ "protocolType": "eas",
+ "addonAccountType": "owl-eas"
+ }
+ ]
+ }
+]
+ */
+function readAddonsJSON(json) {
+ let addons = [];
+ function ensureArray(value) {
+ return Array.isArray(value) ? value : [];
+ }
+ let xulLocale = Services.locale.requestedLocale;
+ let locale = xulLocale ? xulLocale.substring(0, 5) : "default";
+ for (let addonJSON of ensureArray(json)) {
+ try {
+ let addon = {
+ id: addonJSON.id,
+ minVersion: addonJSON.minVersion,
+ xpiURL: lazy.Sanitizer.url(addonJSON.xpiURL),
+ websiteURL: lazy.Sanitizer.url(addonJSON.websiteURL),
+ icon32: addonJSON.icon32 ? lazy.Sanitizer.url(addonJSON.icon32) : null,
+ supportedTypes: [],
+ };
+ assert(
+ new URL(addon.xpiURL).protocol == "https:",
+ "XPI download URL needs to be https"
+ );
+ addon.name =
+ locale in addonJSON.name ? addonJSON.name[locale] : addonJSON.name[0];
+ addon.description =
+ locale in addonJSON.description
+ ? addonJSON.description[locale]
+ : addonJSON.description[0];
+ for (let typeJSON of ensureArray(addonJSON.accountTypes)) {
+ try {
+ addon.supportedTypes.push({
+ generalType: lazy.Sanitizer.alphanumdash(typeJSON.generalType),
+ protocolType: lazy.Sanitizer.alphanumdash(typeJSON.protocolType),
+ addonAccountType: lazy.Sanitizer.alphanumdash(
+ typeJSON.addonAccountType
+ ),
+ });
+ } catch (e) {
+ ddump(e);
+ }
+ }
+ addons.push(addon);
+ } catch (e) {
+ ddump(e);
+ }
+ }
+ return addons;
+}
+
+/**
+ * Probe a found Exchange server for IMAP/POP3 and SMTP support.
+ *
+ * @param {AccountConfig} config - The initial detected Exchange configuration.
+ * @param {string} domain - The domain part of the user's email address
+ * @param {Function(config {AccountConfig})} successCallback - A callback that
+ * will be called when we found an appropriate configuration.
+ * The AccountConfig object will be passed in as first parameter.
+ */
+function detectStandardProtocols(config, domain, successCallback) {
+ gAccountSetupLogger.info("Exchange Autodiscover gave some results.");
+ let alts = [config.incoming, ...config.incomingAlternatives];
+ if (alts.find(alt => alt.type == "imap" || alt.type == "pop3")) {
+ // Autodiscover found an exchange server with advertized IMAP and/or
+ // POP3 support. We're done then.
+ config.preferStandardProtocols();
+ successCallback(config);
+ return;
+ }
+
+ // Autodiscover is known not to advertise all that it supports. Let's see
+ // if there really isn't any IMAP/POP3 support by probing the Exchange
+ // server. Use the server hostname already found.
+ let config2 = new lazy.AccountConfig();
+ config2.incoming.hostname = config.incoming.hostname;
+ config2.incoming.username = config.incoming.username || "%EMAILADDRESS%";
+ // For Exchange 2013+ Kerberos/GSSAPI and NTLM options do not work by
+ // default at least for Linux users, even if support is detected.
+ config2.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+
+ config2.outgoing.hostname = config.incoming.hostname;
+ config2.outgoing.username = config.incoming.username || "%EMAILADDRESS%";
+
+ config2.incomingAlternatives = config.incomingAlternatives;
+ config2.incomingAlternatives.push(config.incoming); // type=exchange
+
+ config2.outgoingAlternatives = config.outgoingAlternatives;
+ if (config.outgoing.hostname) {
+ config2.outgoingAlternatives.push(config.outgoing);
+ }
+
+ lazy.GuessConfig.guessConfig(
+ domain,
+ function (type, hostname, port, ssl, done, config) {
+ gAccountSetupLogger.info(
+ `Probing exchange server ${hostname} for ${type} protocol support.`
+ );
+ },
+ function (probedConfig) {
+ // Probing succeeded: found open protocols, yay!
+ successCallback(probedConfig);
+ },
+ function (e, probedConfig) {
+ // Probing didn't find any open protocols.
+ // Let's use the exchange (only) config that was listed then.
+ config.subSource += "-guess";
+ successCallback(config);
+ },
+ config2,
+ "both"
+ );
+}
diff --git a/comm/mail/components/accountcreation/FetchConfig.jsm b/comm/mail/components/accountcreation/FetchConfig.jsm
new file mode 100644
index 0000000000..1bf16ca2ed
--- /dev/null
+++ b/comm/mail/components/accountcreation/FetchConfig.jsm
@@ -0,0 +1,299 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["FetchConfig"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FetchHTTP",
+ "resource:///modules/accountcreation/FetchHTTP.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "readFromXML",
+ "resource:///modules/accountcreation/readFromXML.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+const {
+ Abortable,
+ ddump,
+ Exception,
+ PriorityOrderAbortable,
+ PromiseAbortable,
+ readURLasUTF8,
+ runAsync,
+ SuccessiveAbortable,
+ TimeoutAbortable,
+} = AccountCreationUtils;
+
+/**
+ * Tries to find a configuration for this ISP on the local harddisk, in the
+ * application install directory's "isp" subdirectory.
+ * Params @see fetchConfigFromISP()
+ */
+function fetchConfigFromDisk(domain, successCallback, errorCallback) {
+ return new TimeoutAbortable(
+ runAsync(function () {
+ try {
+ // <TB installdir>/isp/example.com.xml
+ var configLocation = Services.dirsvc.get("CurProcD", Ci.nsIFile);
+ configLocation.append("isp");
+ configLocation.append(lazy.Sanitizer.hostname(domain) + ".xml");
+
+ if (!configLocation.exists() || !configLocation.isReadable()) {
+ errorCallback(new Exception("local file not found"));
+ return;
+ }
+ var contents = readURLasUTF8(Services.io.newFileURI(configLocation));
+ let domParser = new DOMParser();
+ const xml = JXON.build(domParser.parseFromString(contents, "text/xml"));
+ successCallback(lazy.readFromXML(xml, "disk"));
+ } catch (e) {
+ errorCallback(e);
+ }
+ })
+ );
+}
+
+/**
+ * Tries to get a configuration from the ISP / mail provider directly.
+ *
+ * Disclaimers:
+ * - To support domain hosters, we cannot use SSL. That means we
+ * rely on insecure DNS and http, which means the results may be
+ * forged when under attack. The same is true for guessConfig(), though.
+ *
+ * @param domain {String} - The domain part of the user's email address
+ * @param emailAddress {String} - The user's email address
+ * @param successCallback {Function(config {AccountConfig}})} A callback that
+ * will be called when we could retrieve a configuration.
+ * The AccountConfig object will be passed in as first parameter.
+ * @param errorCallback {Function(ex)} - A callback that
+ * will be called when we could not retrieve a configuration,
+ * for whatever reason. This is expected (e.g. when there's no config
+ * for this domain at this location),
+ * so do not unconditionally show this to the user.
+ * The first parameter will be an exception object or error string.
+ */
+function fetchConfigFromISP(
+ domain,
+ emailAddress,
+ successCallback,
+ errorCallback
+) {
+ if (
+ !Services.prefs.getBoolPref("mailnews.auto_config.fetchFromISP.enabled")
+ ) {
+ errorCallback(new Exception("ISP fetch disabled per user preference"));
+ return new Abortable();
+ }
+
+ let conf1 =
+ "autoconfig." + lazy.Sanitizer.hostname(domain) + "/mail/config-v1.1.xml";
+ // .well-known/ <http://tools.ietf.org/html/draft-nottingham-site-meta-04>
+ let conf2 =
+ lazy.Sanitizer.hostname(domain) +
+ "/.well-known/autoconfig/mail/config-v1.1.xml";
+ // This list is sorted by decreasing priority
+ var urls = ["https://" + conf1, "https://" + conf2];
+ if (
+ !Services.prefs.getBoolPref("mailnews.auto_config.fetchFromISP.sslOnly")
+ ) {
+ urls.push("http://" + conf1, "http://" + conf2);
+ }
+ let callArgs = {
+ urlArgs: {
+ emailaddress: emailAddress,
+ },
+ };
+ if (
+ !Services.prefs.getBoolPref(
+ "mailnews.auto_config.fetchFromISP.sendEmailAddress"
+ )
+ ) {
+ delete callArgs.urlArgs.emailaddress;
+ }
+ let call;
+ let fetch;
+
+ let priority = new PriorityOrderAbortable(
+ (xml, call) =>
+ successCallback(lazy.readFromXML(xml, `isp-${call.foundMsg}`)),
+ errorCallback
+ );
+ for (let url of urls) {
+ call = priority.addCall();
+ call.foundMsg = url.startsWith("https") ? "https" : "http";
+ fetch = new lazy.FetchHTTP(
+ url,
+ callArgs,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+ fetch.start();
+ }
+
+ return priority;
+}
+
+/**
+ * Tries to get a configuration for this ISP from a central database at
+ * Mozilla servers.
+ * Params @see fetchConfigFromISP()
+ */
+function fetchConfigFromDB(domain, successCallback, errorCallback) {
+ let url = Services.prefs.getCharPref("mailnews.auto_config_url");
+ if (!url) {
+ errorCallback(new Exception("no URL for ISP DB configured"));
+ return new Abortable();
+ }
+ domain = lazy.Sanitizer.hostname(domain);
+
+ // If we don't specify a place to put the domain, put it at the end.
+ if (!url.includes("{{domain}}")) {
+ url = url + domain;
+ } else {
+ url = url.replace("{{domain}}", domain);
+ }
+
+ let fetch = new lazy.FetchHTTP(
+ url,
+ { timeout: 10000 }, // 10 seconds
+ function (result) {
+ successCallback(lazy.readFromXML(result, "db"));
+ },
+ errorCallback
+ );
+ fetch.start();
+ return fetch;
+}
+
+/**
+ * Does a lookup of DNS MX, to get the server that is responsible for
+ * receiving mail for this domain. Then it takes the domain of that
+ * server, and does another lookup (in ISPDB and possibly at ISP autoconfig
+ * server) and if such a config is found, returns that.
+ *
+ * Disclaimers:
+ * - DNS is unprotected, meaning the results could be forged.
+ * The same is true for fetchConfigFromISP() and guessConfig(), though.
+ * - DNS MX tells us the incoming server, not the mailbox (IMAP) server.
+ * They are different. This mechanism is only an approximation
+ * for hosted domains (yourname.com is served by mx.hoster.com and
+ * therefore imap.hoster.com - that "therefore" is exactly the
+ * conclusional jump we make here.) and alternative domains
+ * (e.g. yahoo.de -> yahoo.com).
+ * - We make a look up for the base domain. E.g. if MX is
+ * mx1.incoming.servers.hoster.com, we look up hoster.com.
+ * Thanks to Services.eTLD, we also get bbc.co.uk right.
+ *
+ * Params @see fetchConfigFromISP()
+ */
+function fetchConfigForMX(domain, successCallback, errorCallback) {
+ const sanitizedDomain = lazy.Sanitizer.hostname(domain);
+ const sucAbortable = new SuccessiveAbortable();
+ const time = Date.now();
+
+ sucAbortable.current = getMX(
+ sanitizedDomain,
+ function (mxHostname) {
+ // success
+ ddump("getmx took " + (Date.now() - time) + "ms");
+ let sld = Services.eTLD.getBaseDomainFromHost(mxHostname);
+ ddump("base domain " + sld + " for " + mxHostname);
+ if (sld == sanitizedDomain) {
+ errorCallback(
+ new Exception("MX lookup would be no different from domain")
+ );
+ return;
+ }
+
+ // In addition to just the base domain, also check the full domain of the MX server
+ // to differentiate between Outlook.com/Hotmail and Office365 business domains.
+ let mxDomain;
+ try {
+ mxDomain = Services.eTLD.getNextSubDomain(mxHostname);
+ } catch (ex) {
+ // e.g. hostname doesn't have enough components
+ console.error(ex); // not fatal
+ }
+ let priority = new PriorityOrderAbortable(successCallback, errorCallback);
+ if (mxDomain && sld != mxDomain) {
+ let call = priority.addCall();
+ let fetch = fetchConfigFromDB(
+ mxDomain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+ }
+ let call = priority.addCall();
+ let fetch = fetchConfigFromDB(
+ sld,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+ sucAbortable.current = priority;
+ },
+ errorCallback
+ );
+ return sucAbortable;
+}
+
+/**
+ * Queries the DNS MX records for a given domain. Calls `successCallback` with
+ * the hostname of the MX server. If there are several entries with different
+ * preference values, only the most preferred (i.e. has the lowest value)
+ * is used. If there are several most preferred servers (i.e. round robin),
+ * only one of them is used.
+ *
+ * @param {string} sanitizedDomain @see fetchConfigFromISP()
+ * @param {function(hostname {string})} - successCallback
+ * Called when we found an MX for the domain.
+ * For |hostname|, see description above.
+ * @param {function({Exception|string})} errorCallback @see fetchConfigFromISP()
+ */
+function getMX(sanitizedDomain, successCallback, errorCallback) {
+ return new PromiseAbortable(
+ DNS.mx(sanitizedDomain),
+ function (records) {
+ const filteredRecs = records.filter(record => record.host);
+
+ if (filteredRecs.length > 0) {
+ const sortedRecs = filteredRecs.sort((a, b) => a.prio > b.prio);
+ const firstHost = sortedRecs[0].host;
+ successCallback(firstHost);
+ } else {
+ errorCallback(
+ new Exception(
+ "No hostname found in MX records for sanitizedDomain=" +
+ sanitizedDomain
+ )
+ );
+ }
+ },
+ errorCallback
+ );
+}
+
+var FetchConfig = {
+ forMX: fetchConfigForMX,
+ fromDB: fetchConfigFromDB,
+ fromISP: fetchConfigFromISP,
+ fromDisk: fetchConfigFromDisk,
+};
diff --git a/comm/mail/components/accountcreation/FetchHTTP.jsm b/comm/mail/components/accountcreation/FetchHTTP.jsm
new file mode 100644
index 0000000000..54b3629906
--- /dev/null
+++ b/comm/mail/components/accountcreation/FetchHTTP.jsm
@@ -0,0 +1,401 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This is a small wrapper around XMLHttpRequest, which solves various
+ * inadequacies of the API, e.g. error handling. It is entirely generic and
+ * can be used for purposes outside of even mail.
+ *
+ * It does not provide download progress, but assumes that the
+ * fetched resource is so small (<1 10 KB) that the roundtrip and
+ * response generation is far more significant than the
+ * download time of the response. In other words, it's fine for RPC,
+ * but not for bigger file downloads.
+ */
+
+const EXPORTED_SYMBOLS = ["FetchHTTP"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+const {
+ Abortable,
+ alertPrompt,
+ assert,
+ ddump,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ UserCancelledException,
+} = AccountCreationUtils;
+
+/**
+ * Set up a fetch.
+ *
+ * @param {string} url - URL of the server function.
+ * ATTENTION: The caller needs to make sure that the URL is secure to call.
+ * @param {object} args - Additional parameters as properties, see below
+ *
+ * @param {Function({string} result)} successCallback
+ * Called when the server call worked (no errors).
+ * |result| will contain the body of the HTTP response, as string.
+ * @param {Function(ex)} errorCallback
+ * Called in case of error. ex contains the error
+ * with a user-displayable but not localized |.message| and maybe a
+ * |.code|, which can be either
+ * - an nsresult error code,
+ * - an HTTP result error code (0...1000) or
+ * - negative: 0...-100 :
+ * -2 = can't resolve server in DNS etc.
+ * -4 = response body (e.g. XML) malformed
+ *
+ * The following optional parameters are supported as properties of the |args| object:
+ *
+ * @param {Object, associative array} urlArgs - Parameters to add
+ * to the end of the URL as query string. E.g.
+ * { foo: "bla", bar: "blub blub" } will add "?foo=bla&bar=blub%20blub"
+ * to the URL
+ * (unless the URL already has a "?", then it adds "&foo...").
+ * The values will be urlComponentEncoded, so pass them unencoded.
+ * @param {Object, associative array} headers - HTTP headers to be added
+ * to the HTTP request.
+ * { foo: "blub blub" } will add HTTP header "Foo: Blub blub".
+ * The values will be passed verbatim.
+ * @param {boolean} post - HTTP GET or POST
+ * Only influences the HTTP request method,
+ * i.e. first line of the HTTP request, not the body or parameters.
+ * Use POST when you modify server state,
+ * GET when you only request information.
+ * Default is GET.
+ * @param {Object, associative array} bodyFormArgs - Like urlArgs,
+ * just that the params will be sent x-url-encoded in the body,
+ * like a HTML form post.
+ * The values will be urlComponentEncoded, so pass them unencoded.
+ * This cannot be used together with |uploadBody|.
+ * @param {object} uploadBody - Arbitrary object, which to use as
+ * body of the HTTP request. Will also set the mimetype accordingly.
+ * Only supported object types, currently only JXON is supported
+ * (sending XML).
+ * Usually, you have nothing to upload, so just pass |null|.
+ * Only supported object types, currently supported:
+ * JXON -> sending XML
+ * JS object -> sending JSON
+ * string -> sending text/plain
+ * If you want to override the body mimetype, set header Content-Type below.
+ * Usually, you have nothing to upload, so just leave it at |null|.
+ * Default |null|.
+ * @param {boolean} allowCache (default true)
+ * @param {string} username (default null = no authentication)
+ * @param {string} password (default null = no authentication)
+ * @param {boolean} allowAuthPrompt (default true)
+ * @param {boolean} requireSecureAuth (default false)
+ * Ignore the username and password unless we are using https:
+ * This also applies to both https: to http: and http: to https: redirects.
+ */
+function FetchHTTP(url, args, successCallback, errorCallback) {
+ assert(typeof successCallback == "function", "BUG: successCallback");
+ assert(typeof errorCallback == "function", "BUG: errorCallback");
+ this._url = lazy.Sanitizer.string(url);
+ if (!args) {
+ args = {};
+ }
+ if (!args.urlArgs) {
+ args.urlArgs = {};
+ }
+ if (!args.headers) {
+ args.headers = {};
+ }
+
+ this._args = args;
+ this._args.post = lazy.Sanitizer.boolean(args.post || false); // default false
+ this._args.allowCache =
+ "allowCache" in args ? lazy.Sanitizer.boolean(args.allowCache) : true; // default true
+ this._args.allowAuthPrompt = lazy.Sanitizer.boolean(
+ args.allowAuthPrompt || false
+ ); // default false
+ this._args.requireSecureAuth = lazy.Sanitizer.boolean(
+ args.requireSecureAuth || false
+ ); // default false
+ this._args.timeout = lazy.Sanitizer.integer(args.timeout || 5000); // default 5 seconds
+ this._successCallback = successCallback;
+ this._errorCallback = errorCallback;
+ this._logger = gAccountSetupLogger;
+ this._logger.info("Requesting <" + url + ">");
+}
+FetchHTTP.prototype = {
+ __proto__: Abortable.prototype,
+ _url: null, // URL as passed to ctor, without arguments
+ _args: null,
+ _successCallback: null,
+ _errorCallback: null,
+ _request: null, // the XMLHttpRequest object
+ result: null,
+
+ start() {
+ let url = this._url;
+ for (let name in this._args.urlArgs) {
+ url +=
+ (!url.includes("?") ? "?" : "&") +
+ name +
+ "=" +
+ encodeURIComponent(this._args.urlArgs[name]);
+ }
+ this._request = new XMLHttpRequest();
+ let request = this._request;
+ request.mozBackgroundRequest = !this._args.allowAuthPrompt;
+ let username = null,
+ password = null;
+ if (url.startsWith("https:") || !this._args.requireSecureAuth) {
+ username = this._args.username;
+ password = this._args.password;
+ }
+ request.open(
+ this._args.post ? "POST" : "GET",
+ url,
+ true,
+ username,
+ password
+ );
+ request.channel.loadGroup = null;
+ request.timeout = this._args.timeout;
+ // needs bug 407190 patch v4 (or higher) - uncomment if that lands.
+ // try {
+ // var channel = request.channel.QueryInterface(Ci.nsIHttpChannel2);
+ // channel.connectTimeout = 5;
+ // channel.requestTimeout = 5;
+ // } catch (e) { dump(e + "\n"); }
+
+ if (!this._args.allowCache) {
+ // Disable Mozilla HTTP cache
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ }
+
+ // body
+ let mimetype = null;
+ let body = this._args.uploadBody;
+ if (typeof body == "object" && "nodeType" in body) {
+ // XML
+ mimetype = "text/xml; charset=UTF-8";
+ body = new XMLSerializer().serializeToString(body);
+ } else if (typeof body == "object") {
+ // JSON
+ mimetype = "text/json; charset=UTF-8";
+ body = JSON.stringify(body);
+ } else if (typeof body == "string") {
+ // Plaintext
+ // You can override the mimetype with { headers: {"Content-Type" : "text/foo" } }
+ mimetype = "text/plain; charset=UTF-8";
+ // body already set above
+ } else if (this._args.bodyFormArgs) {
+ mimetype = "application/x-www-form-urlencoded; charset=UTF-8";
+ body = "";
+ for (let name in this._args.bodyFormArgs) {
+ body +=
+ (body ? "&" : "") +
+ name +
+ "=" +
+ encodeURIComponent(this._args.bodyFormArgs[name]);
+ }
+ }
+
+ // Headers
+ if (mimetype && !("Content-Type" in this._args.headers)) {
+ request.setRequestHeader("Content-Type", mimetype);
+ }
+ if (username && password) {
+ // workaround, because open(..., username, password) does not work.
+ request.setRequestHeader(
+ "Authorization",
+ "Basic " +
+ btoa(
+ // btoa() takes a BinaryString.
+ String.fromCharCode(
+ ...new TextEncoder().encode(username + ":" + password)
+ )
+ )
+ );
+ }
+ for (let name in this._args.headers) {
+ request.setRequestHeader(name, this._args.headers[name]);
+ if (name == "Cookie") {
+ // Websites are not allowed to set this, but chrome is.
+ // Nevertheless, the cookie lib later overwrites our header.
+ // request.channel.setCookie(this._args.headers[name]); -- crashes
+ // So, deactivate that Firefox cookie lib.
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ }
+ }
+
+ var me = this;
+ request.onload = function () {
+ me._response(true);
+ };
+ request.onerror = function () {
+ me._response(false);
+ };
+ request.ontimeout = function () {
+ me._response(false);
+ };
+ request.send(body);
+ // Store the original stack so we can use it if there is an exception
+ this._callStack = Error().stack;
+ },
+ _response(success, exStored) {
+ try {
+ var errorCode = null;
+ var errorStr = null;
+
+ if (
+ success &&
+ this._request.status >= 200 &&
+ this._request.status < 300
+ ) {
+ // HTTP level success
+ try {
+ // response
+ var mimetype = this._request.getResponseHeader("Content-Type");
+ if (!mimetype) {
+ mimetype = "";
+ }
+ mimetype = mimetype.split(";")[0];
+ if (
+ mimetype == "text/xml" ||
+ mimetype == "application/xml" ||
+ mimetype == "text/rdf"
+ ) {
+ // XML
+ this.result = JXON.build(this._request.responseXML);
+ } else if (
+ mimetype == "text/json" ||
+ mimetype == "application/json"
+ ) {
+ // JSON
+ this.result = JSON.parse(this._request.responseText);
+ } else {
+ // Plaintext (fallback)
+ // ddump("mimetype: " + mimetype + " only supported as text");
+ this.result = this._request.responseText;
+ }
+ } catch (e) {
+ success = false;
+ errorStr = getStringBundle(
+ "chrome://messenger/locale/accountCreationUtil.properties"
+ ).GetStringFromName("bad_response_content.error");
+ errorCode = -4;
+ }
+ } else if (
+ this._args.username &&
+ this._request.responseURL.replace(/\/\/.*@/, "//") != this._url &&
+ this._request.responseURL.startsWith(
+ this._args.requireSecureAuth ? "https" : "http"
+ ) &&
+ !this._isRetry
+ ) {
+ // Redirects lack auth, see <https://stackoverflow.com/a/28411170>
+ this._logger.info(
+ "Call to <" +
+ this._url +
+ "> was redirected to <" +
+ this._request.responseURL +
+ ">, and failed. Re-trying the new URL with authentication again."
+ );
+ this._url = this._request.responseURL;
+ this._isRetry = true;
+ this.start();
+ return;
+ } else {
+ success = false;
+ try {
+ errorCode = this._request.status;
+ errorStr = this._request.statusText;
+ } catch (e) {
+ // In case .statusText throws (it's marked as [Throws] in the webidl),
+ // continue with empty errorStr.
+ }
+ if (!errorStr) {
+ // If we can't resolve the hostname in DNS etc., .statusText is empty.
+ errorCode = -2;
+ errorStr = getStringBundle(
+ "chrome://messenger/locale/accountCreationUtil.properties"
+ ).GetStringFromName("cannot_contact_server.error");
+ ddump(errorStr + " on <" + this._url + ">");
+ }
+ }
+
+ // Callbacks
+ if (success) {
+ try {
+ this._successCallback(this.result);
+ } catch (e) {
+ e.stack = this._callStack;
+ this._error(e);
+ }
+ } else if (exStored) {
+ this._error(exStored);
+ } else {
+ // Put the caller's stack into the exception
+ let e = new ServerException(errorStr, errorCode, this._url);
+ e.stack = this._callStack;
+ this._error(e);
+ }
+
+ if (this._finishedCallback) {
+ try {
+ this._finishedCallback(this);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ } catch (e) {
+ // error in our fetchhttp._response() code
+ this._error(e);
+ }
+ },
+ _error(e) {
+ try {
+ this._errorCallback(e);
+ } catch (e) {
+ // error in errorCallback, too!
+ console.error(e);
+ alertPrompt("Error in errorCallback for fetchhttp", e);
+ }
+ },
+ /**
+ * Call this between start() and finishedCallback fired.
+ */
+ cancel(ex) {
+ assert(!this.result, "Call already returned");
+
+ this._request.abort();
+
+ // Need to manually call error handler
+ // <https://bugzilla.mozilla.org/show_bug.cgi?id=218236#c11>
+ this._response(false, ex ? ex : new UserCancelledException());
+ },
+ /**
+ * Allows caller or lib to be notified when the call is done.
+ * This is useful to enable and disable a Cancel button in the UI,
+ * which allows to cancel the network request.
+ */
+ setFinishedCallback(finishedCallback) {
+ this._finishedCallback = finishedCallback;
+ },
+};
+
+function ServerException(msg, code, uri) {
+ Exception.call(this, msg);
+ this.code = code;
+ this.uri = uri;
+}
+ServerException.prototype = Object.create(Exception.prototype);
+ServerException.prototype.constructor = ServerException;
diff --git a/comm/mail/components/accountcreation/GuessConfig.jsm b/comm/mail/components/accountcreation/GuessConfig.jsm
new file mode 100644
index 0000000000..3d590311d9
--- /dev/null
+++ b/comm/mail/components/accountcreation/GuessConfig.jsm
@@ -0,0 +1,1317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["GuessConfig"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountConfig",
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+const {
+ Abortable,
+ alertPrompt,
+ assert,
+ CancelledException,
+ ddump,
+ deepCopy,
+ Exception,
+ gAccountSetupLogger,
+ getStringBundle,
+ NotReached,
+ UserCancelledException,
+} = AccountCreationUtils;
+
+/**
+ * Try to guess the config, by:
+ * - guessing hostnames (pop3.<domain>, pop.<domain>, imap.<domain>,
+ * mail.<domain> etc.)
+ * - probing known ports (for IMAP, POP3 etc., with SSL, STARTTLS etc.)
+ * - opening a connection via the right protocol and checking the
+ * protocol-specific CAPABILITIES like that the server returns.
+ *
+ * Final verification is not done here, but in verifyConfig().
+ *
+ * This function is async.
+ *
+ * @param domain {String} the domain part of the email address
+ * @param progressCallback {function(type, hostname, port, socketType, done)}
+ * Called when we try a new hostname/port.
+ * type {String-enum} @see AccountConfig type - "imap", "pop3", "smtp"
+ * hostname {String}
+ * port {Integer}
+ * socketType {nsMsgSocketType} @see MailNewsTypes2.idl
+ * 0 = plain, 2 = STARTTLS, 3 = SSL
+ * done {Boolean} false, if we start probing this host/port, true if we're
+ * done and the host is good. (there is no notification when a host is
+ * bad, we'll just tell about the next host tried)
+ * @param successCallback {function(config {AccountConfig})}
+ * Called when we could guess the config.
+ * param accountConfig {AccountConfig} The guessed account config.
+ * username, password, realname, emailaddress etc. are not filled out,
+ * but placeholders to be filled out via replaceVariables().
+ * @param errorCallback function(ex)
+ * Called when we could guess not the config, either
+ * because we have not found anything or
+ * because there was an error (e.g. no network connection).
+ * The ex.message will contain a user-presentable message.
+ * @param resultConfig {AccountConfig} (optional)
+ * A config which may be partially filled in. If so, it will be used as base
+ * for the guess.
+ * @param which {String-enum} (optional) "incoming", "outgoing", or "both".
+ * Default "both". Whether to guess only the incoming or outgoing server.
+ * @result {Abortable} Allows you to cancel the guess
+ */
+function guessConfig(
+ domain,
+ progressCallback,
+ successCallback,
+ errorCallback,
+ resultConfig,
+ which
+) {
+ assert(typeof progressCallback == "function", "need progressCallback");
+ assert(typeof successCallback == "function", "need successCallback");
+ assert(typeof errorCallback == "function", "need errorCallback");
+
+ // Servers that we know enough that they support OAuth2 do not need guessing.
+ if (resultConfig.incoming.auth == Ci.nsMsgAuthMethod.OAuth2) {
+ successCallback(resultConfig);
+ return new Abortable();
+ }
+
+ if (!resultConfig) {
+ resultConfig = new lazy.AccountConfig();
+ }
+ resultConfig.source = lazy.AccountConfig.kSourceGuess;
+
+ if (!which) {
+ which = "both";
+ }
+
+ if (!Services.prefs.getBoolPref("mailnews.auto_config.guess.enabled")) {
+ errorCallback("Guessing config disabled per user preference");
+ return new Abortable();
+ }
+
+ var incomingHostDetector = null;
+ var outgoingHostDetector = null;
+ var incomingEx = null; // if incoming had error, store ex here
+ var outgoingEx = null; // if incoming had error, store ex here
+ var incomingDone = which == "outgoing";
+ var outgoingDone = which == "incoming";
+ // If we're offline, we're going to pick the most common settings.
+ // (Not the "best" settings, but common).
+ if (Services.io.offline) {
+ // TODO: don't do this. Bug 599173.
+ resultConfig.source = lazy.AccountConfig.kSourceUser;
+ resultConfig.incoming.hostname = "mail." + domain;
+ resultConfig.incoming.username = resultConfig.identity.emailAddress;
+ resultConfig.outgoing.username = resultConfig.identity.emailAddress;
+ resultConfig.incoming.type = "imap";
+ resultConfig.incoming.port = 143;
+ resultConfig.incoming.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS;
+ resultConfig.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ resultConfig.outgoing.hostname = "smtp." + domain;
+ resultConfig.outgoing.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS;
+ resultConfig.outgoing.port = 587;
+ resultConfig.outgoing.auth = Ci.nsMsgAuthMethod.passwordCleartext;
+ resultConfig.incomingAlternatives.push({
+ hostname: "mail." + domain,
+ username: resultConfig.identity.emailAddress,
+ type: "pop3",
+ port: 110,
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ auth: Ci.nsMsgAuthMethod.passwordCleartext,
+ });
+ successCallback(resultConfig);
+ return new Abortable();
+ }
+ var progress = function (thisTry) {
+ progressCallback(
+ protocolToString(thisTry.protocol),
+ thisTry.hostname,
+ thisTry.port,
+ thisTry.socketType,
+ false,
+ resultConfig
+ );
+ };
+
+ var checkDone = function () {
+ if (incomingEx) {
+ try {
+ errorCallback(incomingEx, resultConfig);
+ } catch (e) {
+ console.error(e);
+ alertPrompt("Error in errorCallback for guessConfig()", e);
+ }
+ return;
+ }
+ if (outgoingEx) {
+ try {
+ errorCallback(outgoingEx, resultConfig);
+ } catch (e) {
+ console.error(e);
+ alertPrompt("Error in errorCallback for guessConfig()", e);
+ }
+ return;
+ }
+ if (incomingDone && outgoingDone) {
+ try {
+ successCallback(resultConfig);
+ } catch (e) {
+ try {
+ errorCallback(e);
+ } catch (e) {
+ console.error(e);
+ alertPrompt("Error in errorCallback for guessConfig()", e);
+ }
+ }
+ }
+ };
+
+ var logger = gAccountSetupLogger;
+ var HostTryToAccountServer = function (thisTry, server) {
+ server.type = protocolToString(thisTry.protocol);
+ server.hostname = thisTry.hostname;
+ server.port = thisTry.port;
+ server.socketType = thisTry.socketType;
+ server.auth =
+ thisTry.authMethod || chooseBestAuthMethod(thisTry.authMethods);
+ server.authAlternatives = thisTry.authMethods;
+ // TODO
+ // cert is also bad when targetSite is set. (Same below for incoming.)
+ // Fix SSLErrorHandler and security warning dialog in accountSetup.js.
+ server.badCert = thisTry.selfSignedCert;
+ server.targetSite = thisTry.targetSite;
+ logger.info(
+ "CHOOSING " +
+ server.type +
+ " " +
+ server.hostname +
+ ":" +
+ server.port +
+ ", auth method " +
+ server.auth +
+ (server.authAlternatives.length
+ ? " " + server.authAlternatives.join(",")
+ : "") +
+ ", socketType " +
+ server.socketType +
+ (server.badCert ? " (bad cert!)" : "")
+ );
+ };
+
+ var outgoingSuccess = function (thisTry, alternativeTries) {
+ assert(thisTry.protocol == SMTP, "I only know SMTP for outgoing");
+ // Ensure there are no previously saved outgoing errors, if we've got
+ // success here.
+ outgoingEx = null;
+ HostTryToAccountServer(thisTry, resultConfig.outgoing);
+
+ for (let alternativeTry of alternativeTries) {
+ // resultConfig.createNewOutgoing(); misses username etc., so copy
+ let altServer = deepCopy(resultConfig.outgoing);
+ HostTryToAccountServer(alternativeTry, altServer);
+ assert(resultConfig.outgoingAlternatives);
+ resultConfig.outgoingAlternatives.push(altServer);
+ }
+
+ progressCallback(
+ resultConfig.outgoing.type,
+ resultConfig.outgoing.hostname,
+ resultConfig.outgoing.port,
+ resultConfig.outgoing.socketType,
+ true,
+ resultConfig
+ );
+ outgoingDone = true;
+ checkDone();
+ };
+
+ var incomingSuccess = function (thisTry, alternativeTries) {
+ // Ensure there are no previously saved incoming errors, if we've got
+ // success here.
+ incomingEx = null;
+ HostTryToAccountServer(thisTry, resultConfig.incoming);
+
+ for (let alternativeTry of alternativeTries) {
+ // resultConfig.createNewIncoming(); misses username etc., so copy
+ let altServer = deepCopy(resultConfig.incoming);
+ HostTryToAccountServer(alternativeTry, altServer);
+ assert(resultConfig.incomingAlternatives);
+ resultConfig.incomingAlternatives.push(altServer);
+ }
+
+ progressCallback(
+ resultConfig.incoming.type,
+ resultConfig.incoming.hostname,
+ resultConfig.incoming.port,
+ resultConfig.incoming.socketType,
+ true,
+ resultConfig
+ );
+ incomingDone = true;
+ checkDone();
+ };
+
+ var incomingError = function (ex) {
+ incomingEx = ex;
+ checkDone();
+ incomingHostDetector.cancel(new CancelOthersException());
+ outgoingHostDetector.cancel(new CancelOthersException());
+ };
+
+ var outgoingError = function (ex) {
+ outgoingEx = ex;
+ checkDone();
+ incomingHostDetector.cancel(new CancelOthersException());
+ outgoingHostDetector.cancel(new CancelOthersException());
+ };
+
+ incomingHostDetector = new IncomingHostDetector(
+ progress,
+ incomingSuccess,
+ incomingError
+ );
+ outgoingHostDetector = new OutgoingHostDetector(
+ progress,
+ outgoingSuccess,
+ outgoingError
+ );
+ if (which == "incoming" || which == "both") {
+ incomingHostDetector.start(
+ resultConfig.incoming.hostname ? resultConfig.incoming.hostname : domain,
+ !!resultConfig.incoming.hostname,
+ resultConfig.incoming.type,
+ resultConfig.incoming.port,
+ resultConfig.incoming.socketType,
+ resultConfig.incoming.auth
+ );
+ }
+ if (which == "outgoing" || which == "both") {
+ outgoingHostDetector.start(
+ resultConfig.outgoing.hostname ? resultConfig.outgoing.hostname : domain,
+ !!resultConfig.outgoing.hostname,
+ "smtp",
+ resultConfig.outgoing.port,
+ resultConfig.outgoing.socketType,
+ resultConfig.outgoing.auth
+ );
+ }
+
+ return new GuessAbortable(incomingHostDetector, outgoingHostDetector);
+}
+
+function GuessAbortable(incomingHostDetector, outgoingHostDetector) {
+ Abortable.call(this);
+ this._incomingHostDetector = incomingHostDetector;
+ this._outgoingHostDetector = outgoingHostDetector;
+}
+GuessAbortable.prototype = Object.create(Abortable.prototype);
+GuessAbortable.prototype.constructor = GuessAbortable;
+GuessAbortable.prototype.cancel = function (ex) {
+ this._incomingHostDetector.cancel(ex);
+ this._outgoingHostDetector.cancel(ex);
+};
+
+// --------------
+// Implementation
+
+// Objects, functions and constants that follow are not to be used outside
+// this file.
+var kNotTried = 0;
+var kOngoing = 1;
+var kFailed = 2;
+var kSuccess = 3;
+
+/**
+ * Internal object holding one server that we should try or did try.
+ * Used as |thisTry|.
+ *
+ * Note: The consts it uses for protocol is defined towards the end of this file
+ * and not the same as those used in AccountConfig (type). (fix
+ * this)
+ */
+function HostTry() {}
+HostTry.prototype = {
+ // IMAP, POP or SMTP
+ protocol: UNKNOWN,
+ // {String}
+ hostname: undefined,
+ // {Integer}
+ port: undefined,
+ // {nsMsgSocketType}
+ socketType: UNKNOWN,
+ // {String} what to send to server
+ commands: null,
+ // {Integer-enum} kNotTried, kOngoing, kFailed or kSuccess
+ status: kNotTried,
+ // {Abortable} allows to cancel the socket comm
+ abortable: null,
+
+ // {Array of {Integer-enum}} @see _advertisesAuthMethods() result
+ // Info about the server, from the protocol and SSL chat
+ authMethods: null,
+ // {String} Whether the SSL cert is not from a proper CA
+ selfSignedCert: false,
+ // {String} Which host the SSL cert is made for, if not hostname.
+ // If set, this is an SSL error.
+ targetSite: null,
+};
+
+/**
+ * When the success or errorCallbacks are called to abort the other requests
+ * which happened in parallel, this ex is used as param for cancel(), so that
+ * the cancel doesn't trigger another callback.
+ */
+function CancelOthersException() {
+ CancelledException.call(this, "we're done, cancelling the other probes");
+}
+CancelOthersException.prototype = Object.create(CancelledException.prototype);
+CancelOthersException.prototype.constructor = CancelOthersException;
+
+/**
+ * @param successCallback {function(result {HostTry}, alts {Array of HostTry})}
+ * Called when the config is OK
+ * |result| is the most preferred server.
+ * |alts| currently exists only for |IncomingHostDetector| and contains
+ * some servers of the other type (POP3 instead of IMAP), if available.
+ * @param errorCallback {function(ex)} Called when we could not find a config
+ * @param progressCallback { function(server {HostTry}) } Called when we tried
+ * (will try?) a new hostname and port
+ */
+function HostDetector(progressCallback, successCallback, errorCallback) {
+ this.mSuccessCallback = successCallback;
+ this.mProgressCallback = progressCallback;
+ this.mErrorCallback = errorCallback;
+ this._cancel = false;
+ // {Array of {HostTry}}, ordered by decreasing preference
+ this._hostsToTry = [];
+
+ // init logging
+ this._log = gAccountSetupLogger;
+ this._log.info("created host detector");
+}
+
+HostDetector.prototype = {
+ cancel(ex) {
+ this._cancel = true;
+ // We have to actively stop the network calls, as they may result in
+ // callbacks e.g. to the cert handler. If the dialog is gone by the time
+ // this happens, the javascript stack is horked.
+ for (let i = 0; i < this._hostsToTry.length; i++) {
+ let thisTry = this._hostsToTry[i]; // {HostTry}
+ if (thisTry.abortable) {
+ thisTry.abortable.cancel(ex);
+ }
+ thisTry.status = kFailed; // or don't set? Maybe we want to continue.
+ }
+ if (ex instanceof CancelOthersException) {
+ return;
+ }
+ if (!ex) {
+ ex = new CancelledException();
+ }
+ this.mErrorCallback(ex);
+ },
+
+ /**
+ * Start the detection.
+ *
+ * @param {string} domain - Domain to be used as base for guessing.
+ * Should be a domain (e.g. yahoo.co.uk).
+ * If hostIsPrecise == true, it should be a full hostname.
+ * @param {boolean} hostIsPrecise - If true, use only this hostname,
+ * do not guess hostnames.
+ * @param {"pop3"|"imap"|"exchange"|"smtp"|""} - Account type.
+ * @param {integer} port - The port to use. 0 to autodetect
+ * @param {nsMsgSocketType|-1} socketType - Socket type. -1 to autodetect.
+ * @param {nsMsgAuthMethod|0} authMethod - Authentication method. 0 to autodetect.
+ */
+ start(domain, hostIsPrecise, type, port, socketType, authMethod) {
+ domain = domain.replace(/\s*/g, ""); // Remove whitespace
+ if (!hostIsPrecise) {
+ hostIsPrecise = false;
+ }
+ var protocol = lazy.Sanitizer.translate(
+ type,
+ { imap: IMAP, pop3: POP, smtp: SMTP },
+ UNKNOWN
+ );
+ if (!port) {
+ port = UNKNOWN;
+ }
+ var ssl_only = Services.prefs.getBoolPref(
+ "mailnews.auto_config.guess.sslOnly"
+ );
+ this._cancel = false;
+ this._log.info(
+ `Starting ${protocol} detection on ${
+ !hostIsPrecise ? "~ " : ""
+ }${domain}:${port} with socketType=${socketType} and authMethod=${authMethod}`
+ );
+
+ // fill this._hostsToTry
+ this._hostsToTry = [];
+ var hostnamesToTry = [];
+ // if hostIsPrecise is true, it's because that's what the user input
+ // explicitly, and we'll just try it, nothing else.
+ if (hostIsPrecise) {
+ hostnamesToTry.push(domain);
+ } else {
+ hostnamesToTry = this._hostnamesToTry(protocol, domain);
+ }
+
+ for (let i = 0; i < hostnamesToTry.length; i++) {
+ let hostname = hostnamesToTry[i];
+ let hostEntries = this._portsToTry(hostname, protocol, socketType, port);
+ for (let j = 0; j < hostEntries.length; j++) {
+ let hostTry = hostEntries[j]; // from getHostEntry()
+ if (ssl_only && hostTry.socketType == NONE) {
+ continue;
+ }
+ hostTry.hostname = hostname;
+ hostTry.status = kNotTried;
+ hostTry.desc =
+ hostTry.hostname +
+ ":" +
+ hostTry.port +
+ " socketType=" +
+ hostTry.socketType +
+ " " +
+ protocolToString(hostTry.protocol);
+ hostTry.authMethod = authMethod;
+ this._hostsToTry.push(hostTry);
+ }
+ }
+
+ this._hostsToTry = sortTriesByPreference(this._hostsToTry);
+ this._tryAll();
+ },
+
+ // We make all host/port combinations run in parallel, store their
+ // results in an array, and as soon as one finishes successfully and all
+ // higher-priority ones have failed, we abort all lower-priority ones.
+
+ _tryAll() {
+ if (this._cancel) {
+ return;
+ }
+ var me = this;
+ var timeout = Services.prefs.getIntPref(
+ "mailnews.auto_config.guess.timeout"
+ );
+ // We assume we'll resolve the same proxy for all tries, and
+ // proceed to use the first resolved proxy for all tries. This
+ // assumption is generally sound, but not always: mechanisms like
+ // the pref network.proxy.no_proxies_on can make imap.domain and
+ // pop.domain resolve differently.
+ doProxy(this._hostsToTry[0].hostname, function (proxy) {
+ for (let i = 0; i < me._hostsToTry.length; i++) {
+ let thisTry = me._hostsToTry[i]; // {HostTry}
+ if (thisTry.status != kNotTried) {
+ continue;
+ }
+ me._log.info(thisTry.desc + ": initializing probe...");
+ if (i == 0) {
+ // showing 50 servers at once is pointless
+ me.mProgressCallback(thisTry);
+ }
+
+ thisTry.abortable = SocketUtil(
+ thisTry.hostname,
+ thisTry.port,
+ thisTry.socketType,
+ thisTry.commands,
+ timeout,
+ proxy,
+ new SSLErrorHandler(thisTry, me._log),
+ function (wiredata) {
+ // result callback
+ if (me._cancel) {
+ // Don't use response anymore.
+ return;
+ }
+ me.mProgressCallback(thisTry);
+ me._processResult(thisTry, wiredata);
+ me._checkFinished();
+ },
+ function (e) {
+ // error callback
+ if (me._cancel) {
+ // Who set cancel to true already called mErrorCallback().
+ return;
+ }
+ me._log.warn(thisTry.desc + ": " + e);
+ thisTry.status = kFailed;
+ me._checkFinished();
+ }
+ );
+ thisTry.status = kOngoing;
+ }
+ });
+ },
+
+ /**
+ * @param {HostTry} thisTry
+ * @param {string[]} wiredata - What the server returned in response to our protocol chat.
+ */
+ _processResult(thisTry, wiredata) {
+ if (thisTry._gotCertError) {
+ if (thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_MISMATCH) {
+ thisTry._gotCertError = 0;
+ thisTry.status = kFailed;
+ return;
+ }
+
+ if (
+ thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_UNTRUSTED ||
+ thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_TIME
+ ) {
+ this._log.info(
+ thisTry.desc + ": TRYING AGAIN, hopefully with exception recorded"
+ );
+ thisTry._gotCertError = 0;
+ thisTry.selfSignedCert = true; // _next_ run gets this exception
+ thisTry.status = kNotTried; // try again (with exception)
+ this._tryAll();
+ return;
+ }
+ }
+
+ if (wiredata == null || wiredata === undefined) {
+ this._log.info(thisTry.desc + ": no data");
+ thisTry.status = kFailed;
+ return;
+ }
+ this._log.info(thisTry.desc + ": wiredata: " + wiredata.join(""));
+ thisTry.authMethods = this._advertisesAuthMethods(
+ thisTry.protocol,
+ wiredata
+ );
+ if (
+ thisTry.socketType == STARTTLS &&
+ !this._hasSTARTTLS(thisTry, wiredata)
+ ) {
+ this._log.info(thisTry.desc + ": STARTTLS wanted, but not offered");
+ thisTry.status = kFailed;
+ return;
+ }
+ this._log.info(
+ thisTry.desc +
+ ": success" +
+ (thisTry.selfSignedCert ? " (selfSignedCert)" : "")
+ );
+ thisTry.status = kSuccess;
+
+ if (thisTry.selfSignedCert) {
+ // eh, ERROR_UNTRUSTED or ERROR_TIME
+ // We clear the temporary override now after success. If we clear it
+ // earlier we get into an infinite loop, probably because the cert
+ // remembering is temporary and the next try gets a new connection which
+ // isn't covered by that temporariness.
+ this._log.info(
+ thisTry.desc + ": clearing validity override for " + thisTry.hostname
+ );
+ Cc["@mozilla.org/security/certoverride;1"]
+ .getService(Ci.nsICertOverrideService)
+ .clearValidityOverride(thisTry.hostname, thisTry.port, {});
+ }
+ },
+
+ _checkFinished() {
+ var successfulTry = null;
+ var successfulTryAlternative = null; // POP3
+ var unfinishedBusiness = false;
+ // this._hostsToTry is ordered by decreasing preference
+ for (let i = 0; i < this._hostsToTry.length; i++) {
+ let thisTry = this._hostsToTry[i];
+ if (thisTry.status == kNotTried || thisTry.status == kOngoing) {
+ unfinishedBusiness = true;
+ } else if (thisTry.status == kSuccess && !unfinishedBusiness) {
+ // thisTry is good, and all higher preference tries failed, so use this
+ if (!successfulTry) {
+ successfulTry = thisTry;
+ if (successfulTry.protocol == SMTP) {
+ break;
+ }
+ } else if (successfulTry.protocol != thisTry.protocol) {
+ successfulTryAlternative = thisTry;
+ break;
+ }
+ }
+ }
+ if (successfulTry && (successfulTryAlternative || !unfinishedBusiness)) {
+ this.mSuccessCallback(
+ successfulTry,
+ successfulTryAlternative ? [successfulTryAlternative] : []
+ );
+ this.cancel(new CancelOthersException());
+ } else if (!unfinishedBusiness) {
+ // all failed
+ this._log.info("ran out of options");
+ var errorMsg = getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ ).GetStringFromName("cannot_find_server.error");
+ this.mErrorCallback(new Exception(errorMsg));
+ // no need to cancel, all failed
+ }
+ // else let ongoing calls continue
+ },
+
+ /**
+ * Which auth mechanism the server claims to support.
+ * That doesn't necessarily reflect reality, it is more an upper bound.
+ *
+ * @param {integer} protocol - IMAP, POP or SMTP
+ * @param {string[]} capaResponse - On the wire data that the server returned.
+ * May be the full exchange or just capa.
+ * @returns {nsMsgAuthMethod[]} Advertised authentication methods,
+ * in decreasing order of preference.
+ * E.g. [ nsMsgAuthMethod.GSSAPI, nsMsgAuthMethod.passwordEncrypted ]
+ * for a server that supports only Kerberos and encrypted passwords.
+ */
+ _advertisesAuthMethods(protocol, capaResponse) {
+ // For IMAP, capabilities include e.g.:
+ // "AUTH=CRAM-MD5", "AUTH=NTLM", "AUTH=GSSAPI", "AUTH=MSN", "AUTH=PLAIN"
+ // for POP3, the auth mechanisms are returned in capa as the following:
+ // "CRAM-MD5", "NTLM", "MSN", "GSSAPI"
+ // For SMTP, EHLO will return AUTH and then a list of the
+ // mechanism(s) supported, e.g.,
+ // AUTH LOGIN NTLM MSN CRAM-MD5 GSSAPI
+ var supported = new Set();
+ var line = capaResponse.join("\n").toUpperCase();
+ var prefix = "";
+ if (protocol == POP) {
+ prefix = "";
+ } else if (protocol == IMAP) {
+ prefix = "AUTH=";
+ } else if (protocol == SMTP) {
+ prefix = "AUTH.*";
+ } else {
+ throw NotReached("must pass protocol");
+ }
+ // add in decreasing order of preference
+ if (new RegExp(prefix + "GSSAPI").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.GSSAPI);
+ }
+ if (new RegExp(prefix + "CRAM-MD5").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.passwordEncrypted);
+ }
+ if (new RegExp(prefix + "(NTLM|MSN)").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.NTLM);
+ }
+ if (new RegExp(prefix + "LOGIN").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.passwordCleartext);
+ }
+ if (new RegExp(prefix + "PLAIN").test(line)) {
+ supported.add(Ci.nsMsgAuthMethod.passwordCleartext);
+ }
+ if (protocol != IMAP || !line.includes("LOGINDISABLED")) {
+ supported.add(Ci.nsMsgAuthMethod.passwordCleartext);
+ }
+ // The array elements will be in the Set's order of addition.
+ return Array.from(supported);
+ },
+
+ _hasSTARTTLS(thisTry, wiredata) {
+ var capa = thisTry.protocol == POP ? "STLS" : "STARTTLS";
+ return (
+ thisTry.socketType == STARTTLS &&
+ wiredata.join("").toUpperCase().includes(capa)
+ );
+ },
+};
+
+/**
+ * @param {nsMsgAuthMethod[]} authMethods - Authentication methods to choose from.
+ * See return value of _advertisesAuthMethods()
+ * Note: the returned auth method will be removed from the array.
+ * @returns {nsMsgAuthMethod} one of them, the preferred one
+ * Note: this might be Kerberos, which might not actually work,
+ * so you might need to try the others, too.
+ */
+function chooseBestAuthMethod(authMethods) {
+ if (!authMethods || !authMethods.length) {
+ return Ci.nsMsgAuthMethod.passwordCleartext;
+ }
+ return authMethods.shift(); // take first (= most preferred)
+}
+
+function IncomingHostDetector(
+ progressCallback,
+ successCallback,
+ errorCallback
+) {
+ HostDetector.call(this, progressCallback, successCallback, errorCallback);
+}
+IncomingHostDetector.prototype = {
+ __proto__: HostDetector.prototype,
+ _hostnamesToTry(protocol, domain) {
+ var hostnamesToTry = [];
+ if (protocol != POP) {
+ hostnamesToTry.push("imap." + domain);
+ }
+ if (protocol != IMAP) {
+ hostnamesToTry.push("pop3." + domain);
+ hostnamesToTry.push("pop." + domain);
+ }
+ hostnamesToTry.push("mail." + domain);
+ hostnamesToTry.push(domain);
+ return hostnamesToTry;
+ },
+ _portsToTry: getIncomingTryOrder,
+};
+
+function OutgoingHostDetector(
+ progressCallback,
+ successCallback,
+ errorCallback
+) {
+ HostDetector.call(this, progressCallback, successCallback, errorCallback);
+}
+OutgoingHostDetector.prototype = {
+ __proto__: HostDetector.prototype,
+ _hostnamesToTry(protocol, domain) {
+ var hostnamesToTry = [];
+ hostnamesToTry.push("smtp." + domain);
+ hostnamesToTry.push("mail." + domain);
+ hostnamesToTry.push(domain);
+ return hostnamesToTry;
+ },
+ _portsToTry: getOutgoingTryOrder,
+};
+
+// ---------------------------------------------
+// Encode protocol ports and order of preference
+
+// Protocol Types
+var UNKNOWN = -1;
+var IMAP = 0;
+var POP = 1;
+var SMTP = 2;
+// Security Types
+var NONE = Ci.nsMsgSocketType.plain;
+var STARTTLS = Ci.nsMsgSocketType.alwaysSTARTTLS;
+var SSL = Ci.nsMsgSocketType.SSL;
+
+var IMAP_PORTS = {};
+IMAP_PORTS[NONE] = 143;
+IMAP_PORTS[STARTTLS] = 143;
+IMAP_PORTS[SSL] = 993;
+
+var POP_PORTS = {};
+POP_PORTS[NONE] = 110;
+POP_PORTS[STARTTLS] = 110;
+POP_PORTS[SSL] = 995;
+
+var SMTP_PORTS = {};
+SMTP_PORTS[NONE] = 587;
+SMTP_PORTS[STARTTLS] = 587;
+SMTP_PORTS[SSL] = 465;
+
+var CMDS = {};
+CMDS[IMAP] = ["1 CAPABILITY\r\n", "2 LOGOUT\r\n"];
+CMDS[POP] = ["CAPA\r\n", "QUIT\r\n"];
+CMDS[SMTP] = ["EHLO we-guess.mozilla.org\r\n", "QUIT\r\n"];
+
+/**
+ * Sort by preference of SSL, IMAP etc.
+ *
+ * @param tries {Array of {HostTry}}
+ * @returns {Array of {HostTry}}
+ */
+function sortTriesByPreference(tries) {
+ return tries.sort(function (a, b) {
+ // -1 = a is better; 1 = b is better; 0 = equal
+ // Prefer SSL/STARTTLS above all else
+ if (a.socketType != NONE && b.socketType == NONE) {
+ return -1;
+ }
+ if (b.socketType != NONE && a.socketType == NONE) {
+ return 1;
+ }
+ // Prefer IMAP over POP
+ if (a.protocol == IMAP && b.protocol == POP) {
+ return -1;
+ }
+ if (b.protocol == IMAP && a.protocol == POP) {
+ return 1;
+ }
+ // Prefer SSL/TLS over STARTTLS
+ if (a.socketType == SSL && b.socketType == STARTTLS) {
+ return -1;
+ }
+ if (a.socketType == STARTTLS && b.socketType == SSL) {
+ return 1;
+ }
+ // For hostnames, leave existing sorting, as in _hostnamesToTry()
+ // For ports, leave existing sorting, as in getOutgoingTryOrder()
+ return 0;
+ });
+}
+
+/**
+ * @returns {HostTry[]} Hosts to try.
+ */
+function getIncomingTryOrder(host, protocol, socketType, port) {
+ var lowerCaseHost = host.toLowerCase();
+
+ if (
+ protocol == UNKNOWN &&
+ (lowerCaseHost.startsWith("pop.") || lowerCaseHost.startsWith("pop3."))
+ ) {
+ protocol = POP;
+ } else if (protocol == UNKNOWN && lowerCaseHost.startsWith("imap.")) {
+ protocol = IMAP;
+ }
+
+ if (protocol != UNKNOWN) {
+ if (socketType == UNKNOWN) {
+ return [
+ getHostEntry(protocol, STARTTLS, port),
+ getHostEntry(protocol, SSL, port),
+ getHostEntry(protocol, NONE, port),
+ ];
+ }
+ return [getHostEntry(protocol, socketType, port)];
+ }
+ if (socketType == UNKNOWN) {
+ return [
+ getHostEntry(IMAP, STARTTLS, port),
+ getHostEntry(IMAP, SSL, port),
+ getHostEntry(POP, STARTTLS, port),
+ getHostEntry(POP, SSL, port),
+ getHostEntry(IMAP, NONE, port),
+ getHostEntry(POP, NONE, port),
+ ];
+ }
+ return [
+ getHostEntry(IMAP, socketType, port),
+ getHostEntry(POP, socketType, port),
+ ];
+}
+
+/**
+ * @returns {Array of {HostTry}}
+ */
+function getOutgoingTryOrder(host, protocol, socketType, port) {
+ assert(protocol == SMTP, "need SMTP as protocol for outgoing");
+ if (socketType == UNKNOWN) {
+ if (port == UNKNOWN) {
+ // neither SSL nor port known
+ return [
+ getHostEntry(SMTP, STARTTLS, UNKNOWN),
+ getHostEntry(SMTP, STARTTLS, 25),
+ getHostEntry(SMTP, SSL, UNKNOWN),
+ getHostEntry(SMTP, NONE, UNKNOWN),
+ getHostEntry(SMTP, NONE, 25),
+ ];
+ }
+ // port known, SSL not
+ return [
+ getHostEntry(SMTP, STARTTLS, port),
+ getHostEntry(SMTP, SSL, port),
+ getHostEntry(SMTP, NONE, port),
+ ];
+ }
+ // SSL known, port not
+ if (port == UNKNOWN) {
+ if (socketType == SSL) {
+ return [getHostEntry(SMTP, SSL, UNKNOWN)];
+ }
+ return [
+ getHostEntry(SMTP, socketType, UNKNOWN),
+ getHostEntry(SMTP, socketType, 25),
+ ];
+ }
+ // SSL and port known
+ return [getHostEntry(SMTP, socketType, port)];
+}
+
+/**
+ * @returns {HostTry} with proper default port and commands,
+ * but without hostname.
+ */
+function getHostEntry(protocol, socketType, port) {
+ if (!port || port == UNKNOWN) {
+ switch (protocol) {
+ case POP:
+ port = POP_PORTS[socketType];
+ break;
+ case IMAP:
+ port = IMAP_PORTS[socketType];
+ break;
+ case SMTP:
+ port = SMTP_PORTS[socketType];
+ break;
+ default:
+ throw new NotReached("unsupported protocol " + protocol);
+ }
+ }
+
+ var r = new HostTry();
+ r.protocol = protocol;
+ r.socketType = socketType;
+ r.port = port;
+ r.commands = CMDS[protocol];
+ return r;
+}
+
+// here -> AccountConfig
+function protocolToString(type) {
+ if (type == IMAP) {
+ return "imap";
+ }
+ if (type == POP) {
+ return "pop3";
+ }
+ if (type == SMTP) {
+ return "smtp";
+ }
+ throw new NotReached("unexpected protocol");
+}
+
+// ----------------------
+// SSL cert error handler
+
+/**
+ * @param thisTry {HostTry}
+ * @param logger {ConsoleAPI}
+ */
+function SSLErrorHandler(thisTry, logger) {
+ this._try = thisTry;
+ this._log = logger;
+ // _ gotCertError will be set to an error code (one of those defined in
+ // nsICertOverrideService)
+ this._gotCertError = 0;
+}
+SSLErrorHandler.prototype = {
+ processCertError(secInfo, targetSite) {
+ this._log.error("Got Cert error for " + targetSite);
+
+ if (!secInfo) {
+ return;
+ }
+
+ let cert = secInfo.serverCert;
+
+ let parts = targetSite.split(":");
+ let host = parts[0];
+ let port = parts[1];
+
+ /* The following 2 cert problems are unfortunately common:
+ * 1) hostname mismatch:
+ * user is customer at a domain hoster, he owns yourname.org,
+ * and the IMAP server is imap.hoster.com (but also reachable as
+ * imap.yourname.org), and has a cert for imap.hoster.com.
+ * 2) self-signed:
+ * a company has an internal IMAP server, and it's only for
+ * 30 employees, and they didn't want to buy a cert, so
+ * they use a self-signed cert.
+ *
+ * We would like the above to pass, somehow, with user confirmation.
+ * The following case should *not* pass:
+ *
+ * 1) MITM
+ * User has @gmail.com, and an attacker is between the user and
+ * the Internet and runs a man-in-the-middle (MITM) attack.
+ * Attacker controls DNS and sends imap.gmail.com to his own
+ * imap.attacker.com. He has either a valid, CA-issued
+ * cert for imap.attacker.com, or a self-signed cert.
+ * Of course, attacker.com could also be legit-sounding gmailservers.com.
+ *
+ * What makes it dangerous is that we (!) propose the server to the user,
+ * and he cannot judge whether imap.gmailservers.com is correct or not,
+ * and he will likely approve it.
+ */
+
+ if (secInfo.isDomainMismatch) {
+ this._try._gotCertError = Ci.nsICertOverrideService.ERROR_MISMATCH;
+ } else if (secInfo.isUntrusted) {
+ // e.g. self-signed
+ this._try._gotCertError = Ci.nsICertOverrideService.ERROR_UNTRUSTED;
+ } else if (secInfo.isNotValidAtThisTime) {
+ this._try._gotCertError = Ci.nsICertOverrideService.ERROR_TIME;
+ } else {
+ this._try._gotCertError = -1; // other
+ }
+
+ /* We will add a temporary cert exception here, so that
+ * we can continue and connect and try.
+ * But we will remove it again as soon as we close the
+ * connection, in _processResult().
+ * _gotCertError will serve as the marker that we
+ * have to clear the override later.
+ *
+ * In verifyConfig(), before we send the password, we *must*
+ * get another cert exception, this time with dialog to the user
+ * so that he gets informed about this and can make a choice.
+ */
+ this._try.targetSite = targetSite;
+ Cc["@mozilla.org/security/certoverride;1"]
+ .getService(Ci.nsICertOverrideService)
+ .rememberValidityOverride(host, port, {}, cert, true); // temporary override
+ this._log.warn(`Added temporary override of bad cert for: ${host}:${port}`);
+ },
+};
+
+// -----------
+// Socket Util
+
+/**
+ * @param hostname {String} The DNS hostname to connect to.
+ * @param port {Integer} The numeric port to connect to on the host.
+ * @param socketType {nsMsgSocketType} SSL, STARTTLS or NONE
+ * @param commands {Array of String}: protocol commands
+ * to send to the server.
+ * @param timeout {Integer} seconds to wait for a server response, then cancel.
+ * @param proxy {nsIProxyInfo} The proxy to use (or null to not use any).
+ * @param sslErrorHandler {SSLErrorHandler}
+ * @param resultCallback {function(wiredata)} This function will
+ * be called with the result string array from the server
+ * or null if no communication occurred.
+ * @param errorCallback {function(e)}
+ */
+function SocketUtil(
+ hostname,
+ port,
+ socketType,
+ commands,
+ timeout,
+ proxy,
+ sslErrorHandler,
+ resultCallback,
+ errorCallback
+) {
+ assert(commands && commands.length, "need commands");
+
+ var index = 0; // commands[index] is next to send to server
+ var initialized = false;
+ var aborted = false;
+
+ function _error(e) {
+ if (aborted) {
+ return;
+ }
+ aborted = true;
+ errorCallback(e);
+ }
+
+ function timeoutFunc() {
+ if (!initialized) {
+ _error("timeout");
+ }
+ }
+
+ // In case DNS takes too long or does not resolve or another blocking
+ // issue occurs before the timeout can be set on the socket, this
+ // ensures that the listener callback will be fired in a timely manner.
+ // XXX There might to be some clean up needed after the timeout is fired
+ // for socket and io resources.
+
+ // The timeout value plus 2 seconds
+ setTimeout(timeoutFunc, timeout * 1000 + 2000);
+
+ var transportService = Cc[
+ "@mozilla.org/network/socket-transport-service;1"
+ ].getService(Ci.nsISocketTransportService);
+
+ // @see NS_NETWORK_SOCKET_CONTRACTID_PREFIX
+ var socketTypeName;
+ if (socketType == SSL) {
+ socketTypeName = ["ssl"];
+ } else if (socketType == STARTTLS) {
+ socketTypeName = ["starttls"];
+ } else {
+ socketTypeName = [];
+ }
+ var transport = transportService.createTransport(
+ socketTypeName,
+ hostname,
+ port,
+ proxy,
+ null
+ );
+
+ transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, timeout);
+ transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_READ_WRITE, timeout);
+
+ var outstream = transport.openOutputStream(0, 0, 0);
+ var stream = transport.openInputStream(0, 0, 0);
+ var instream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ instream.init(stream);
+
+ var dataListener = {
+ data: [],
+ onStartRequest(request) {
+ try {
+ initialized = true;
+ if (!aborted) {
+ // Send the first request
+ let outputData = commands[index++];
+ outstream.write(outputData, outputData.length);
+ }
+ } catch (e) {
+ _error(e);
+ }
+ },
+ async onStopRequest(request, status) {
+ try {
+ instream.close();
+ outstream.close();
+ // Did it fail because of a bad certificate?
+ let isCertError = false;
+ if (!Components.isSuccessCode(status)) {
+ let nssErrorsService = Cc[
+ "@mozilla.org/nss_errors_service;1"
+ ].getService(Ci.nsINSSErrorsService);
+ try {
+ let errorType = nssErrorsService.getErrorClass(status);
+ if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ isCertError = true;
+ }
+ } catch (e) {
+ // nsINSSErrorsService.getErrorClass throws if given a non-STARTTLS,
+ // non-cert error, so ignore this.
+ }
+ }
+ if (isCertError) {
+ if (
+ Services.prefs.getBoolPref(
+ "mailnews.auto_config.guess.requireGoodCert",
+ true
+ )
+ ) {
+ gAccountSetupLogger.info(
+ `Bad (overridable) certificate for ${hostname}:${port}. Set mailnews.auto_config.guess.requireGoodCert to false to allow detecting this as a valid SSL/TLS configuration`
+ );
+ } else {
+ let socketTransport = transport.QueryInterface(
+ Ci.nsISocketTransport
+ );
+ let secInfo =
+ await socketTransport.tlsSocketControl?.asyncGetSecurityInfo();
+ sslErrorHandler.processCertError(secInfo, hostname + ":" + port);
+ }
+ }
+ resultCallback(this.data.length ? this.data : null);
+ } catch (e) {
+ _error(e);
+ }
+ },
+ onDataAvailable(request, inputStream, offset, count) {
+ try {
+ if (!aborted) {
+ let inputData = instream.read(count);
+ this.data.push(inputData);
+ if (index < commands.length) {
+ // Send the next request to the server.
+ let outputData = commands[index++];
+ outstream.write(outputData, outputData.length);
+ }
+ }
+ } catch (e) {
+ _error(e);
+ }
+ },
+ };
+
+ try {
+ var pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(
+ Ci.nsIInputStreamPump
+ );
+
+ pump.init(stream, 0, 0, false);
+ pump.asyncRead(dataListener);
+ return new SocketAbortable(transport);
+ } catch (e) {
+ _error(e);
+ }
+ return null;
+}
+
+function SocketAbortable(transport) {
+ Abortable.call(this);
+ assert(transport instanceof Ci.nsITransport, "need transport");
+ this._transport = transport;
+}
+SocketAbortable.prototype = Object.create(Abortable.prototype);
+SocketAbortable.prototype.constructor = UserCancelledException;
+SocketAbortable.prototype.cancel = function (ex) {
+ try {
+ this._transport.close(Cr.NS_ERROR_ABORT);
+ } catch (e) {
+ ddump("canceling socket failed: " + e);
+ }
+};
+
+/**
+ * Resolve a proxy for some domain and expose it via a callback.
+ *
+ * @param hostname {String} The hostname which a proxy will be resolved for
+ * @param resultCallback {function(proxyInfo)}
+ * Called after the proxy has been resolved for hostname.
+ * proxy {nsIProxyInfo} The resolved proxy, or null if none were found
+ * for hostname
+ */
+function doProxy(hostname, resultCallback) {
+ // This implements the nsIProtocolProxyCallback interface:
+ function ProxyResolveCallback() {}
+ ProxyResolveCallback.prototype = {
+ onProxyAvailable(req, uri, proxy, status) {
+ // Anything but a SOCKS proxy will be unusable for email.
+ if (proxy != null && proxy.type != "socks" && proxy.type != "socks4") {
+ proxy = null;
+ }
+ resultCallback(proxy);
+ },
+ };
+ var proxyService = Cc[
+ "@mozilla.org/network/protocol-proxy-service;1"
+ ].getService(Ci.nsIProtocolProxyService);
+ // Use some arbitrary scheme just because it is required...
+ var uri = Services.io.newURI("http://" + hostname);
+ // ... we'll ignore it any way. We prefer SOCKS since that's the
+ // only thing we can use for email protocols.
+ var proxyFlags =
+ Ci.nsIProtocolProxyService.RESOLVE_IGNORE_URI_SCHEME |
+ Ci.nsIProtocolProxyService.RESOLVE_PREFER_SOCKS_PROXY;
+ if (Services.prefs.getBoolPref("network.proxy.socks_remote_dns")) {
+ proxyFlags |= Ci.nsIProtocolProxyService.RESOLVE_ALWAYS_TUNNEL;
+ }
+ proxyService.asyncResolve(uri, proxyFlags, new ProxyResolveCallback());
+}
+
+var GuessConfig = {
+ UNKNOWN,
+ IMAP,
+ POP,
+ SMTP,
+ NONE,
+ STARTTLS,
+ SSL,
+ getHostEntry,
+ getIncomingTryOrder,
+ getOutgoingTryOrder,
+ guessConfig,
+};
diff --git a/comm/mail/components/accountcreation/Sanitizer.jsm b/comm/mail/components/accountcreation/Sanitizer.jsm
new file mode 100644
index 0000000000..d6bc3918bc
--- /dev/null
+++ b/comm/mail/components/accountcreation/Sanitizer.jsm
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["Sanitizer"];
+
+const { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+
+const { cleanUpHostName, isLegalHostNameOrIP } = ChromeUtils.import(
+ "resource:///modules/hostnameUtils.jsm"
+);
+
+/**
+ * This is a generic input validation lib. Use it when you process
+ * data from the network.
+ *
+ * Just a few functions which verify, for security purposes, that the
+ * input variables (strings, if nothing else is noted) are of the expected
+ * type and syntax.
+ *
+ * The functions take a string (unless noted otherwise) and return
+ * the expected datatype in JS types. If the value is not as expected,
+ * they throw exceptions.
+ */
+
+// To debug, set mail.setup.loglevel="All" and kDebug = true.
+var kDebug = false;
+
+var Sanitizer = {
+ integer(unchecked) {
+ if (typeof unchecked == "number" && !isNaN(unchecked)) {
+ return unchecked;
+ }
+
+ var r = parseInt(unchecked);
+ if (isNaN(r)) {
+ throw new MalformedException("no_number.error", unchecked);
+ }
+
+ return r;
+ },
+
+ integerRange(unchecked, min, max) {
+ var int = this.integer(unchecked);
+ if (int < min) {
+ throw new MalformedException("number_too_small.error", unchecked);
+ }
+
+ if (int > max) {
+ throw new MalformedException("number_too_large.error", unchecked);
+ }
+
+ return int;
+ },
+
+ boolean(unchecked) {
+ if (typeof unchecked == "boolean") {
+ return unchecked;
+ }
+
+ if (unchecked == "true") {
+ return true;
+ }
+
+ if (unchecked == "false") {
+ return false;
+ }
+
+ throw new MalformedException("boolean.error", unchecked);
+ },
+
+ string(unchecked) {
+ return String(unchecked);
+ },
+
+ nonemptystring(unchecked) {
+ if (!unchecked) {
+ throw new MalformedException("string_empty.error", unchecked);
+ }
+
+ return this.string(unchecked);
+ },
+
+ /**
+ * Allow only letters, numbers, "-" and "_".
+ *
+ * Empty strings not allowed (good idea?).
+ */
+ alphanumdash(unchecked) {
+ var str = this.nonemptystring(unchecked);
+ if (!/^[a-zA-Z0-9\-\_]*$/.test(str)) {
+ throw new MalformedException("alphanumdash.error", unchecked);
+ }
+
+ return str;
+ },
+
+ /**
+ * DNS hostnames like foo.bar.example.com
+ * Allow only letters, numbers, "-" and "."
+ * Empty strings not allowed.
+ * Currently does not support IDN (international domain names).
+ */
+ hostname(unchecked) {
+ let str = cleanUpHostName(this.nonemptystring(unchecked));
+
+ // Allow placeholders. TODO move to a new hostnameOrPlaceholder()
+ // The regex is "anything, followed by one or more (placeholders than
+ // anything)". This doesn't catch the non-placeholder case, but that's
+ // handled down below.
+ if (/^[a-zA-Z0-9\-\.]*(%[A-Z0-9]+%[a-zA-Z0-9\-\.]*)+$/.test(str)) {
+ return str;
+ }
+
+ if (!isLegalHostNameOrIP(str)) {
+ throw new MalformedException("hostname_syntax.error", unchecked);
+ }
+
+ return str.toLowerCase();
+ },
+
+ /**
+ * A value which resembles an email address.
+ */
+ emailAddress(unchecked) {
+ let str = this.nonemptystring(unchecked);
+ if (!/^[a-z0-9\-%+_\.\*]+@[a-z0-9\-\.]+\.[a-z]+$/i.test(str)) {
+ throw new MalformedException("emailaddress_syntax.error", unchecked);
+ }
+
+ return str.toLowerCase();
+ },
+
+ /**
+ * A non-chrome URL that's safe to request.
+ */
+ url(unchecked) {
+ var str = this.string(unchecked);
+
+ // DANGER ZONE: data:text/javascript or data:text/html can contain
+ // JavaScript code, run in the caller's security context, and might allow
+ // arbitrary code execution, so these must be prevented at all costs.
+ // PNG and JPEG data: URLs are fine. But SVG is again dangerous,
+ // it can contain javascript, so it would create a critical security hole.
+ // Talk to BenB or bz before relaxing *any* of the checks in this function.
+ if (
+ str.startsWith("data:image/png;") ||
+ str.startsWith("data:image/jpeg;")
+ ) {
+ return new URL(str).href;
+ }
+
+ if (!str.startsWith("http:") && !str.startsWith("https:")) {
+ throw new MalformedException("url_scheme.error", unchecked);
+ }
+
+ var uri;
+ try {
+ uri = Services.io.newURI(str);
+ uri = uri.QueryInterface(Ci.nsIURL);
+ } catch (e) {
+ throw new MalformedException("url_parsing.error", unchecked);
+ }
+
+ if (uri.scheme != "http" && uri.scheme != "https") {
+ throw new MalformedException("url_scheme.error", unchecked);
+ }
+
+ return uri.spec;
+ },
+
+ /**
+ * A value which should be shown to the user in the UI as label
+ */
+ label(unchecked) {
+ return this.string(unchecked);
+ },
+
+ /**
+ * Allows only certain values as input, otherwise throw.
+ *
+ * @param unchecked {Any} The value to check
+ * @param allowedValues {Array} List of values that |unchecked| may have.
+ * @param defaultValue {Any} (Optional) If |unchecked| does not match
+ * anything in |mapping|, a |defaultValue| can be returned instead of
+ * throwing an exception. The latter is the default and happens when
+ * no |defaultValue| is passed.
+ * @throws MalformedException
+ */
+ enum(unchecked, allowedValues, defaultValue) {
+ for (let allowedValue of allowedValues) {
+ if (allowedValue == unchecked) {
+ return allowedValue;
+ }
+ }
+ // value is bad
+ if (typeof defaultValue == "undefined") {
+ throw new MalformedException("allowed_value.error", unchecked);
+ }
+ return defaultValue;
+ },
+
+ /**
+ * Like enum, allows only certain (string) values as input, but allows the
+ * caller to specify another value to return instead of the input value. E.g.,
+ * if unchecked == "foo", return 1, if unchecked == "bar", return 2,
+ * otherwise throw. This allows to translate string enums into integer enums.
+ *
+ * @param unchecked {Any} The value to check
+ * @param mapping {Object} Associative array. property name is the input
+ * value, property value is the output value. E.g. the example above
+ * would be: { foo: 1, bar : 2 }.
+ * Use quotes when you need freaky characters: "baz-" : 3.
+ * @param defaultValue {Any} (Optional) If |unchecked| does not match
+ * anything in |mapping|, a |defaultValue| can be returned instead of
+ * throwing an exception. The latter is the default and happens when
+ * no |defaultValue| is passed.
+ * @throws MalformedException
+ */
+ translate(unchecked, mapping, defaultValue) {
+ for (var inputValue in mapping) {
+ if (inputValue == unchecked) {
+ return mapping[inputValue];
+ }
+ }
+ // value is bad
+ if (typeof defaultValue == "undefined") {
+ throw new MalformedException("allowed_value.error", unchecked);
+ }
+ return defaultValue;
+ },
+};
+
+function MalformedException(msgID, uncheckedBadValue) {
+ var stringBundle = AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationUtil.properties"
+ );
+ var msg = stringBundle.GetStringFromName(msgID);
+ if (typeof kDebug != "undefined" && kDebug) {
+ msg += " (bad value: " + uncheckedBadValue + ")";
+ }
+ AccountCreationUtils.Exception.call(this, msg);
+}
+MalformedException.prototype = Object.create(
+ AccountCreationUtils.Exception.prototype
+);
+MalformedException.prototype.constructor = MalformedException;
diff --git a/comm/mail/components/accountcreation/content/accountHub.js b/comm/mail/components/accountcreation/content/accountHub.js
new file mode 100644
index 0000000000..a703ffce16
--- /dev/null
+++ b/comm/mail/components/accountcreation/content/accountHub.js
@@ -0,0 +1,277 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Holds the main controller class.
+ *
+ * @type {?AccountHubControllerClass}
+ */
+var AccountHubController;
+
+/**
+ * Controller class to handle the primary views of the account setup flow.
+ * This class acts as a sort of controller to lazily load the needed views upon
+ * request. It doesn't handle any data and it should only be used to switch
+ * between the different setup flows.
+ * All methods of this class should be private, except for the open() method.
+ */
+class AccountHubControllerClass {
+ /**
+ * The account hub main modal dialog.
+ *
+ * @type {?HTMLElement}
+ */
+ #modal = null;
+
+ /**
+ * The currently visible view inside the dialog.
+ *
+ * @type {?HTMLElement}
+ */
+ #currentView = null;
+
+ /**
+ * Object containing all strings to trigger the needed methods for the various
+ * views.
+ */
+ #accounts = {
+ START: () => this.#viewStart(),
+ MAIL: () => this.#viewEmailSetup(),
+ CALENDAR: () => this.#viewCalendarSetup(),
+ ADDRESS_BOOK: () => this.#viewAddressBookSetup(),
+ CHAT: () => this.#viewChatSetup(),
+ FEED: () => this.#viewFeedSetup(),
+ NNTP: () => this.#viewNNTPSetup(),
+ IMPORT: () => this.#viewImportSetup(),
+ };
+
+ constructor() {
+ this.ready = this.#init();
+ }
+
+ async #init() {
+ await this.#loadScript("container");
+ const element = document.createElement("account-hub-container");
+ document.body.appendChild(element);
+ this.#modal = element.modal;
+
+ let closeButton = this.#modal.querySelector("#closeButton");
+ closeButton.hidden = !MailServices.accounts.accounts.length;
+ closeButton.addEventListener("click", () => this.#modal.close());
+
+ // Listen from closing requests coming from child elements.
+ this.#modal.addEventListener(
+ "request-close",
+ event => {
+ event.stopPropagation();
+ this.#modal.close();
+ },
+ {
+ capture: true,
+ }
+ );
+
+ this.#modal.addEventListener(
+ "open-view",
+ event => {
+ event.stopPropagation();
+ this.open(event.detail.type);
+ },
+ {
+ capture: true,
+ }
+ );
+
+ this.#modal.addEventListener("close", event => {
+ // Don't allow the dialog to be closed if some operations are can't be
+ // aborted or the UI can't be cleared.
+ if (!this.#reset()) {
+ event.preventDefault();
+ }
+ });
+
+ this.#modal.addEventListener("cancel", event => {
+ if (
+ !MailServices.accounts.accounts.length &&
+ !Services.prefs.getBoolPref("app.use_without_mail_account", false)
+ ) {
+ // Prevent closing the modal if no account is currently present and the
+ // user didn't request using Thunderbird without an email account.
+ event.preventDefault();
+ return;
+ }
+
+ // Don't allow the dialog to be canceled via the ESC key if some
+ // operations are in progress and can't be aborted or the UI can't be
+ // cleared.
+ if (!this.#reset()) {
+ event.preventDefault();
+ }
+ });
+ }
+
+ /**
+ * Check if we don't currently have the needed custom element for the
+ * requested view and load the needed script. We do this to avoid loading all
+ * the unnecessary account creation files.
+ *
+ * @param {string} view - The name of the view to load.
+ * @returns {Promise<void>} Resolves when custom element of the view is usable.
+ */
+ #loadScript(view) {
+ if (customElements.get(`account-hub-${view}`)) {
+ return Promise.resolve();
+ }
+ // eslint-disable-next-line no-unsanitized/method
+ return import(
+ `chrome://messenger/content/accountcreation/views/${view}.mjs`
+ );
+ }
+
+ /**
+ * Create a custom element and append it to the modal inner HTML, or simply
+ * show it if it was already loaded.
+ *
+ * @param {string} id - The ID of the template to clone.
+ */
+ #loadView(id) {
+ this.#hideViews();
+
+ let view = this.#modal.querySelector(id);
+ if (view) {
+ view.hidden = false;
+ this.#currentView = view;
+ // Update the UI to make sure we're refreshing old views.
+ this.#currentView.initUI();
+ return;
+ }
+
+ view = document.createElement(id);
+ this.#modal.appendChild(view);
+ this.#currentView = view;
+ }
+
+ /**
+ * Hide all the currently visible views.
+ */
+ #hideViews() {
+ for (let view of this.#modal.querySelectorAll(".account-hub-view")) {
+ view.hidden = true;
+ }
+ }
+
+ /**
+ * Open the main modal dialog and load the requested account setup view, or
+ * fallback to the initial start screen.
+ *
+ * @param {?string} type - Which account flow to load when the modal opens.
+ */
+ open(type = "START") {
+ // Interrupt if something went wrong while cleaning up a previously loaded
+ // view.
+ if (!this.#reset()) {
+ return;
+ }
+
+ this.#accounts[type].call();
+ if (!this.#modal.open) {
+ this.#modal.showModal();
+ }
+ }
+
+ /**
+ * Check if we have a current class and try to trigger the rest in order to
+ * handle abort operations and markup clean up, if possible.
+ *
+ * @returns {boolean} - True if the reset process was successful or we didn't
+ * have anything to reset.
+ */
+ #reset() {
+ let isClean = this.#currentView?.reset() ?? true;
+ // If the reset operation was successful, clear the current class.
+ if (isClean) {
+ this.#hideViews();
+ this.#currentView = null;
+ }
+ return isClean;
+ }
+
+ /**
+ * Show the initial view of the account hub dialog.
+ */
+ async #viewStart() {
+ await this.#loadScript("start");
+ this.#loadView("account-hub-start");
+ }
+
+ /**
+ * Show the email setup view.
+ */
+ async #viewEmailSetup() {
+ await this.#loadScript("email");
+ this.#loadView("account-hub-email");
+ }
+
+ /**
+ * TODO: Show the calendar setup view.
+ */
+ #viewCalendarSetup() {
+ console.log("Calendar setup");
+ }
+
+ /**
+ * TODO: Show the address book setup view.
+ */
+ #viewAddressBookSetup() {
+ console.log("Address Book setup");
+ }
+
+ /**
+ * TODO: Show the chat setup view.
+ */
+ #viewChatSetup() {
+ console.log("Chat setup");
+ }
+
+ /**
+ * TODO: Show the feed setup view.
+ */
+ #viewFeedSetup() {
+ console.log("Feed setup");
+ }
+
+ /**
+ * TODO: Show the newsgroup setup view.
+ */
+ #viewNNTPSetup() {
+ console.log("Newsgroup setup");
+ }
+
+ /**
+ * TODO: Show the import setup view.
+ */
+ #viewImportSetup() {
+ console.log("Import setup");
+ }
+}
+
+/**
+ * Open the account hub dialog and show the requested view.
+ *
+ * @param {?string} type - The type of view that should be loaded when the modal
+ * is showed. See AccountHubController::#accounts for a list references.
+ */
+async function openAccountHub(type) {
+ if (!AccountHubController) {
+ AccountHubController = new AccountHubControllerClass();
+ }
+ await AccountHubController.ready;
+ AccountHubController.open(type);
+}
diff --git a/comm/mail/components/accountcreation/content/accountSetup.js b/comm/mail/components/accountcreation/content/accountSetup.js
new file mode 100644
index 0000000000..3a214f2292
--- /dev/null
+++ b/comm/mail/components/accountcreation/content/accountSetup.js
@@ -0,0 +1,3023 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements */
+
+/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */
+var { AccountCreationUtils } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+var { fetchConfigFromExchange, getAddonsList } = ChromeUtils.import(
+ "resource:///modules/accountcreation/ExchangeAutoDiscover.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm",
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CardDAVUtils: "resource:///modules/CardDAVUtils.jsm",
+ ConfigVerifier: "resource:///modules/accountcreation/ConfigVerifier.jsm",
+ CreateInBackend: "resource:///modules/accountcreation/CreateInBackend.jsm",
+ FetchConfig: "resource:///modules/accountcreation/FetchConfig.jsm",
+ GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm",
+ OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
+ Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+var {
+ Abortable,
+ AddonInstaller,
+ alertPrompt,
+ assert,
+ CancelledException,
+ Exception,
+ gAccountSetupLogger,
+ NotReached,
+ PriorityOrderAbortable,
+ UserCancelledException,
+} = AccountCreationUtils;
+
+/**
+ * This is the dialog opened by menu File | New account | Mail... .
+ *
+ * It gets the user's realname, email address and password,
+ * and tries to automatically configure the account from that,
+ * using various mechanisms. If all fails, the user can enter/edit
+ * the config, then we create the account.
+ *
+ * Steps:
+ * - User enters realname, email address and password
+ * - check for config files on disk
+ * (shipping with Thunderbird, for enterprise deployments)
+ * - (if fails) try to get the config file from the ISP via a
+ * fixed URL on the domain of the email address
+ * - (if fails) try to get the config file from our own database
+ * at MoMo servers, maintained by the community
+ * - (if fails) try to guess the config, by guessing hostnames,
+ * probing ports, checking config via server's CAPS line etc..
+ * - verify the setup, by trying to login to the configured servers
+ * - let user verify and maybe edit the server names and ports
+ * - If user clicks OK, create the account
+ */
+
+// Keep track of the prefers-reduce-motion media query for JS based animations.
+var gReducedMotion;
+
+// The main 3 Pane Window that we need to define on load in order to properly
+// update the UI when a new account is created.
+var gMainWindow;
+
+// Define standard incoming port numbers.
+var gStandardPorts = {
+ imap: [143, 993],
+ pop3: [110, 995],
+ smtp: [587, 25, 465], // order matters
+ exchange: [443],
+};
+
+// Store all ports into a flat array for greppability.
+var gAllStandardPorts = gStandardPorts.smtp
+ .concat(gStandardPorts.imap)
+ .concat(gStandardPorts.pop3)
+ .concat(gStandardPorts.exchange);
+
+// Define window event listeners.
+window.addEventListener("load", () => {
+ gAccountSetup.onLoad();
+});
+window.addEventListener("unload", () => {
+ gAccountSetup.onUnload();
+});
+
+function onSetupComplete() {
+ // Post a message to the main window at the end of a successful account setup.
+ gMainWindow.postMessage("account-created", "*");
+}
+
+/**
+ * Prompt a native HTML confirmation dialog for the Exchange auto discover.
+ *
+ * @param {string} domain - Text with the question.
+ * @param {Function} okCallback - Called when the user clicks OK.
+ * @param {function(ex)} cancelCallback - Called when the user clicks Cancel
+ * or if you call `Abortable.cancel()`.
+ * @returns {Abortable} - If `Abortable.cancel()` is called,
+ * the dialog is closed and the `cancelCallback()` is called.
+ */
+function confirmExchange(domain, okCallback, cancelCallback) {
+ let dialog = document.getElementById("exchangeDialog");
+
+ document.l10n.setAttributes(
+ document.getElementById("exchangeDialogQuestion"),
+ "exchange-dialog-question",
+ {
+ domain,
+ }
+ );
+
+ document.getElementById("exchangeDialogConfirmButton").onclick = () => {
+ dialog.close();
+ okCallback();
+ };
+
+ document.getElementById("exchangeDialogCancelButton").onclick = () => {
+ dialog.close();
+ cancelCallback(new UserCancelledException());
+ };
+
+ // Show the dialog.
+ dialog.showModal();
+
+ let abortable = new Abortable();
+ abortable.cancel = ex => {
+ dialog.close();
+ cancelCallback(ex);
+ };
+ return abortable;
+}
+
+/**
+ * This is our controller for the entire account setup workflow.
+ */
+var gAccountSetup = {
+ // Boolean attribute to keep track of the initialization status of the wizard.
+ isInited: false,
+ // Attribute to store methods to interrupt abortable operations like testing
+ // a server configuration or installing an add-on.
+ _abortable: null,
+
+ tabMonitor: {
+ monitorName: "accountSetupMonitor",
+
+ onTabTitleChanged() {},
+ onTabOpened() {},
+ onTabPersist() {},
+ onTabRestored() {},
+ onTabClosing(tab) {
+ if (tab?.urlbar?.value == "about:accountsetup") {
+ gMainWindow?.postMessage("account-setup-dismissed", "*");
+ }
+ },
+ onTabSwitched() {},
+ },
+
+ /**
+ * Initialize the main notification box for the account setup process.
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("accountSetupNotifications").append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ /**
+ * Initialize the notification box for the calendar and address book sync
+ * process at the end of the account setup.
+ */
+ get syncingBox() {
+ if (!this._syncingBox) {
+ this._syncingBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("syncNotifications").append(element);
+ });
+ }
+ return this._syncingBox;
+ },
+
+ clearNotifications() {
+ this.notificationBox.removeAllNotifications();
+ },
+
+ onLoad() {
+ // Bail out if it was already initialized.
+ if (this.isInited) {
+ return;
+ }
+
+ gAccountSetupLogger.debug("Initializing setup wizard");
+ gReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+
+ // Store the main window.
+ gMainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // this._currentConfig is the config we got either from the XML file or from
+ // guessing or from the user. Unless it's from the user, it contains
+ // placeholders like %EMAILLOCALPART% in username and other fields.
+ //
+ // The config here must retain these placeholders, to be able to adapt when
+ // the user enters a different realname, or password or email local part.
+ // A change of the domain name will trigger a new detection anyways. That
+ // means, before you actually use the config (e.g. to create an account or
+ // to show it to the user), you need to run replaceVariables().
+ this._currentConfig = null;
+ this._domain = "";
+ this._hostname = "";
+ this._email = "";
+ this._realname = "";
+ if ("@mozilla.org/userinfo;1" in Cc) {
+ let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo);
+ // Assume that it's a genuine full name if it includes a space.
+ if (userInfo.fullname.includes(" ")) {
+ this._realname = userInfo.fullname;
+ document.getElementById("realname").value = this._realname;
+ }
+ }
+
+ this._password = "";
+ // Keep track of the state of the password field, if password or clear text.
+ this._showPassword = false;
+ // This is used only for Exchange AutoDiscover and only if needed.
+ this._exchangeUsername = "";
+ // Store the successful callback in this attribute so we can send it around
+ // the various validation methods.
+ this._okCallback = onSetupComplete;
+ this._msgWindow = gMainWindow.msgWindow;
+
+ // If the account provisioner is preffed off, don't display the account
+ // provisioner button.
+ if (!Services.prefs.getBoolPref("mail.provider.enabled")) {
+ document.getElementById("provisionerButton").hidden = true;
+ }
+
+ // Disable the remember password checkbox if the pref is false.
+ if (!Services.prefs.getBoolPref("signon.rememberSignons")) {
+ let passwordCheckbox = document.getElementById("rememberPassword");
+ passwordCheckbox.checked = false;
+ passwordCheckbox.disabled = true;
+ }
+
+ // Ensure the cursor is on the first input field.
+ document.getElementById("realname").focus();
+
+ // In a new profile, the first request to live.thunderbird.net is much
+ // slower because of one-time overheads like DNS and OCSP. Let's create some
+ // dummy requests to prime the connections.
+ let autoconfigURL = Services.prefs.getCharPref("mailnews.auto_config_url");
+ fetch(autoconfigURL, { method: "OPTIONS" }).catch(console.error);
+
+ let addonsURL = Services.prefs.getCharPref(
+ "mailnews.auto_config.addons_url"
+ );
+ if (new URL(autoconfigURL).origin != new URL(addonsURL).origin) {
+ fetch(addonsURL, { method: "OPTIONS" }).catch(console.error);
+ }
+
+ // The tab monitor will inform us when this tab is getting closed.
+ gMainWindow.document
+ .getElementById("tabmail")
+ .registerTabMonitor(this.tabMonitor);
+
+ // We did everything, now we can update the variable.
+ this.isInited = true;
+ gAccountSetupLogger.debug("Account setup tab loaded.");
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+ },
+
+ /**
+ * Changes the window configuration to the different modes we have.
+ * Shows/hides various window parts and buttons.
+ *
+ * @param {string} modename
+ * "start" : Just the realname, email address, password fields
+ * "find-config" : detection step, adds the loading notification
+ * "result" : We found a config and display it to the user.
+ * The user may create the account.
+ * "manual-edit" : The user wants (or needs) to manually enter their
+ * the server hostname and other settings. We'll use them as provided.
+ * Additionally, there are the following sub-modes which can be entered after
+ * you entered the main mode:
+ * "manual-edit-have-hostname" : user entered a hostname for both servers
+ * that we can use
+ * "manual-edit-testing" : User pressed the [Re-test] button and
+ * we're currently detecting the "Auto" values
+ * "manual-edit-complete" : user entered (or we tested) all necessary
+ * values, and we're ready to create to account
+ * Currently, this doesn't cover the warning dialogs etc.. It may later.
+ */
+ switchToMode(modename) {
+ // Bail out if we requested the same mode we're currently viewing.
+ if (modename == this._currentModename) {
+ return;
+ }
+
+ this._currentModename = modename;
+ gAccountSetupLogger.debug(`switching to UI mode ${modename}`);
+
+ let continueButton = document.getElementById("continueButton");
+ let createButton = document.getElementById("createButton");
+ let reTestButton = document.getElementById("reTestButton");
+ let autoconfigDesc = document.getElementById("manualConfigDescription");
+ let setupTitle = document.getElementById("accountSetupTitle");
+
+ switch (modename) {
+ case "start":
+ this.clearNotifications();
+ document.getElementById("setupView").hidden = false;
+ document.getElementById("successView").hidden = true;
+
+ document.l10n.setAttributes(setupTitle, "account-setup-title");
+ setupTitle.classList.remove("success");
+ document.l10n.setAttributes(
+ document.getElementById("accountSetupDescription"),
+ "account-setup-description"
+ );
+
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ createButton.hidden = true;
+ continueButton.disabled = true;
+ continueButton.hidden = false;
+ break;
+ case "find-config":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = false;
+
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ createButton.hidden = true;
+ continueButton.disabled = true;
+ continueButton.hidden = false;
+ this.onStop = this.onStopFindConfig;
+ break;
+ case "result":
+ document.getElementById("manualConfigArea").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+ document.getElementById("resultsArea").hidden = false;
+ document.getElementById("manualConfigButton").hidden = false;
+
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ continueButton.hidden = true;
+ createButton.hidden = false;
+ createButton.disabled = false;
+ break;
+ case "manual-edit":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+
+ continueButton.hidden = true;
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = true;
+ createButton.hidden = false;
+ createButton.disabled = true;
+ break;
+ case "manual-edit-have-hostname":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = false;
+ continueButton.hidden = true;
+ createButton.hidden = false;
+ createButton.disabled = true;
+ break;
+ case "manual-edit-testing":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = false;
+
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = true;
+ continueButton.hidden = true;
+ createButton.hidden = false;
+ createButton.disabled = true;
+
+ this.onStop = this.onStopHalfManualTesting;
+ break;
+ case "manual-edit-complete":
+ document.getElementById("resultsArea").hidden = true;
+ document.getElementById("manualConfigArea").hidden = false;
+ document.getElementById("manualConfigButton").hidden = true;
+ document.getElementById("stopButton").hidden = true;
+
+ reTestButton.hidden = false;
+ autoconfigDesc.hidden = false;
+ reTestButton.disabled = false;
+ continueButton.hidden = true;
+ createButton.disabled = false;
+ createButton.hidden = false;
+
+ document.getElementById("incomingProtocol").focus();
+ break;
+ case "success":
+ document.getElementById("setupView").hidden = true;
+ document.getElementById("successView").hidden = false;
+
+ document.l10n.setAttributes(setupTitle, "account-setup-success-title");
+ setupTitle.classList.add("success");
+ document.l10n.setAttributes(
+ document.getElementById("accountSetupDescription"),
+ "account-setup-success-description"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("accountSetupDescriptionSecondary"),
+ "account-setup-success-secondary-description"
+ );
+ break;
+ default:
+ throw new NotReached("Unknown mode requested");
+ }
+
+ // If we're offline, we're going to disable the create button, but enable
+ // the advanced config button if we have a current config.
+ if (Services.io.offline && !this._currentConfig) {
+ document.getElementById("manualConfigButton").hidden = true;
+ reTestButton.hidden = true;
+ autoconfigDesc.hidden = true;
+ createButton.hidden = true;
+ }
+ },
+
+ /**
+ * Reset the form and the entire UI of the account setup.
+ */
+ resetSetup() {
+ document.getElementById("form").reset();
+ document.getElementById("realname").focus();
+ // Call onStartOver only after resetting the form in order to properly
+ // update the form buttons.
+ this.onStartOver();
+ },
+
+ /**
+ * Start from beginning with possibly new email address.
+ */
+ onStartOver() {
+ this._currentConfig = null;
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.switchToMode("start");
+ this.checkValidForm();
+ },
+
+ getConcreteConfig() {
+ let result = this._currentConfig.copy();
+
+ AccountConfig.replaceVariables(
+ result,
+ this._realname,
+ this._email,
+ this._password
+ );
+ result.rememberPassword =
+ document.getElementById("rememberPassword").checked && !!this._password;
+
+ if (result.incoming.addonAccountType) {
+ result.incoming.type = result.incoming.addonAccountType;
+ }
+
+ return result;
+ },
+
+ /**
+ * onInputEmail and onInputRealname are called on input = keypresses, and
+ * enable/disable the next button based on whether there's a semi-proper
+ * e-mail address and non-blank realname to start with.
+ *
+ * A change to the email address also automatically restarts the
+ * whole process.
+ */
+ onInputEmail() {
+ this._email = document.getElementById("email").value;
+ this.onStartOver();
+ },
+
+ onInputRealname() {
+ this._realname = document.getElementById("realname").value;
+ this.checkValidForm();
+ },
+
+ onInputUsername() {
+ this._exchangeUsername = document.getElementById("usernameEx").value;
+ this.onStartOver();
+ },
+
+ onInputPassword() {
+ this._password = document.getElementById("password").value;
+ this.onStartOver();
+
+ // Show the password toggle button only if the field is not empty.
+ let toggleButton = document.getElementById("passwordToggleButton");
+ toggleButton.hidden = !this._password;
+
+ if (!this._password) {
+ // Always reset the field to the proper type.
+ this.hidePassword();
+ }
+ },
+
+ /**
+ * Toggle the type of the password field between password and text to allow
+ * users reading their own password.
+ */
+ passwordToggle(event) {
+ // Prevent the form submission if the user presses Enter.
+ event.preventDefault();
+
+ // The password field is in plain text, change it back to the proper type.
+ if (this._showPassword) {
+ this.hidePassword();
+ return;
+ }
+
+ // Change the password field to plain text to make the text visible.
+ this.showPassword();
+ },
+
+ /**
+ * Convert the password field into a plain text field allowing users and
+ * assistive technologies to read the typed text.
+ */
+ showPassword() {
+ document.getElementById("password").type = "text";
+ document.l10n.setAttributes(
+ document.getElementById("passwordToggleButton"),
+ "account-setup-password-toggle-hide"
+ );
+
+ let toggleImage = document.getElementById("passwordInfo");
+ toggleImage.src = "chrome://messenger/skin/icons/new/compact/eye.svg";
+ toggleImage.classList.add("password-toggled");
+
+ this._showPassword = true;
+ },
+
+ /**
+ * Convert the password field back to its default password type.
+ */
+ hidePassword() {
+ // No need to reset anything if the password was never shown.
+ if (!this._showPassword) {
+ return;
+ }
+
+ document.getElementById("password").type = "password";
+ document.l10n.setAttributes(
+ document.getElementById("passwordToggleButton"),
+ "account-setup-password-toggle-show"
+ );
+
+ let toggleImage = document.getElementById("passwordInfo");
+ toggleImage.src = "chrome://messenger/skin/icons/new/compact/hidden.svg";
+ toggleImage.classList.remove("password-toggled");
+
+ this._showPassword = false;
+ },
+
+ /**
+ * Check whether the user entered the minimum amount of information needed to
+ * leave the "start" mode (name and email) and is allowed to proceed to the
+ * detection step.
+ */
+ checkValidForm() {
+ let email = document.getElementById("email");
+ let isValidForm =
+ email.checkValidity() &&
+ document.getElementById("realname").checkValidity();
+ this._domain = isValidForm ? this._email.split("@")[1].toLowerCase() : "";
+
+ document.getElementById("continueButton").disabled = !isValidForm;
+ document.getElementById("manualConfigButton").hidden = !isValidForm;
+ document.getElementById("provisionerButton").hidden = email.value;
+ },
+
+ /**
+ * When the [Continue] button is clicked, we move from the initial account
+ * information stage to using that information to configure account details.
+ */
+ onContinue() {
+ this.findConfig(this._domain, this._email);
+ },
+
+ // --------------
+ // Detection step
+
+ /**
+ * Try to find an account configuration for this email address.
+ * This is the function which runs the autoconfig.
+ */
+ findConfig(domain, emailAddress) {
+ gAccountSetupLogger.debug("findConfig()");
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.switchToMode("find-config");
+ this.startLoadingState("account-setup-looking-up-settings");
+
+ let self = this;
+ let call = null;
+ let fetch = null;
+
+ let priority = (this._abortable = new PriorityOrderAbortable(
+ function (config, call) {
+ // success
+ self._abortable = null;
+ self.stopLoadingState(call.foundMsg);
+ self.foundConfig(config);
+ },
+ function (e, allErrors) {
+ // all failed
+ if (e instanceof CancelledException) {
+ self.onStartOver();
+ return;
+ }
+
+ // guess config
+ let initialConfig = new AccountConfig();
+ self._prefillConfig(initialConfig);
+ self._guessConfig(domain, initialConfig);
+ }
+ ));
+
+ try {
+ call = priority.addCall();
+ gAccountSetupLogger.debug(
+ "Looking up configuration: Thunderbird installation…"
+ );
+ call.foundMsg = "account-setup-success-settings-disk";
+ fetch = FetchConfig.fromDisk(
+ domain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug("Looking up configuration: Email provider…");
+ call.foundMsg = "account-setup-success-settings-isp";
+ fetch = FetchConfig.fromISP(
+ domain,
+ emailAddress,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug(
+ "Looking up configuration: Thunderbird installation…"
+ );
+ call.foundMsg = "account-setup-success-settings-db";
+ fetch = FetchConfig.fromDB(
+ domain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug(
+ "Looking up configuration: Incoming mail domain…"
+ );
+ // "account-setup-success-settings-db" is correct.
+ // We display the same message for both db and mx cases.
+ call.foundMsg = "account-setup-success-settings-db";
+ fetch = FetchConfig.forMX(
+ domain,
+ call.successCallback(),
+ call.errorCallback()
+ );
+ call.setAbortable(fetch);
+
+ call = priority.addCall();
+ gAccountSetupLogger.debug("Looking up configuration: Exchange server…");
+ call.foundMsg = "account-setup-success-settings-exchange";
+ fetch = fetchConfigFromExchange(
+ domain,
+ emailAddress,
+ this._exchangeUsername,
+ this._password,
+ confirmExchange,
+ call.successCallback(),
+ (e, allErrors) => {
+ // Must call error callback in any case to stop the discover mode.
+ let errorCallback = call.errorCallback();
+ if (e instanceof CancelledException) {
+ errorCallback(e);
+ } else if (allErrors && allErrors.some(e => e.code == 401)) {
+ // Auth failed.
+ // Ask user for username.
+ this.onStartOver();
+ this.stopLoadingState(); // clears status message
+ document.getElementById("usernameRow").hidden = false;
+
+ this.showErrorNotification(
+ !this._exchangeUsername
+ ? "account-setup-credentials-incomplete"
+ : "account-setup-credentials-wrong"
+ );
+ document.getElementById("manualConfigButton").hidden = false;
+ errorCallback(new CancelledException());
+ } else {
+ errorCallback(e);
+ }
+ }
+ );
+ call.setAbortable(fetch);
+ } catch (e) {
+ this.onStop();
+ // e.g. when entering an invalid domain like "c@c.-com"
+ this.showErrorNotification(e, true);
+ }
+ },
+
+ /**
+ * Just a continuation of findConfig()
+ */
+ _guessConfig(domain, initialConfig) {
+ this.startLoadingState("account-setup-looking-up-settings-guess");
+ let self = this;
+ self._abortable = GuessConfig.guessConfig(
+ domain,
+ function (type, hostname, port, socketType, done, config) {
+ // progress
+ gAccountSetupLogger.debug(
+ `${hostname}:${port} socketType=${socketType} ${type}: progress callback`
+ );
+ },
+ function (config) {
+ // success
+ self._abortable = null;
+ self.foundConfig(config);
+ self.stopLoadingState(
+ Services.io.offline
+ ? "account-setup-success-guess-offline"
+ : "account-setup-success-guess"
+ );
+ },
+ function (e, config) {
+ // guessconfig failed
+ if (e instanceof CancelledException) {
+ return;
+ }
+ self._abortable = null;
+ gAccountSetupLogger.warn(`guessConfig failed: ${e}`);
+ self.showErrorNotification("account-setup-find-settings-failed");
+ self.editConfigDetails();
+ },
+ initialConfig,
+ "both"
+ );
+ },
+
+ /**
+ * Called after findConfig() is successful and displays the data to the user.
+ *
+ * @param {AccountConfig} config - The config to present to the user.
+ */
+ foundConfig(config) {
+ gAccountSetupLogger.debug("found config:\n" + config);
+ assert(
+ config instanceof AccountConfig,
+ "BUG: Arg 'config' needs to be an AccountConfig object"
+ );
+
+ this._haveValidConfigForDomain = this._email.split("@")[1];
+
+ // Bail out if the name and email fields are empty.
+ if (!this._realname || !this._email) {
+ return;
+ }
+
+ config.addons = [];
+ let successCallback = () => {
+ this._abortable = null;
+ this.displayConfigResult(config);
+ this.switchToMode("result");
+ this.ensureVisibleButtons();
+ };
+ this._abortable = getAddonsList(config, successCallback, e => {
+ successCallback();
+ this.showErrorNotification(e, true);
+ });
+ },
+
+ /**
+ * [Stop] button click handler.
+ * This allows the user to abort any longer operation, esp. network activity.
+ * We currently have 3 such cases here:
+ * 1. findConfig(), i.e. fetch config from DB, guessConfig etc.
+ * 2. testManualConfig(), i.e. the [Retest] button in manual config.
+ * 3. verifyConfig() - We can't stop this yet, so irrelevant here currently.
+ * Given that these need slightly different actions, this function will be set
+ * to a function (i.e. overwritten) by whoever enables the stop button.
+ *
+ * We also call this from the code when the user started a different action
+ * without explicitly clicking [Stop] for the old one first.
+ */
+ onStop() {
+ throw new NotReached("onStop should be overridden by now");
+ },
+
+ _onStopCommon() {
+ if (!this._abortable) {
+ throw new NotReached("onStop called although there's nothing to stop");
+ }
+ gAccountSetupLogger.debug("onStop cancelled _abortable");
+ this._abortable.cancel(new UserCancelledException());
+ this._abortable = null;
+ this.stopLoadingState();
+ },
+
+ onStopFindConfig() {
+ this._onStopCommon();
+ this.switchToMode("start");
+ this.checkValidForm();
+ },
+
+ onStopHalfManualTesting() {
+ this._onStopCommon();
+ this.validateManualEditComplete();
+ },
+
+ // ----------- Loading area -----------
+ /**
+ * Disable all the input fields of the main form to prevent editing and show
+ * a notification while a loading or fetching state.
+ *
+ * @param {string} stringName - The name of the fluent string that needs to be
+ * attached to the notification.
+ */
+ async startLoadingState(stringName) {
+ gAccountSetupLogger.debug(`Loading start: ${stringName}`);
+
+ this.showHelperImage("step2");
+
+ // Disable all input fields.
+ for (let input of document.querySelectorAll("#form input")) {
+ input.disabled = true;
+ }
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+
+ gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`);
+
+ let notification = this.notificationBox.getNotificationWithValue(
+ "accountSetupLoading"
+ );
+
+ // If a notification already exists, simply update the message.
+ if (notification) {
+ notification.label = notificationMessage;
+ this.ensureVisibleNotification();
+ return;
+ }
+
+ notification = this.notificationBox.appendNotification(
+ "accountSetupLoading",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_INFO_LOW,
+ },
+ null
+ );
+ notification.setAttribute("align", "center");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Update the text of a currently visible loading notification
+ *
+ * @param {string} stringName - The name of the fluent string that needs to be
+ * attached to the notification.
+ */
+ async updateLoadingState(stringName) {
+ let notification = this.notificationBox.getNotificationWithValue(
+ "accountSetupLoading"
+ );
+ // If a notification doesn't already exist, create one.
+ if (!notification) {
+ this.startLoadingState(stringName);
+ return;
+ }
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+ notification.label = notificationMessage;
+ this.ensureVisibleNotification();
+
+ gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`);
+ },
+
+ /**
+ * Clear the loading notification and show a successful notification if
+ * needed.
+ *
+ * @param {?string} stringName - The name of the fluent string that needs to
+ * be attached to the notification, or null if nothing needs to be showed.
+ */
+ async stopLoadingState(stringName) {
+ // Re-enable all form input fields.
+ for (let input of document.querySelectorAll("#form input")) {
+ input.removeAttribute("disabled");
+ }
+
+ // Always remove any leftover notification.
+ this.clearNotifications();
+
+ // Bail out if we don't need to show anything else.
+ if (!stringName) {
+ gAccountSetupLogger.debug("Loading stopped");
+ this.showHelperImage("step1");
+ return;
+ }
+
+ gAccountSetupLogger.debug(`Loading stopped: ${stringName}`);
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountSetupSuccess",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_INFO_HIGH,
+ },
+ null
+ );
+ notification.setAttribute("type", "success");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.showHelperImage("step3");
+ },
+
+ /**
+ * Show an error notification in case something went wrong.
+ *
+ * @param {string} stringName - The name of the fluent string that needs to
+ * be attached to the notification.
+ * @param {boolean} isMsgError - True if the message comes from a server error
+ * response or try/catch.
+ */
+ async showErrorNotification(stringName, isMsgError) {
+ gAccountSetupLogger.debug(`Status error: ${stringName}`);
+
+ this.showHelperImage("step4");
+
+ // Re-enable all form input fields.
+ for (let input of document.querySelectorAll("#form input")) {
+ input.removeAttribute("disabled");
+ }
+
+ // Always remove any leftover notification before creating a new one.
+ this.clearNotifications();
+
+ // Fetch the fluent string only if this is not an error message coming from
+ // a previous method.
+ let notificationMessage = isMsgError
+ ? stringName
+ : await document.l10n.formatValue(stringName);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountSetupError",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Hide all the helper images and show the requested one.
+ *
+ * @param {string} id - The string ID of the element to show.
+ */
+ showHelperImage(id) {
+ // Hide all currently visible articles containing helper images in the
+ // second column.
+ for (let article of document.querySelectorAll(
+ ".second-column article:not([hidden])"
+ )) {
+ article.hidden = true;
+ }
+
+ // Simply show the requested helper image if the user specified a reduced
+ // motion preference.
+ if (gReducedMotion) {
+ document.getElementById(id).hidden = false;
+ return;
+ }
+
+ // Handle a nice cross fade between steps.
+ let stepToShow = document.getElementById(id);
+ // Add the class to let the revealing element start from a proper state.
+ stepToShow.classList.add("hide");
+ stepToShow.hidden = false;
+ // Timeout to animate after the hidden attribute has been removed.
+ setTimeout(() => {
+ stepToShow.classList.remove("hide");
+ });
+ },
+
+ /**
+ * Always ensure the primary button is visible by scrolling the page until the
+ * button is above the fold.
+ */
+ ensureVisibleButtons() {
+ // We use the #footDescription element to ensure the buttons are properly
+ // scrolled above the fold.
+ document.getElementById("footDescription").scrollIntoView({
+ behavior: gReducedMotion ? "auto" : "smooth",
+ block: "end",
+ inline: "nearest",
+ });
+ },
+
+ /**
+ * Always ensure the notification area is visible when a new notification is
+ * created.
+ */
+ ensureVisibleNotification() {
+ document.getElementById("accountSetupNotifications").scrollIntoView({
+ behavior: gReducedMotion ? "auto" : "smooth",
+ block: "start",
+ inline: "nearest",
+ });
+ },
+
+ /**
+ * Populate the results config details area.
+ *
+ * @param {AccountConfig} config - The config to present to user.
+ */
+ displayConfigResult(config) {
+ assert(config instanceof AccountConfig);
+ this._currentConfig = config;
+ let configFilledIn = this.getConcreteConfig();
+
+ // Filter out Protcols we don't currently support
+ let protocols = config.incomingAlternatives.filter(protocol =>
+ ["imap", "pop3", "exchange"].includes(protocol.type)
+ );
+ protocols.unshift(config.incoming);
+ protocols = protocols.reduce((found, nextEl) => {
+ if (!found.some(prevEl => prevEl.type == nextEl.type)) {
+ found.push(nextEl);
+ }
+ return found;
+ }, []);
+
+ // Hide all the available options in order to start with a clean slate.
+ for (let row of document.querySelectorAll(".content-blocking-category")) {
+ row.classList.remove("selected");
+ row.hidden = true;
+ }
+
+ // Remove all previously generated protocol types.
+ for (let type of document.querySelectorAll(".config-type")) {
+ type.remove();
+ }
+
+ // Reveal all the matching protocols.
+ for (let protocol of protocols) {
+ let row = document.getElementById(`resultsOption-${protocol.type}`);
+ row.hidden = false;
+ // Attach the protocol to the radio input for later usage.
+ row.querySelector(`input[type="radio"]`).configIncoming = protocol;
+ }
+
+ // Preselect the default protocol type.
+ let selected = document.getElementById(
+ `resultSelect-${config.incoming.type}`
+ );
+ selected.closest(".content-blocking-category").classList.add("selected");
+ selected.checked = true;
+
+ // Update the results area title to match the protocols choice.
+ document.l10n.setAttributes(
+ document.getElementById("resultAreaTitle"),
+ "account-setup-results-area-title",
+ {
+ count: protocols.length,
+ }
+ );
+
+ // Ensure by default the "Done" button is enabled.
+ document.getElementById("createButton").disabled = false;
+
+ // Thunderbird can't handle Exchange server independently, therefore we
+ // need to prompt the user with the installation of the Owl add-on.
+ if (config.incoming.type == "exchange") {
+ let addonsInstallRows = document.getElementById("resultAddonInstallRows");
+
+ // Remove any pre-existing child element.
+ while (addonsInstallRows.hasChildNodes()) {
+ addonsInstallRows.lastChild.remove();
+ }
+
+ let container = document.getElementById("resultExchangeHostname");
+ _makeHostDisplayString(config.incoming, container);
+ document
+ .getElementById("incomingTitle-exchange")
+ .appendChild(_socketTypeSpan(config.incoming.socketType));
+
+ (async () => {
+ try {
+ for (let addon of config.addons) {
+ let installer = new AddonInstaller(addon);
+ addon.isInstalled = await installer.isInstalled();
+ addon.isDisabled = await installer.isDisabled();
+ }
+
+ let addonInfoArea = document.getElementById("installAddonInfo");
+ let installedAddon = config.addons.find(addon => addon.isInstalled);
+
+ // The needed add-on is already installed, no need to show anything.
+ if (installedAddon) {
+ config.incoming.addonAccountType =
+ installedAddon.useType.addonAccountType;
+ addonInfoArea.hidden = true;
+ return;
+ }
+ // Disable "Done" until add-on is installed, or some other protocol
+ // is selected.
+ document.getElementById("createButton").disabled = true;
+
+ addonInfoArea.hidden = false;
+
+ document.l10n.setAttributes(
+ document.getElementById("resultAddonIntro"),
+ !config.incomingAlternatives.find(alt =>
+ ["imap", "pop3"].includes(alt.type)
+ )
+ ? "account-setup-addon-install-intro"
+ : "account-setup-addon-no-protocol"
+ );
+
+ for (let addon of config.addons) {
+ // Creates and addon install section.
+ // <div><img/><a></a><button></button></div>
+ let container = document.createElement("div");
+ container.classList.add("addon-container");
+
+ let img = document.createElement("img");
+ img.alt = "";
+ img.classList.add("icon");
+ if (addon.icon32) {
+ img.setAttribute("src", addon.icon32);
+ }
+
+ let link = document.createElement("a");
+ link.classList.add("link");
+ link.setAttribute("href", addon.websiteURL);
+ link.textContent = addon.description;
+
+ let button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ "account-setup-addon-install-title"
+ );
+ button.addEventListener("click", () => {
+ gAccountSetup.addonInstall(addon);
+ });
+ if (addon.isDisabled) {
+ // If the add on is disabled by user, or due to incompatibility
+ // - disable install (it won't help, it's already installed)
+ // - link to the addons manager instead (so they can fix it)
+ button.disabled = true;
+ link.setAttribute("href", "about:addons");
+ link.setAttribute("target", "_blank");
+
+ // Trigger an add-on update check. If an update is available,
+ // enable the install button to (re)install.
+ AddonManager.getAddonByID(addon.id).then(a => {
+ if (!a) {
+ return;
+ }
+ let listener = {
+ onUpdateAvailable(addon, install) {
+ button.disabled = false;
+ },
+ onNoUpdateAvailable() {},
+ };
+ a.findUpdates(
+ listener,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ });
+ }
+
+ container.appendChild(img);
+ container.appendChild(link);
+ container.appendChild(button);
+
+ addonsInstallRows.appendChild(container);
+ }
+ } catch (e) {
+ this.showErrorNotification(e, true);
+ }
+ })();
+ return;
+ }
+
+ function _makeHostDisplayString(server, container) {
+ // Clean up any existing element.
+ while (container.hasChildNodes()) {
+ container.lastChild.remove();
+ }
+
+ let cert = container.parentNode.querySelector(".cert-status");
+ if (cert != null) {
+ cert.remove();
+ }
+
+ let domain = server.hostname;
+ try {
+ domain = Services.eTLD.getBaseDomainFromHost(server.hostname);
+ } catch (ex) {
+ gAccountSetupLogger.warn(ex);
+ }
+
+ let hostSpan = document.createElement("span");
+ hostSpan.classList.add("host-without-domain");
+ hostSpan.textContent = server.hostname.substr(
+ 0,
+ server.hostname.length - domain.length
+ );
+ container.appendChild(hostSpan);
+
+ let domainSpan = document.createElement("span");
+ domainSpan.classList.add("domain");
+ domainSpan.textContent = domain;
+ container.appendChild(domainSpan);
+
+ if (!gAllStandardPorts.includes(server.port)) {
+ let portSpan = document.createElement("span");
+ portSpan.classList.add("port");
+ portSpan.textContent = `:${server.port}`;
+ container.appendChild(portSpan);
+ }
+
+ if (server.badCert) {
+ container.parentNode
+ .querySelector(".cert-status")
+ .classList.add("insecure");
+ }
+ }
+
+ /**
+ * Helper method to create the span protocol element.
+ *
+ * @returns {HTMLElement} - The newly created span label.
+ */
+ function _protocolTypeSpan() {
+ let span = document.createElement("span");
+ span.classList.add("protocol-type", "config-type");
+ return span;
+ }
+
+ /**
+ * Helper method to create the span socket element.
+ *
+ * @param {nsMsgSocketType} socket - The value representing the server
+ * socket type.
+ * @returns {HTMLElement} - The newly created span label.
+ */
+ function _socketTypeSpan(socket) {
+ let ssl = Sanitizer.translate(socket, {
+ 0: "no-encryption",
+ 2: "starttls",
+ 3: "ssl",
+ });
+ let span = _protocolTypeSpan();
+ document.l10n.setAttributes(span, `account-setup-result-${ssl}`);
+ span.classList.add("ssl");
+ if (socket != 2 && socket != 3) {
+ // Not an SSL or STARTTLS socket.
+ span.classList.add("insecure");
+ }
+ return span;
+ }
+
+ let protocolType = config.incoming.type;
+ if (configFilledIn.incoming.hostname) {
+ _makeHostDisplayString(
+ configFilledIn.incoming,
+ document.getElementById(`incomingInfo-${protocolType}`)
+ );
+
+ let container = document.getElementById(`incomingTitle-${protocolType}`);
+
+ // No need to show the protocol type if it's exchange, and the socket span
+ // is generated somewhere else specifically for exchange.
+ if (protocolType != "exchange") {
+ let span = _protocolTypeSpan();
+ span.textContent = configFilledIn.incoming.type;
+ container.appendChild(span);
+ container.appendChild(_socketTypeSpan(config.incoming.socketType));
+ }
+ }
+
+ let outgoingInfo = document.getElementById(`outgoingInfo-${protocolType}`);
+ if (!config.outgoing.existingServerKey) {
+ if (configFilledIn.outgoing.hostname) {
+ _makeHostDisplayString(configFilledIn.outgoing, outgoingInfo);
+ }
+ let container = document.getElementById(`outgoingTitle-${protocolType}`);
+ // No need to show the protocol type if it's exchange, and the socket span
+ // is generated somewhere else specifically for exchange.
+ if (protocolType != "exchange") {
+ let span = _protocolTypeSpan();
+ span.textContent = configFilledIn.outgoing.type;
+ container.appendChild(span);
+ container.appendChild(_socketTypeSpan(config.outgoing.socketType));
+ }
+ } else {
+ let span = document.createElement("span");
+ document.l10n.setAttributes(
+ span,
+ "account-setup-result-outgoing-existing"
+ );
+ outgoingInfo.appendChild(span);
+ }
+
+ let usernameInfo = document.getElementById(
+ `usernameInfo-${config.incoming.type}`
+ );
+ if (configFilledIn.incoming.username == configFilledIn.outgoing.username) {
+ usernameInfo.textContent = configFilledIn.incoming.username;
+ } else {
+ document.l10n.setAttributes(
+ usernameInfo,
+ "account-setup-result-username-different",
+ {
+ incoming: configFilledIn.incoming.username,
+ outgoing: configFilledIn.outgoing.username,
+ }
+ );
+ }
+ },
+
+ /**
+ * Handle the user switching between IMAP and POP3 settings using the
+ * radio buttons.
+ */
+ onResultServerTypeChanged() {
+ let config = this._currentConfig;
+ // Add current server as best alternative to start of array.
+ config.incomingAlternatives.unshift(config.incoming);
+
+ // Clear the visually selected radio container.
+ document
+ .querySelector(".content-blocking-category.selected")
+ .classList.remove("selected");
+
+ // Use selected server (stored as special property on the <input> node).
+ let selected = document.querySelector(
+ 'input[name="resultsServerType"]:checked'
+ );
+ selected.closest(".content-blocking-category").classList.add("selected");
+ config.incoming = selected.configIncoming;
+
+ // Remove newly selected server from list of alternatives.
+ config.incomingAlternatives = config.incomingAlternatives.filter(
+ alt => alt != config.incoming
+ );
+ this.displayConfigResult(config);
+ },
+
+ /**
+ * Install the addon
+ * Called when user clicks [Install] button.
+ *
+ * @param {AddonInfo} addon - @see AccountConfig.addons
+ */
+ async addonInstall(addon) {
+ let addonInfoArea = document.getElementById("installAddonInfo");
+ let createButton = document.getElementById("createButton");
+ addonInfoArea.hidden = true;
+ createButton.disabled = true;
+
+ this.clearNotifications();
+ await this.startLoadingState("account-setup-installing-addon");
+
+ try {
+ let installer = (this._abortable = new AddonInstaller(addon));
+ await installer.install();
+
+ this._abortable = null;
+ this.stopLoadingState("account-setup-success-addon");
+ createButton.disabled = false;
+
+ this._currentConfig.incoming.addonAccountType =
+ addon.useType.addonAccountType;
+ // Remove the note about having to install an add-on.
+ let rows = document.getElementById("resultAddonInstallRows");
+ while (rows.lastChild) {
+ rows.lastChild.remove();
+ }
+ } catch (e) {
+ console.error(e);
+ this.showErrorNotification(e, true);
+ addonInfoArea.hidden = false;
+ }
+ },
+
+ // ----------------
+ // Manual Edit area
+
+ /**
+ * Gets the values from the user in the manual edit area. Realname and
+ * password are not part of that area and still placeholders, but hostname and
+ * username are concrete and no placeholders anymore.
+ */
+ getUserConfig() {
+ let config = this.getConcreteConfig() || new AccountConfig();
+ config.source = AccountConfig.kSourceUser;
+
+ // Incoming server
+ try {
+ let inHostnameField = document.getElementById("incomingHostname");
+ config.incoming.hostname = Sanitizer.hostname(inHostnameField.value);
+ inHostnameField.value = config.incoming.hostname;
+ } catch (e) {
+ gAccountSetupLogger.warn(e);
+ }
+
+ try {
+ config.incoming.port = Sanitizer.integerRange(
+ document.getElementById("incomingPort").value,
+ 1,
+ 65535
+ );
+ } catch (e) {
+ config.incoming.port = undefined; // incl. default "Auto"
+ }
+
+ config.incoming.type = Sanitizer.translate(
+ document.getElementById("incomingProtocol").value,
+ {
+ 1: "imap",
+ 2: "pop3",
+ 3: "exchange",
+ 0: null,
+ }
+ );
+ config.incoming.socketType = Sanitizer.integer(
+ document.getElementById("incomingSsl").value
+ );
+ config.incoming.auth = Sanitizer.integer(
+ document.getElementById("incomingAuthMethod").value
+ );
+ config.incoming.username =
+ document.getElementById("incomingUsername").value;
+
+ // Outgoing server
+
+ config.outgoing.username =
+ document.getElementById("outgoingUsername").value;
+
+ // The user specified a custom SMTP server.
+ config.outgoing.existingServerKey = null;
+ config.outgoing.addThisServer = true;
+ config.outgoing.useGlobalPreferredServer = false;
+
+ try {
+ let input = document.getElementById("outgoingHostname");
+ config.outgoing.hostname = Sanitizer.hostname(input.value);
+ input.value = config.outgoing.hostname;
+ } catch (e) {
+ gAccountSetupLogger.warn(e);
+ }
+
+ try {
+ config.outgoing.port = Sanitizer.integerRange(
+ document.getElementById("outgoingPort").value,
+ 1,
+ 65535
+ );
+ } catch (e) {
+ config.outgoing.port = undefined; // incl. default "Auto"
+ }
+
+ config.outgoing.socketType = Sanitizer.integer(
+ document.getElementById("outgoingSsl").value
+ );
+ config.outgoing.auth = Sanitizer.integer(
+ document.getElementById("outgoingAuthMethod").value
+ );
+
+ return config;
+ },
+
+ /**
+ * [Manual Config] button click handler. This turns the config details area
+ * into an editable form and makes the (Go) button appear. The edit button
+ * should only be available after the config probing is completely finished,
+ * replacing what was the (Stop) button.
+ */
+ onManualEdit() {
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.editConfigDetails();
+ this.showHelperImage("step3");
+ },
+
+ /**
+ * Setting the config details form so it can be edited. We also disable
+ * (and hide) the create button during this time because we don't know what
+ * might have changed. The function called from the button that restarts
+ * the config check should be enabling the config button as needed.
+ */
+ editConfigDetails() {
+ gAccountSetupLogger.debug("manual edit");
+
+ if (!this._currentConfig) {
+ this._currentConfig = new AccountConfig();
+ this._currentConfig.incoming.type = "imap";
+ this._currentConfig.incoming.username = "%EMAILADDRESS%";
+ this._currentConfig.outgoing.username = "%EMAILADDRESS%";
+ this._currentConfig.incoming.hostname = ".%EMAILDOMAIN%";
+ this._currentConfig.outgoing.hostname = ".%EMAILDOMAIN%";
+ }
+ // Although we go manual, and we need to display the concrete username,
+ // however the realname and password is not part of manual config and
+ // must stay a placeholder in _currentConfig. @see getUserConfig()
+
+ this._fillManualEditFields(this.getConcreteConfig());
+
+ // _fillManualEditFields() indirectly calls validateManualEditComplete(),
+ // but it's important to not forget it in case the code is rewritten,
+ // so calling it explicitly again. Doesn't do harm, speed is irrelevant.
+ this.validateManualEditComplete();
+ },
+
+ /**
+ * Fills the manual edit textfields with the provided config.
+ *
+ * @param {AccountConfig} config - The config to present to the user.
+ */
+ _fillManualEditFields(config) {
+ assert(config instanceof AccountConfig);
+
+ let isExchange = config.incoming.type == "exchange";
+
+ // Incoming server.
+ document.getElementById("incomingProtocolExchange").hidden = !isExchange;
+ document.getElementById("incomingProtocol").value = Sanitizer.translate(
+ config.incoming.type,
+ { imap: 1, pop3: 2, exchange: 3 },
+ 1
+ );
+ document.getElementById("incomingHostname").value =
+ config.incoming.hostname;
+ document.getElementById("incomingSsl").value = Sanitizer.enum(
+ config.incoming.socketType,
+ [0, 1, 2, 3],
+ 0
+ );
+ document.getElementById("incomingAuthMethod").value = Sanitizer.enum(
+ config.incoming.auth,
+ [0, 3, 4, 5, 6, 10],
+ 0
+ );
+ document.getElementById("incomingUsername").value =
+ config.incoming.username;
+
+ // If a port number was specified other than "Auto"
+ if (config.incoming.port) {
+ document.getElementById("incomingPort").value = config.incoming.port;
+ } else {
+ this.adjustIncomingPortToSSLAndProtocol(config);
+ }
+
+ // Outgoing server.
+
+ document.getElementById("outgoingHostname").value =
+ config.outgoing.hostname;
+ document.getElementById("outgoingUsername").value =
+ config.outgoing.username;
+
+ // While sameInOutUsernames is true we synchronize values of incoming
+ // and outgoing username.
+ this.sameInOutUsernames = true;
+ document.getElementById("outgoingSsl").value = Sanitizer.enum(
+ config.outgoing.socketType,
+ [0, 1, 2, 3],
+ 0
+ );
+ document.getElementById("outgoingAuthMethod").value = Sanitizer.enum(
+ config.outgoing.auth,
+ [0, 1, 3, 4, 5, 6, 10],
+ 0
+ );
+
+ // If a port number was specified other than "Auto"
+ if (config.outgoing.port) {
+ document.getElementById("outgoingPort").value = config.outgoing.port;
+ } else {
+ this.adjustOutgoingPortToSSLAndProtocol(config);
+ }
+
+ this.adjustOAuth2Visibility(config);
+ },
+
+ /**
+ * Make OAuth2 visible as an authentication method when a hostname that
+ * OAuth2 can be used with is entered.
+ *
+ * @param {AccountConfig} config - The account configuration.
+ */
+ async adjustOAuth2Visibility(config) {
+ // If the incoming server hostname supports OAuth2, enable it.
+ let iDetails = OAuth2Providers.getHostnameDetails(config.incoming.hostname);
+ document.getElementById("in-authMethod-oauth2").hidden = !iDetails;
+ if (iDetails) {
+ gAccountSetupLogger.debug(
+ `OAuth2 details for incoming server ${config.incoming.hostname} is ${iDetails}`
+ );
+ config.incoming.oauthSettings = {};
+ [
+ config.incoming.oauthSettings.issuer,
+ config.incoming.oauthSettings.scope,
+ ] = iDetails;
+ this._currentConfig.incoming.oauthSettings =
+ config.incoming.oauthSettings;
+ }
+
+ // If the smtp hostname supports OAuth2, enable it.
+ let oDetails = OAuth2Providers.getHostnameDetails(config.outgoing.hostname);
+ document.getElementById("out-authMethod-oauth2").hidden = !oDetails;
+ if (oDetails) {
+ gAccountSetupLogger.debug(
+ `OAuth2 details for outgoing server ${config.outgoing.hostname} is ${oDetails}`
+ );
+ config.outgoing.oauthSettings = {};
+ [
+ config.outgoing.oauthSettings.issuer,
+ config.outgoing.oauthSettings.scope,
+ ] = oDetails;
+ this._currentConfig.outgoing.oauthSettings =
+ config.outgoing.oauthSettings;
+ }
+ },
+
+ /**
+ * Automatically fill port field in manual edit, unless the user entered a
+ * non-standard port.
+ *
+ * @param {AccountConfig} config - The account configuration.
+ */
+ async adjustIncomingPortToSSLAndProtocol(config) {
+ let incoming = config.incoming;
+
+ // Bail out if a port number is already defined and it's not part of the
+ // known ports array.
+ if (!gAllStandardPorts.includes(incoming.port)) {
+ return;
+ }
+
+ let input = document.getElementById("incomingPort");
+
+ switch (incoming.type) {
+ case "imap":
+ input.value = incoming.socketType == Ci.nsMsgSocketType.SSL ? 993 : 143;
+ break;
+
+ case "pop3":
+ input.value = incoming.socketType == Ci.nsMsgSocketType.SSL ? 995 : 110;
+ break;
+
+ case "exchange":
+ input.value = 443;
+ break;
+ }
+ },
+
+ /**
+ * Automatically fill port field in manual edit, unless the user entered a
+ * non-standard port.
+ *
+ * @param {AccountConfig} config - The account configuration.
+ */
+ async adjustOutgoingPortToSSLAndProtocol(config) {
+ let outgoing = config.outgoing;
+
+ // Bail out if a port number is already defined and it's not part of the
+ // known ports array.
+ if (!gAllStandardPorts.includes(outgoing.port)) {
+ return;
+ }
+
+ // Implicit TLS for SMTP is on port 465.
+ if (outgoing.socketType == Ci.nsMsgSocketType.SSL) {
+ document.getElementById("outgoingPort").value = 465;
+ return;
+ }
+
+ // Implicit TLS for SMTP is on port 465. STARTTLS won't work there.
+ if (
+ outgoing.port == 465 &&
+ outgoing.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS
+ ) {
+ document.getElementById("outgoingPort").value = 587;
+ }
+ },
+
+ /**
+ * If the user changed the port manually, adjust the SSL value,
+ * (only) if the new port is impossible with the old SSL value.
+ *
+ * @param config {AccountConfig}
+ */
+ adjustIncomingSSLToPort(config) {
+ let incoming = config.incoming;
+ if (!gAllStandardPorts.includes(incoming.port)) {
+ return;
+ }
+
+ if (incoming.type == "imap") {
+ // Implicit TLS for IMAP is on port 993.
+ if (
+ incoming.port == 993 &&
+ incoming.socketType != Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value = Ci.nsMsgSocketType.SSL;
+ return;
+ }
+ if (
+ incoming.port == 143 &&
+ incoming.socketType == Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value =
+ Ci.nsMsgSocketType.alwaysSTARTTLS;
+ return;
+ }
+ }
+
+ if (incoming.type == "pop3") {
+ // Implicit TLS for POP3 is on port 995.
+ if (
+ incoming.port == 995 &&
+ incoming.socketType != Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value = Ci.nsMsgSocketType.SSL;
+ return;
+ }
+ if (
+ incoming.port == 110 &&
+ incoming.socketType == Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("incomingSsl").value =
+ Ci.nsMsgSocketType.alwaysSTARTTLS;
+ }
+ }
+ },
+
+ /**
+ * @see adjustIncomingSSLToPort()
+ */
+ adjustOutgoingSSLToPort(config) {
+ let outgoing = config.outgoing;
+ if (!gAllStandardPorts.includes(outgoing.port)) {
+ return;
+ }
+
+ // Implicit TLS for SMTP is on port 465.
+ if (outgoing.port == 465 && outgoing.socketType != Ci.nsMsgSocketType.SSL) {
+ document.getElementById("outgoingSsl").value = Ci.nsMsgSocketType.SSL;
+ return;
+ }
+
+ // Port 587 and port 25 are for plain or STARTTLS. Not for Implicit TLS.
+ if (
+ (outgoing.port == 587 || outgoing.port == 25) &&
+ outgoing.socketType == Ci.nsMsgSocketType.SSL
+ ) {
+ document.getElementById("outgoingSsl").value =
+ Ci.nsMsgSocketType.alwaysSTARTTLS;
+ }
+ },
+
+ onChangedProtocolIncoming() {
+ let config = this.getUserConfig();
+ this.adjustIncomingPortToSSLAndProtocol(config);
+ this.onChangedManualEdit();
+ },
+
+ onChangedPortIncoming() {
+ gAccountSetupLogger.debug(
+ "incoming port changed: " + document.getElementById("incomingPort").value
+ );
+ this.adjustIncomingSSLToPort(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedPortOutgoing() {
+ gAccountSetupLogger.debug(
+ "outgoing port changed: " + document.getElementById("outgoingPort").value
+ );
+ this.adjustOutgoingSSLToPort(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedSSLIncoming() {
+ this.adjustIncomingPortToSSLAndProtocol(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedSSLOutgoing() {
+ this.adjustOutgoingPortToSSLAndProtocol(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ onChangedInAuth() {
+ this.onChangedManualEdit();
+ },
+
+ onChangedOutAuth(event) {
+ // Disable the outgoing username field if the "No Authentication" option is
+ // selected.
+ document.getElementById("outgoingUsername").disabled =
+ event.target.selectedOptions[0].id == "outNoAuth";
+ this.onChangedManualEdit();
+ },
+
+ onInputInUsername() {
+ if (this.sameInOutUsernames) {
+ document.getElementById("outgoingUsername").value =
+ document.getElementById("incomingUsername").value;
+ }
+ this.onChangedManualEdit();
+ },
+
+ onInputOutUsername() {
+ this.sameInOutUsernames = false;
+ this.onChangedManualEdit();
+ },
+
+ onChangeHostname() {
+ this.adjustOAuth2Visibility(this.getUserConfig());
+ this.onChangedManualEdit();
+ },
+
+ /**
+ * A value in the manual configuration area was changed.
+ */
+ onChangedManualEdit() {
+ // If there's a current operation in progress and is abortable.
+ if (this._abortable) {
+ this.onStop();
+ }
+ this.validateManualEditComplete();
+ },
+
+ /**
+ * The user interacted with an input field in the manual configuration area
+ * therefore we need to clear previous notifications and disable the "Done"
+ * button as the current config is not valid until we run again the
+ * validateManualEditComplete() method, which happens on input blur.
+ */
+ manualConfigChanged() {
+ this.clearNotifications();
+ document.getElementById("createButton").disabled = true;
+ },
+
+ /**
+ * This enables the buttons which allow the user to proceed
+ * once he has entered enough information.
+ *
+ * We can easily and fairly surely autodetect everything apart from the
+ * hostname (and username). So, once the user has entered
+ * proper hostnames, change to "manual-edit-have-hostname" mode
+ * which allows to press [Re-test], which starts the detection
+ * of the other values.
+ * Once the user has entered (or we detected) all values, he may
+ * do [Create Account] (tests login and if successful creates the account)
+ * or [Advanced Setup] (goes to Account Manager). Esp. in the latter case,
+ * we will not second-guess his setup and just to as told, so here we make
+ * sure that he at least entered all values.
+ */
+ validateManualEditComplete() {
+ // getUserConfig() is expensive, but still OK, not a problem.
+ let manualConfig = this.getUserConfig();
+ this._currentConfig = manualConfig;
+
+ if (manualConfig.isComplete()) {
+ this.switchToMode("manual-edit-complete");
+ return;
+ }
+
+ if (!!manualConfig.incoming.hostname && !!manualConfig.outgoing.hostname) {
+ this.switchToMode("manual-edit-have-hostname");
+ return;
+ }
+
+ this.switchToMode("manual-edit");
+ },
+
+ /**
+ * [Advanced Setup...] button click handler
+ * Only active in manual edit mode, and goes straight into
+ * Account Settings (pref UI) dialog. Requires a backend account,
+ * which requires proper hostname, port and protocol.
+ */
+ async onAdvancedSetup() {
+ assert(this._currentConfig instanceof AccountConfig);
+ let configFilledIn = this.getConcreteConfig();
+
+ if (CreateInBackend.checkIncomingServerAlreadyExists(configFilledIn)) {
+ let [title, description] = await document.l10n.formatValues([
+ "account-setup-creation-error-title",
+ "account-setup-error-server-exists",
+ ]);
+ Services.prompt.alert(null, title, description);
+ return;
+ }
+
+ let [title, description] = await document.l10n.formatValues([
+ "account-setup-confirm-advanced-title",
+ "account-setup-confirm-advanced-description",
+ ]);
+
+ if (!Services.prompt.confirm(null, title, description)) {
+ return;
+ }
+
+ gAccountSetupLogger.debug("creating account in backend");
+ let newAccount = CreateInBackend.createAccountInBackend(configFilledIn);
+
+ window.close();
+ gMainWindow.postMessage("account-created-in-backend", "*");
+ MsgAccountManager("am-server.xhtml", newAccount.incomingServer);
+ },
+
+ /**
+ * [Re-test] button click handler.
+ * Restarts the config guessing process after a person editing the server
+ * fields.
+ * It's called "half-manual", because we take the user-entered values
+ * as given and will not second-guess them, to respect the user wishes.
+ * (Yes, Sir! Will do as told!)
+ * The values that the user left empty or on "Auto" will be guessed/probed
+ * here. We will also check that the user-provided values work.
+ */
+ async testManualConfig() {
+ this.clearNotifications();
+ await this.startLoadingState(
+ "account-setup-looking-up-settings-half-manual"
+ );
+
+ let newConfig = this.getUserConfig();
+ gAccountSetupLogger.debug("manual config to test:\n" + newConfig);
+
+ this.switchToMode("manual-edit-testing");
+ // if (this._userPickedOutgoingServer) TODO
+ let self = this;
+ this._abortable = GuessConfig.guessConfig(
+ this._domain,
+ function (type, hostname, port, ssl, done, config) {
+ // Progress.
+ gAccountSetupLogger.debug(
+ `progress callback host: ${hostname}, port: ${port}, type: ${type}`
+ );
+ },
+ function (config) {
+ // Success.
+ self._abortable = null;
+ self._fillManualEditFields(config);
+ self.stopLoadingState("account-setup-success-half-manual");
+ self.validateManualEditComplete();
+ },
+ function (e, config) {
+ // guessConfig failed.
+ if (e instanceof CancelledException) {
+ return;
+ }
+ self._abortable = null;
+ gAccountSetupLogger.warn(`guessConfig failed: ${e}`);
+ self.showErrorNotification("account-setup-find-settings-failed");
+ self.switchToMode("manual-edit-have-hostname");
+ },
+ newConfig,
+ newConfig.outgoing.existingServerKey ? "incoming" : "both"
+ );
+ },
+
+ // -------------------
+ // UI helper functions
+
+ _prefillConfig(initialConfig) {
+ let emailsplit = this._email.split("@");
+ assert(emailsplit.length > 1);
+ let emaillocal = Sanitizer.nonemptystring(emailsplit[0]);
+ initialConfig.incoming.username = emaillocal;
+ initialConfig.outgoing.username = emaillocal;
+ return initialConfig;
+ },
+
+ clearError(which) {
+ document.getElementById(`${which}Warning`).hidden = true;
+ document.getElementById(`${which}Info`).hidden = false;
+ },
+
+ setError(which, msg_name) {
+ try {
+ document.getElementById(`${which}Info`).hidden = true;
+ document.getElementById(`${which}Warning`).hidden = false;
+ } catch (ex) {
+ alertPrompt("Missing error string", msg_name);
+ }
+ },
+
+ onFormSubmit(event) {
+ // Prevent the actual form submission.
+ event.preventDefault();
+
+ // Select the only primary button that is visible and enabled.
+ let currentButton = document.querySelector(
+ ".buttons-container-last button.primary:not([disabled],[hidden])"
+ );
+ if (currentButton) {
+ currentButton.click();
+ }
+ },
+
+ // -------------------------------
+ // Finish & dialog close functions
+
+ onCancel() {
+ // Some tests might close the account setup before it finishes loading,
+ // therefore the gMainWindow might still be null. If that's the case, do an
+ // early return since we don't need to run any condition.
+ if (!gMainWindow) {
+ window.close();
+ return;
+ }
+
+ // Ask for confirmation if the user never set Thunderbrid to be used without
+ // an email account, and no account has been configured.
+ if (
+ !Services.prefs.getBoolPref("app.use_without_mail_account", false) &&
+ !MailServices.accounts.accounts.length
+ ) {
+ // Abort any possible process before showing the confirmation dialog.
+ this.checkIfAbortable();
+ this.confirmExitDialog();
+ return;
+ }
+
+ window.close();
+ },
+
+ /**
+ * Ask for confirmation when the account setup is dismissed and the user
+ * doesn't have any configured account.
+ */
+ confirmExitDialog() {
+ let dialog = document.getElementById("confirmExitDialog");
+
+ document.getElementById("exitDialogConfirmButton").onclick = () => {
+ // Update the pref only if the checkbox was checked since it's FALSE by
+ // default. We won't expose this checkbox in the UI anymore afterward.
+ if (document.getElementById("useWithoutAccount").checked) {
+ Services.prefs.setBoolPref("app.use_without_mail_account", true);
+ }
+
+ dialog.close();
+ window.close();
+ };
+
+ document.getElementById("exitDialogCancelButton").onclick = () => {
+ dialog.close();
+ };
+
+ dialog.showModal();
+ },
+
+ /**
+ * Disable the exit dialog button if the user checks the "Use without an email
+ * account" checkbox.
+ *
+ * @param {DOMEvent} event - The checkbox change event.
+ */
+ toggleExitDialogButton(event) {
+ document.getElementById("exitDialogCancelButton").disabled =
+ event.target.checked;
+ },
+
+ checkIfAbortable() {
+ if (this._abortable) {
+ this._abortable.cancel(new UserCancelledException());
+ }
+ },
+
+ onUnload() {
+ gMainWindow.document
+ .getElementById("tabmail")
+ .unregisterTabMonitor(this.tabMonitor);
+ this.checkIfAbortable();
+ gAccountSetupLogger.debug("Shutting down email config dialog");
+ },
+
+ async onCreate() {
+ gAccountSetupLogger.debug("Create button clicked");
+
+ let configFilledIn = this.getConcreteConfig();
+ let self = this;
+ // If the dialog is not needed, it will go straight to OK callback
+ gSecurityWarningDialog.open(
+ this._currentConfig,
+ configFilledIn,
+ true,
+ async function () {
+ // on OK
+ await self.validateAndFinish(configFilledIn).catch(async ex => {
+ let errorMessage = await document.l10n.formatValue(
+ "account-setup-creation-error-title"
+ );
+ gAccountSetupLogger.error(errorMessage + ". " + ex);
+
+ self.clearNotifications();
+ let notification = self.notificationBox.appendNotification(
+ "accountSetupError",
+ {
+ label: errorMessage,
+ priority: self.notificationBox.PRIORITY_CRITICAL_HIGH,
+ },
+ null
+ );
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+ });
+ },
+ function () {
+ // on cancel, do nothing
+ }
+ );
+ },
+
+ // called by onCreate()
+ async validateAndFinish(configFilled) {
+ let configFilledIn = configFilled || this.getConcreteConfig();
+ if (
+ configFilledIn.incoming.type == "exchange" &&
+ "addonAccountType" in configFilledIn.incoming
+ ) {
+ configFilledIn.incoming.type = configFilledIn.incoming.addonAccountType;
+ }
+
+ if (CreateInBackend.checkIncomingServerAlreadyExists(configFilledIn)) {
+ let [title, description] = await document.l10n.formatValues([
+ "account-setup-creation-error-title",
+ "account-setup-error-server-exists",
+ ]);
+ Services.prompt.alert(null, title, description);
+ return;
+ }
+
+ if (configFilledIn.outgoing.addThisServer) {
+ let existingServer =
+ CreateInBackend.checkOutgoingServerAlreadyExists(configFilledIn);
+ if (existingServer) {
+ configFilledIn.outgoing.addThisServer = false;
+ configFilledIn.outgoing.existingServerKey = existingServer.key;
+ }
+ }
+
+ let createButton = document.getElementById("createButton");
+ let reTestButton = document.getElementById("reTestButton");
+ createButton.disabled = true;
+ reTestButton.disabled = true;
+
+ this.clearNotifications();
+ this.startLoadingState("account-setup-checking-password");
+ let telemetryKey =
+ this._currentConfig.source == AccountConfig.kSourceXML ||
+ this._currentConfig.source == AccountConfig.kSourceExchange
+ ? this._currentConfig.subSource
+ : this._currentConfig.source;
+
+ let self = this;
+ let verifier = new ConfigVerifier(this._msgWindow);
+ window.addEventListener("unload", event => {
+ verifier.cleanup();
+ });
+ verifier
+ .verifyConfig(
+ configFilledIn,
+ // guess login config?
+ configFilledIn.source != AccountConfig.kSourceXML
+ // TODO Instead, the following line would be correct, but I cannot use it,
+ // because some other code doesn't adhere to the expectations/specs.
+ // Find out what it was and fix it.
+ // concreteConfig.source == AccountConfig.kSourceGuess,
+ )
+ .then(successfulConfig => {
+ // success
+ self.stopLoadingState(
+ successfulConfig.incoming.password
+ ? "account-setup-success-password"
+ : null
+ );
+
+ // The auth might have changed, so we should update the current config.
+ self._currentConfig.incoming.auth = successfulConfig.incoming.auth;
+ self._currentConfig.outgoing.auth = successfulConfig.outgoing.auth;
+ self._currentConfig.incoming.username =
+ successfulConfig.incoming.username;
+ self._currentConfig.outgoing.username =
+ successfulConfig.outgoing.username;
+
+ // We loaded dynamic client registration, fill this data back in to the
+ // config set.
+ if (successfulConfig.incoming.oauthSettings) {
+ self._currentConfig.incoming.oauthSettings =
+ successfulConfig.incoming.oauthSettings;
+ }
+ if (successfulConfig.outgoing.oauthSettings) {
+ self._currentConfig.outgoing.oauthSettings =
+ successfulConfig.outgoing.oauthSettings;
+ }
+ self.finish(configFilledIn);
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.successful_email_account_setup",
+ telemetryKey,
+ 1
+ );
+ })
+ .catch(e => {
+ // failed
+ // Could be a wrong password, but there are 1000 other
+ // reasons why this failed. Only the backend knows.
+ // If we got no message, then something other than VerifyLogon failed.
+
+ // For an Exchange server, some known configurations can
+ // be disabled (per user or domain or server).
+ // Warn the user if the open protocol we tried didn't work.
+ if (
+ ["imap", "pop3"].includes(configFilledIn.incoming.type) &&
+ configFilledIn.incomingAlternatives.some(i => i.type == "exchange")
+ ) {
+ self.showErrorNotification(
+ "account-setup-exchange-config-unverifiable"
+ );
+ } else {
+ let msg = e.message || e.toString();
+ self.showErrorNotification(msg, true);
+ }
+
+ // give user something to proceed after fixing
+ createButton.disabled = false;
+ // hidden in non-manual mode, so it's fine to enable
+ reTestButton.disabled = false;
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.failed_email_account_setup",
+ telemetryKey,
+ 1
+ );
+ });
+ },
+
+ /**
+ * @param {AccountConfig} concreteConfig - The config to use.
+ */
+ finish(concreteConfig) {
+ gAccountSetupLogger.debug("creating account in backend");
+ let newAccount = CreateInBackend.createAccountInBackend(concreteConfig);
+
+ // Trigger the first login to download the folder structure and messages.
+ newAccount.incomingServer.getNewMessages(
+ newAccount.incomingServer.rootFolder,
+ this._msgWindow,
+ null
+ );
+
+ if (this._okCallback) {
+ this._okCallback();
+ }
+
+ this.showSuccessView(newAccount);
+ },
+
+ /**
+ * Toggle the visibility of the list of available services to configure.
+ */
+ toggleSetupContainer(event) {
+ let container = event.target.closest(".linked-services-section");
+ container.classList.toggle("opened");
+ container
+ .querySelector(".linked-services-container")
+ .toggleAttribute("hidden");
+ },
+
+ /**
+ * Update the account setup tab to show a successful final view with quick
+ * links and suggested next steps.
+ *
+ * @param {nsIMsgAccount} account - The newly created account.
+ */
+ async showSuccessView(account) {
+ gAccountSetupLogger.debug("Account creation successful");
+
+ // Populate the account recap info.
+ document.getElementById("newAccountName").textContent = this._realname;
+ document.getElementById("newAccountEmail").textContent = this._email;
+ document.getElementById("newAccountProtocol").textContent =
+ account.incomingServer.type;
+
+ // Store the host domain that will be used to look for CardDAV and CalDAV
+ // services.
+ this._hostname = this._email.split("@")[1];
+
+ // Set up event listeners for the quick links.
+ document.getElementById("settingsButton").addEventListener(
+ "click",
+ () => {
+ MsgAccountManager(null, account.incomingServer);
+ },
+ { once: true }
+ );
+
+ // Hide the e2ee button if the current server doesn't support it.
+ let hasEncryption =
+ account.incomingServer.type != "rss" &&
+ account.incomingServer.type != "nntp" &&
+ account.incomingServer.protocolInfo?.canGetMessages;
+ document.getElementById("encryptionButton").hidden = !hasEncryption;
+ if (hasEncryption) {
+ document
+ .getElementById("encryptionButton")
+ .addEventListener("click", () => {
+ MsgAccountManager("am-e2e.xhtml", account.incomingServer);
+ });
+ }
+
+ document.getElementById("signatureButton").addEventListener("click", () => {
+ MsgAccountManager(null, account.incomingServer);
+ });
+
+ // Finally, show the success view.
+ this.switchToMode("success");
+
+ // Initialize the fetching of possible linked services like address books
+ // or calendars.
+ gAccountSetupLogger.debug("Fetching linked address books and calendars");
+
+ let notification = this.syncingBox.appendNotification(
+ "accountSetupLoading",
+ {
+ label: await document.l10n.formatValue(
+ "account-setup-looking-up-address-books"
+ ),
+ priority: this.syncingBox.PRIORITY_INFO_LOW,
+ },
+ null
+ );
+ notification.setAttribute("align", "center");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ // Detect linked address books.
+ await this.fetchAddressBooks();
+
+ // Update the notification and start detecting linked calendars.
+ document.l10n.setAttributes(
+ notification.messageText,
+ "account-setup-looking-up-calendars"
+ );
+ await this.fetchCalendars();
+
+ // Update the connected services description if we have at least one address
+ // book or one calendar we can connect to.
+ document.l10n.setAttributes(
+ document.getElementById("linkedServicesDescription"),
+ !this.addressBooks.length && !this.calendars.size
+ ? "account-setup-no-linked-description"
+ : "account-setup-linked-services-description"
+ );
+
+ // Clear the loading notification.
+ this.syncingBox.removeAllNotifications();
+ this.showHelperImage("step5");
+ },
+
+ /**
+ * Fetch any available CardDAV address books.
+ */
+ async fetchAddressBooks() {
+ this.addressBooks = [];
+ try {
+ this.addressBooks = await CardDAVUtils.detectAddressBooks(
+ this._email,
+ this._password,
+ `https://${this._hostname}`,
+ false
+ );
+ } catch (ex) {
+ gAccountSetupLogger.error(ex);
+ }
+
+ let hideAddressBookUI = !this.addressBooks.length;
+ document.getElementById("linkedAddressBooks").hidden = hideAddressBookUI;
+
+ // Clear the UI from any previous list.
+ let abList = document.querySelector(
+ "#addressBooksSetup .linked-services-list"
+ );
+ while (abList.hasChildNodes()) {
+ abList.lastChild.remove();
+ }
+
+ // Interrupt if we don't have anything to show.
+ if (hideAddressBookUI) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("addressBooksCountDescription"),
+ "account-setup-found-address-books-description",
+ { count: this.addressBooks.length }
+ );
+
+ // Collect existing carddav address books to compare with the list of
+ // recently fetched ones.
+ let existing = MailServices.ab.directories.map(d =>
+ d.getStringValue("carddav.url", "")
+ );
+
+ // Populate the list of available address books.
+ for (let book of this.addressBooks) {
+ let provider = document.createElement("span");
+ provider.classList.add("protocol-type");
+ provider.textContent = "CardDAV";
+
+ let name = document.createElement("span");
+ name.classList.add("list-item-name");
+ name.textContent = book.name;
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+
+ if (existing.includes(book.url.href)) {
+ // This address book aready exists for some reason, so disable the
+ // button and mark it as existing.
+ button.classList.add("existing", "small-button");
+ document.l10n.setAttributes(
+ button,
+ "account-setup-existing-address-book"
+ );
+ button.disabled = true;
+ } else {
+ button.classList.add("small-button");
+ document.l10n.setAttributes(button, "account-setup-connect-link");
+ button.addEventListener("click", () => {
+ this._setupAddressBook(button, book);
+ });
+ }
+
+ let row = document.createElement("li");
+ row.appendChild(provider);
+ row.appendChild(name);
+ row.appendChild(button);
+ abList.appendChild(row);
+ }
+
+ // Show a "connect all" button if we have more than one address book.
+ document.getElementById("addressBooksSetupAll").hidden =
+ this.addressBooks.length <= 1;
+ },
+
+ /**
+ * Connect to the selected address book.
+ *
+ * @param {HTMLElement} button - The clicked button in the list.
+ * @param {foundBook} book - The address book to configure.
+ */
+ _setupAddressBook(button, book) {
+ book.create();
+
+ // Update the button to reflect the creation of the new address book.
+ button.classList.add("existing");
+ document.l10n.setAttributes(button, "account-setup-existing-address-book");
+ button.disabled = true;
+
+ // Check if we have any address book left to set up and hide the
+ // "Connect all" button if not.
+ document.getElementById("addressBooksSetupAll").hidden =
+ !document.querySelectorAll(
+ "#addressBooksSetup .linked-services-list button:not(.existing)"
+ ).length;
+ },
+
+ /**
+ * Loop through all available address books found and click the connect
+ * button to trigger the method attached to the onclick listener.
+ */
+ setupAllAddressBooks() {
+ for (let button of document.querySelectorAll(
+ "#addressBooksSetup .linked-services-list button"
+ )) {
+ button.click();
+ }
+ },
+
+ /**
+ * Fetch any available CalDAV calendars.
+ */
+ async fetchCalendars() {
+ this.calendars = {};
+ try {
+ this.calendars = await cal.provider.detection.detect(
+ this._email,
+ this._password,
+ `https://${this._hostname}`,
+ document.getElementById("rememberPassword").checked,
+ [],
+ {}
+ );
+ } catch (ex) {
+ gAccountSetupLogger.error(ex);
+ }
+
+ let hideCalendarUI = !this.calendars.size;
+ document.getElementById("linkedCalendars").hidden = hideCalendarUI;
+
+ // Clear the UI from any previous list.
+ let calList = document.querySelector(
+ "#calendarsSetup .linked-services-list"
+ );
+ while (calList.hasChildNodes()) {
+ calList.lastChild.remove();
+ }
+
+ // Interrupt if we don't have anything to show.
+ if (hideCalendarUI) {
+ return;
+ }
+
+ // Collect existing calendars to compare with the list of recently fetched
+ // ones.
+ let existing = new Set(
+ cal.manager.getCalendars({}).map(calendar => calendar.uri.spec)
+ );
+
+ let calendarsCount = 0;
+
+ // Populate the list of available calendars.
+ for (let [provider, calendars] of this.calendars.entries()) {
+ for (let calendar of calendars) {
+ let cal_provider = document.createElement("span");
+ cal_provider.classList.add("protocol-type");
+ cal_provider.textContent = provider.shortName;
+
+ let cal_name = document.createElement("span");
+ cal_name.classList.add("list-item-name");
+ cal_name.textContent = calendar.name;
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+
+ if (existing.has(calendar.uri.spec)) {
+ // This calendar aready exists for some reason, so disable the button
+ // and mark it as existing.
+ button.classList.add("existing", "small-button");
+ document.l10n.setAttributes(
+ button,
+ "account-setup-existing-calendar"
+ );
+ button.disabled = true;
+ } else {
+ button.classList.add("small-button");
+ document.l10n.setAttributes(button, "account-setup-connect-link");
+ button.addEventListener("click", () => {
+ // If the button has a specific data attribute it means we want to
+ // set up the calendar directly without opening the dialog.
+ if (button.hasAttribute("data-setup-calendar")) {
+ this._setupCalendar(button, calendar);
+ return;
+ }
+
+ this._showCalendarDialog(button, calendar);
+ });
+ }
+
+ let row = document.createElement("li");
+ row.appendChild(cal_provider);
+ row.appendChild(cal_name);
+ row.appendChild(button);
+ calList.appendChild(row);
+
+ calendarsCount++;
+ }
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("calendarsCountDescription"),
+ "account-setup-found-calendars-description",
+ { count: calendarsCount }
+ );
+
+ // Show a "connect all" button if we have more than one calendar.
+ document.getElementById("calendarsSetupAll").hidden = calendarsCount <= 1;
+ },
+
+ /**
+ * Show the dialog to connect the selected calendar. This native HTML dialog
+ * is a streamlined version of the calendar-properties-dialog.xhtml. The two
+ * dialogs should kept in sync if a property of the calendar changes that
+ * requires updating any field.
+ *
+ * @param {HTMLElement} button - The clicked button in the list.
+ * @param {calICalendar} calendar - The calendar to configure.
+ */
+ _showCalendarDialog(button, calendar) {
+ let dialog = document.getElementById("calendarDialog");
+
+ // Update the calendar info in the dialog.
+ let nameInput = document.getElementById("calendarName");
+ nameInput.value = calendar.name;
+
+ // Some servers provide colors as an 8-character hex string, which the color
+ // picker can't handle. Strip the alpha component.
+ let color = calendar.getProperty("color");
+ let alpha = color?.match(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/);
+ if (alpha) {
+ calendar.setProperty("color", alpha[1]);
+ color = alpha[1];
+ }
+ let colorInput = document.getElementById("calendarColor");
+ colorInput.value = color || "#A8C2E1";
+
+ let readOnlyCheckbox = document.getElementById("calendarReadOnly");
+ readOnlyCheckbox.checked = calendar.readOnly;
+
+ // Hide the "Show reminders" checkbox if the calendar doesn't support it.
+ document.getElementById("calendarShowRemindersRow").hidden =
+ calendar.getProperty("capabilities.alarms.popup.supported") === false;
+ let remindersCheckbox = document.getElementById("calendarShowReminders");
+ remindersCheckbox.checked = !calendar.getProperty("suppressAlarms");
+
+ // Hide the "Offline support" if the calendar doesn't support it.
+ let offlineCheckbox = document.getElementById("calendarOfflineSupport");
+ let canCache = calendar.getProperty("cache.supported") !== false;
+ let alwaysCache = calendar.getProperty("cache.always");
+ if (!canCache || alwaysCache) {
+ offlineCheckbox.hidden = true;
+ offlineCheckbox.disabled = true;
+ }
+ offlineCheckbox.checked =
+ alwaysCache || (canCache && calendar.getProperty("cache.enabled"));
+
+ // Set up the "Refresh calendar" menulist.
+ let calendarRefresh = document.getElementById("calendarRefresh");
+ calendarRefresh.disabled = !calendar.canRefresh;
+ calendarRefresh.value = calendar.getProperty("refreshInterval") || 30;
+
+ // Set up the dialog's action buttons.
+ document.getElementById("calendarDialogConfirmButton").onclick = () => {
+ // Update the attributes of the calendar in case the user changed some
+ // values.
+ calendar.name = nameInput.value;
+ calendar.setProperty("color", colorInput.value);
+ if (calendar.canRefresh) {
+ calendar.setProperty("refreshInterval", calendarRefresh.value);
+ }
+
+ calendar.readOnly = readOnlyCheckbox.checked;
+ calendar.setProperty("suppressAlarms", !remindersCheckbox.checked);
+ if (!alwaysCache) {
+ calendar.setProperty("cache.enabled", offlineCheckbox.checked);
+ }
+
+ this._setupCalendar(button, calendar);
+ dialog.close();
+ };
+
+ document.getElementById("calendarDialogCancelButton").onclick = () => {
+ dialog.close();
+ };
+
+ dialog.showModal();
+ },
+
+ /**
+ * Connect to the selected calendar.
+ *
+ * @param {HTMLElement} button - The clicked button in the list.
+ * @param {calICalendar} calendar - The calendar to configure.
+ */
+ _setupCalendar(button, calendar) {
+ cal.manager.registerCalendar(calendar);
+
+ // Update the button to reflect the creation of the new calendar.
+ button.classList.add("existing");
+ document.l10n.setAttributes(button, "account-setup-existing-calendar");
+ button.disabled = true;
+
+ // Check if we have any calendar left to set up and hide the "Connect all"
+ // button if not.
+ document.getElementById("calendarsSetupAll").hidden =
+ !document.querySelectorAll(
+ "#calendarsSetup .linked-services-list button:not(.existing)"
+ ).length;
+ },
+
+ /**
+ * Loop through all available calendars found and click the connect
+ * button to trigger the method attached to the onclick listener.
+ */
+ setupAllCalendars() {
+ for (let button of document.querySelectorAll(
+ "#calendarsSetup .linked-services-list button:not(.existing)"
+ )) {
+ // Set the attribute to skip the opening of the properties dialog.
+ button.setAttribute("data-setup-calendar", true);
+ button.click();
+ }
+ },
+
+ /**
+ * Called from the very final view of the account setup, when the user decides
+ * to close the wizard.
+ */
+ onFinish() {
+ // Send the message to the mail tab in case the UI didn't load during the
+ // previous setup callback.
+ gMainWindow.postMessage("account-setup-closed", "*");
+ // Close this tab.
+ window.close();
+ },
+};
+
+function serverMatches(a, b) {
+ return (
+ a.type == b.type &&
+ a.hostname == b.hostname &&
+ a.port == b.port &&
+ a.socketType == b.socketType &&
+ a.auth == b.auth
+ );
+}
+
+/**
+ * Warning dialog, warning user about lack of, or inappropriate, encryption.
+ */
+var gSecurityWarningDialog = {
+ /**
+ * {Array of {(incoming or outgoing) server part of {AccountConfig}}
+ * A list of the servers for which we already showed this dialog and the
+ * user approved the configs. For those, we won't show the warning again.
+ * (Make sure to store a copy in case the underlying object is changed.)
+ */
+ _acknowledged: [],
+
+ _inSecurityBad: 0x0001,
+ _inCertBad: 0x0010,
+ _outSecurityBad: 0x0100,
+ _outCertBad: 0x1000,
+
+ /**
+ * Checks whether we need to warn about this config.
+ *
+ * We (currently) warn if
+ * - the mail travels unsecured (no SSL/STARTTLS)
+ * - (We don't warn about unencrypted passwords specifically,
+ * because they'd be encrypted with SSL and without SSL, we'd
+ * warn anyways.)
+ *
+ * We may not warn despite these conditions if we had shown the
+ * warning for that server before and the user acknowledged it.
+ * (Given that this dialog object is static/global and persistent,
+ * we can store that approval state here in this object.)
+ *
+ * @param configSchema @see open()
+ * @param configFilledIn @see open()
+ * @returns {boolean} - True when the dialog should be shown
+ * (call open()). if false, the dialog can and should be skipped.
+ */
+ needed(configSchema, configFilledIn) {
+ assert(configSchema instanceof AccountConfig);
+ assert(configFilledIn instanceof AccountConfig);
+ assert(configSchema.isComplete());
+ assert(configFilledIn.isComplete());
+
+ let incomingBad =
+ (configFilledIn.incoming.socketType > 1 ? 0 : this._inSecurityBad) |
+ (configFilledIn.incoming.badCert ? this._inCertBad : 0);
+ let outgoingBad = 0;
+ if (configFilledIn.outgoing.addThisServer) {
+ outgoingBad =
+ (configFilledIn.outgoing.socketType > 1 ? 0 : this._outSecurityBad) |
+ (configFilledIn.outgoing.badCert ? this._outCertBad : 0);
+ }
+
+ if (incomingBad > 0) {
+ if (
+ this._acknowledged.some(ackServer => {
+ return serverMatches(ackServer, configFilledIn.incoming);
+ })
+ ) {
+ incomingBad = 0;
+ }
+ }
+ if (outgoingBad > 0) {
+ if (
+ this._acknowledged.some(ackServer => {
+ return serverMatches(ackServer, configFilledIn.outgoing);
+ })
+ ) {
+ outgoingBad = 0;
+ }
+ }
+
+ return incomingBad | outgoingBad;
+ },
+
+ /**
+ * Opens the dialog, fills it with values, and shows it to the user.
+ *
+ * The function is async: it returns immediately, and when the user clicks
+ * OK or Cancel, the callbacks are called. There the callers proceed as
+ * appropriate.
+ *
+ * @param configSchema The config, with placeholders not replaced yet.
+ * This object may be modified to store the user's confirmations, but
+ * currently that's not the case.
+ * @param configFilledIn The concrete config with placeholders replaced.
+ * @param onlyIfNeeded {Boolean} - If there is nothing to warn about,
+ * call okCallback() immediately (and sync).
+ * @param okCallback {function(config {AccountConfig})}
+ * Called when the user clicked OK and approved the config including
+ * the warnings. |config| is without placeholders replaced.
+ * @param cancalCallback {function()}
+ * Called when the user decided to heed the warnings and not approve.
+ */
+ open(configSchema, configFilledIn, onlyIfNeeded, okCallback, cancelCallback) {
+ assert(typeof okCallback == "function");
+ assert(typeof cancelCallback == "function");
+
+ // needed() also checks the parameters
+ let needed = this.needed(configSchema, configFilledIn);
+ if (needed == 0 && onlyIfNeeded) {
+ okCallback();
+ return;
+ }
+
+ assert(needed > 0, "security dialog opened needlessly");
+
+ let dialog = document.getElementById("insecureDialog");
+ this._currentConfigFilledIn = configFilledIn;
+ this._okCallback = okCallback;
+ this._cancelCallback = cancelCallback;
+ let incoming = configFilledIn.incoming;
+ let outgoing = configFilledIn.outgoing;
+
+ // Reset the dialog, in case we've shown it before.
+ document.getElementById("acknowledgeWarning").checked = false;
+ document.getElementById("insecureConfirmButton").disabled = true;
+
+ // Incoming security is bad.
+ let insecureIncoming = document.getElementById("insecureSectionIncoming");
+ if (needed & this._inSecurityBad) {
+ document.l10n.setAttributes(
+ document.getElementById("warningIncoming"),
+ "account-setup-warning-cleartext",
+ {
+ server: incoming.hostname,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("detailsIncoming"),
+ "account-setup-warning-cleartext-details"
+ );
+
+ insecureIncoming.hidden = false;
+ } else {
+ insecureIncoming.hidden = true;
+ }
+
+ // Outgoing security or certificate is bad.
+ let insecureOutgoing = document.getElementById("insecureSectionOutgoing");
+ if (needed & this._outSecurityBad) {
+ document.l10n.setAttributes(
+ document.getElementById("warningOutgoing"),
+ "account-setup-warning-cleartext",
+ {
+ server: outgoing.hostname,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("detailsOutgoing"),
+ "account-setup-warning-cleartext-details"
+ );
+
+ insecureOutgoing.hidden = false;
+ } else {
+ insecureOutgoing.hidden = true;
+ }
+
+ assert(
+ !insecureIncoming.hidden || !insecureOutgoing.hidden,
+ "warning dialog shown for unknown reason"
+ );
+
+ // Show the dialog.
+ dialog.showModal();
+ },
+
+ /**
+ * User checked checkbox that he understood it and wishes to ignore the
+ * warning.
+ */
+ toggleAcknowledge() {
+ document.getElementById("insecureConfirmButton").disabled =
+ !document.getElementById("acknowledgeWarning").checked;
+ },
+
+ /**
+ * [Cancel] button pressed. Get me out of here!
+ */
+ onCancel() {
+ document.getElementById("insecureDialog").close();
+ document.getElementById("incomingProtocol").focus();
+
+ this._cancelCallback();
+ },
+
+ /**
+ * [OK] button pressed.
+ * Implies that the user toggled the acknowledge checkbox,
+ * i.e. approved the config and ignored the warnings,
+ * otherwise the button would have been disabled.
+ */
+ onOK() {
+ assert(document.getElementById("acknowledgeWarning").checked);
+
+ // Need filled in, in case the hostname is a placeholder.
+ let storeConfig = this._currentConfigFilledIn.copy();
+ this._acknowledged.push(storeConfig.incoming);
+ this._acknowledged.push(storeConfig.outgoing);
+
+ document.getElementById("insecureDialog").close();
+
+ this._okCallback();
+ },
+};
+
+/**
+ * Helper method to open the dictionaries list in a new tab.
+ */
+function openDictionariesTab() {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = mailWindow.document.getElementById("tabmail");
+
+ let url = Services.urlFormatter.formatURLPref(
+ "spellchecker.dictionaries.download.url"
+ );
+
+ // Open the dictionaries URL.
+ tabmail.openTab("contentTab", {
+ url,
+ });
+}
diff --git a/comm/mail/components/accountcreation/content/accountSetup.xhtml b/comm/mail/components/accountcreation/content/accountSetup.xhtml
new file mode 100644
index 0000000000..ed1148c561
--- /dev/null
+++ b/comm/mail/components/accountcreation/content/accountSetup.xhtml
@@ -0,0 +1,1333 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html
+ id="accountSetup"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="mail:accountsetup"
+>
+ <head>
+ <title data-l10n-id="account-setup-tab-title"></title>
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="icon"
+ href="chrome://messenger/skin/icons/new/compact/new-mail.svg"
+ />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/menulist.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/accountSetup.css" />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link
+ rel="localization"
+ href="messenger/accountcreation/accountSetup.ftl"
+ />
+
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountUtils.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountcreation/accountSetup.js"
+ ></script>
+ </head>
+
+ <body>
+ <!-- Native HTML dialog used for setup cancel confirmation. -->
+ <dialog id="confirmExitDialog" class="account-setup-dialog">
+ <div class="dialog-container vertical">
+ <h2 class="dialog-title">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ alt=""
+ class="dialog-header-image"
+ />
+ <span data-l10n-id="exit-dialog-title"></span>
+ </h2>
+
+ <div class="dialog-container">
+ <p
+ data-l10n-id="exit-dialog-description"
+ class="dialog-description indent"
+ ></p>
+ </div>
+
+ <label class="toggle-container-with-text indent">
+ <input
+ id="useWithoutAccount"
+ type="checkbox"
+ onchange="gAccountSetup.toggleExitDialogButton(event);"
+ />
+ <span
+ data-l10n-id="account-setup-no-account-checkbox"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button
+ id="exitDialogConfirmButton"
+ data-l10n-id="exit-dialog-confirm-button"
+ ></button>
+ <button
+ id="exitDialogCancelButton"
+ data-l10n-id="exit-dialog-cancel-button"
+ class="primary"
+ ></button>
+ </menu>
+ </dialog>
+
+ <!-- Native HTML dialog used for Exchange confirmation. -->
+ <dialog id="exchangeDialog" class="account-setup-dialog">
+ <div class="dialog-container">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/question.svg"
+ alt=""
+ class="dialog-header-image"
+ />
+ <p id="exchangeDialogQuestion" class="dialog-description"></p>
+ </div>
+ <menu class="dialog-menu-container">
+ <button
+ id="exchangeDialogCancelButton"
+ data-l10n-id="exchange-dialog-cancel-button"
+ ></button>
+ <button
+ id="exchangeDialogConfirmButton"
+ data-l10n-id="exchange-dialog-confirm-button"
+ class="primary"
+ ></button>
+ </menu>
+ </dialog>
+
+ <!-- Native HTML dialog used for insecure password confirmation. -->
+ <dialog id="insecureDialog" class="account-setup-dialog dialog-critical">
+ <div class="dialog-container vertical">
+ <h2 class="warning-title">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ alt=""
+ class="dialog-header-image warning-icon"
+ />
+ <span data-l10n-id="account-setup-insecure-title"></span>
+ </h2>
+
+ <section
+ id="insecureSectionIncoming"
+ class="insecure-section content-blocking-category"
+ hidden="hidden"
+ >
+ <h3 data-l10n-id="account-setup-insecure-incoming-title"></h3>
+ <p id="warningIncoming"></p>
+ <p
+ id="detailsIncoming"
+ class="insecure-section-description indent"
+ ></p>
+ </section>
+
+ <section
+ id="insecureSectionOutgoing"
+ class="insecure-section content-blocking-category"
+ hidden="hidden"
+ >
+ <h3 data-l10n-id="account-setup-insecure-outgoing-title"></h3>
+ <p id="warningOutgoing"></p>
+ <p
+ id="detailsOutgoing"
+ class="insecure-section-description indent"
+ ></p>
+ </section>
+
+ <p
+ class="dialog-footnote"
+ data-l10n-id="account-setup-insecure-description"
+ >
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-name="thunderbird-faq-link"
+ ></a>
+ </p>
+ </div>
+
+ <menu class="dialog-menu-container two-columns">
+ <aside>
+ <label class="toggle-container-with-text">
+ <input
+ id="acknowledgeWarning"
+ type="checkbox"
+ onchange="gSecurityWarningDialog.toggleAcknowledge();"
+ />
+ <span
+ data-l10n-id="account-setup-insecure-server-checkbox"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </aside>
+
+ <aside>
+ <button
+ data-l10n-id="insecure-dialog-cancel-button"
+ onclick="gSecurityWarningDialog.onCancel();"
+ ></button>
+ <button
+ id="insecureConfirmButton"
+ data-l10n-id="insecure-dialog-confirm-button"
+ class="primary"
+ disable="disabled"
+ onclick="gSecurityWarningDialog.onOK();"
+ ></button>
+ </aside>
+ </menu>
+ </dialog>
+
+ <!-- Native HTML dialog for Calendar synchronization. This is a streamlined
+ version of the calendar-properties-dialog.xhtml with fewer properties:
+ - Name
+ - Color
+ - Refresh rate
+ - Read only
+ - Show reminders
+ - Offline support
+ This dialog should be kept synced with the calendar-properties-dialog.xhtml
+ if one of these properties changes.
+ -->
+ <dialog id="calendarDialog" class="account-setup-dialog">
+ <div class="dialog-container vertical">
+ <div class="dialog-container">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/new-event.svg"
+ alt=""
+ class="dialog-header-image small"
+ />
+ <p
+ data-l10n-id="calendar-dialog-title"
+ class="dialog-description"
+ ></p>
+ </div>
+
+ <section class="calendar-dialog-form">
+ <label
+ for="calendarName"
+ data-l10n-id="account-setup-calendar-name-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="calendarName"
+ type="text"
+ autocomplete="off"
+ class="input-field input-grow"
+ data-l10n-id="account-setup-calendar-name-input"
+ required="required"
+ />
+ </div>
+
+ <label
+ for="calendarColor"
+ data-l10n-id="account-setup-calendar-color-label"
+ >
+ </label>
+ <div class="input-control">
+ <input id="calendarColor" type="color" />
+ </div>
+
+ <label
+ for="calendarRefresh"
+ data-l10n-id="account-setup-calendar-refresh-label"
+ >
+ </label>
+ <div class="input-control">
+ <select id="calendarRefresh" class="input-grow">
+ <option
+ data-l10n-id="account-setup-calendar-refresh-manual"
+ value="0"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 1 }'
+ value="1"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 5 }'
+ value="5"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 15 }'
+ value="15"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 30 }'
+ value="30"
+ selected="selected"
+ ></option>
+ <option
+ data-l10n-id="account-setup-calendar-refresh-interval"
+ data-l10n-args='{ "count": 60 }'
+ value="60"
+ ></option>
+ </select>
+ </div>
+ </section>
+
+ <section class="indent">
+ <label class="toggle-container-with-text">
+ <input id="calendarReadOnly" type="checkbox" />
+ <span
+ data-l10n-id="account-setup-calendar-read-only"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+
+ <label
+ id="calendarShowRemindersRow"
+ class="toggle-container-with-text"
+ >
+ <input
+ id="calendarShowReminders"
+ type="checkbox"
+ checked="checked"
+ />
+ <span
+ data-l10n-id="account-setup-calendar-show-reminders"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+
+ <label class="toggle-container-with-text">
+ <input
+ id="calendarOfflineSupport"
+ type="checkbox"
+ checked="checked"
+ />
+ <span
+ data-l10n-id="account-setup-calendar-offline-support"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </section>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button
+ id="calendarDialogCancelButton"
+ data-l10n-id="calendar-dialog-cancel-button"
+ ></button>
+ <button
+ id="calendarDialogConfirmButton"
+ data-l10n-id="calendar-dialog-confirm-button"
+ class="primary"
+ ></button>
+ </menu>
+ </dialog>
+
+ <header>
+ <h1
+ id="accountSetupTitle"
+ data-l10n-id="account-setup-title"
+ class="title"
+ ></h1>
+ <p
+ id="accountSetupDescription"
+ data-l10n-id="account-setup-description"
+ class="description"
+ ></p>
+ <p
+ id="accountSetupDescriptionSecondary"
+ data-l10n-id="account-setup-secondary-description"
+ class="description"
+ ></p>
+ </header>
+
+ <section class="main-container">
+ <aside id="setupView" class="column first-column">
+ <form id="form" onsubmit="gAccountSetup.onFormSubmit(event);">
+ <!-- Hidden submit field to enable the natural Enter keypress to
+ submit the form. We do this because we have the Continue and Done
+ button outside the form and we want to only handle the Enter to
+ submit on the primary fields inside the form. -->
+ <input type="submit" hidden="hidden" />
+ <label
+ for="realname"
+ data-l10n-id="account-setup-name-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="realname"
+ type="text"
+ autocomplete="off"
+ class="input-field"
+ data-l10n-id="account-setup-name-input"
+ oninput="gAccountSetup.onInputRealname();"
+ required="required"
+ />
+ <img
+ id="realnameInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-name-info-icon"
+ alt=""
+ class="form-icon"
+ />
+ <img
+ id="realnameWarning"
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-name-warning-icon"
+ alt=""
+ class="form-icon icon-warning"
+ />
+ </div>
+
+ <label
+ for="email"
+ data-l10n-id="account-setup-email-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="email"
+ type="email"
+ autocomplete="off"
+ data-l10n-id="account-setup-email-input"
+ class="input-field"
+ oninput="gAccountSetup.onInputEmail();"
+ required="required"
+ />
+ <img
+ id="emailInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-email-info-icon"
+ alt=""
+ class="form-icon"
+ />
+ <img
+ id="emailWarning"
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-email-warning-icon"
+ alt=""
+ class="form-icon icon-warning"
+ />
+ </div>
+
+ <div class="provisioner-button-container">
+ <button
+ id="provisionerButton"
+ type="button"
+ data-l10n-id="account-provisioner-button"
+ data-l10n-attrs="accesskey"
+ class="btn-link btn-link-new-email"
+ onclick="openAccountProvisionerTab();"
+ ></button>
+ </div>
+
+ <label
+ for="password"
+ data-l10n-id="account-setup-password-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="password"
+ type="password"
+ autocomplete="off"
+ class="input-field"
+ oninput="gAccountSetup.onInputPassword();"
+ />
+ <button
+ id="passwordToggleButton"
+ type="button"
+ onclick="gAccountSetup.passwordToggle(event);"
+ data-l10n-id="account-setup-password-toggle-show"
+ class="form-toggle-button"
+ hidden="hidden"
+ >
+ <img
+ id="passwordInfo"
+ src="chrome://messenger/skin/icons/new/compact/hidden.svg"
+ class="form-icon"
+ alt=""
+ />
+ </button>
+ </div>
+
+ <div class="remember-button-container">
+ <label class="toggle-container-with-text">
+ <input id="rememberPassword" type="checkbox" checked="checked" />
+ <span
+ data-l10n-id="account-setup-remember-password"
+ data-l10n-attrs="accesskey"
+ >
+ </span>
+ </label>
+ </div>
+
+ <div id="usernameRow" hidden="hidden">
+ <!-- This is only used for Exchange AutoDiscover, and even then
+ only when absolutely necessary and known to be needed. -->
+ <label
+ for="usernameEx"
+ data-l10n-id="account-setup-exchange-label"
+ data-l10n-attrs="accesskey"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="usernameEx"
+ type="text"
+ data-l10n-id="account-setup-exchange-input"
+ class="input-field"
+ oninput="gAccountSetup.onInputUsername();"
+ />
+ <img
+ id="usernameExInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ class="form-icon"
+ data-l10n-id="account-setup-exchange-info-icon"
+ alt=""
+ />
+ </div>
+ </div>
+ </form>
+
+ <section
+ id="accountSetupNotifications"
+ class="account-setup-notifications"
+ >
+ <!-- Notifications will be lazily loaded here. -->
+ </section>
+
+ <!-- Results area -->
+ <section id="resultsArea" hidden="hidden">
+ <h4 id="resultAreaTitle" class="section-title"></h4>
+
+ <!-- IMAP -->
+ <div
+ id="resultsOption-imap"
+ class="content-blocking-category results-option"
+ >
+ <label class="toggle-container-with-text">
+ <input
+ id="resultSelect-imap"
+ type="radio"
+ value="imap"
+ name="resultsServerType"
+ onchange="gAccountSetup.onResultServerTypeChanged();"
+ />
+ <span class="strong">IMAP</span>
+ <p
+ class="result-indent"
+ data-l10n-id="account-setup-result-imap-description"
+ ></p>
+ </label>
+ <aside class="result-details">
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/inbox.svg"
+ alt=""
+ />
+ <div id="incomingTitle-imap" class="result-details-title">
+ <h4 data-l10n-id="account-setup-incoming-title"></h4>
+ </div>
+ <div id="incomingInfo-imap" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/outbox.svg"
+ alt=""
+ />
+ <div id="outgoingTitle-imap" class="result-details-title">
+ <h4 data-l10n-id="account-setup-outgoing-title"></h4>
+ </div>
+ <div id="outgoingInfo-imap" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/contact.svg"
+ alt=""
+ />
+ <div class="result-details-title">
+ <h4 data-l10n-id="account-setup-username-title"></h4>
+ </div>
+ <div id="usernameInfo-imap" class="result-host-info"></div>
+ </section>
+ </aside>
+ </div>
+
+ <!-- POP3 -->
+ <div
+ id="resultsOption-pop3"
+ class="content-blocking-category results-option"
+ >
+ <label class="toggle-container-with-text">
+ <input
+ id="resultSelect-pop3"
+ type="radio"
+ value="pop3"
+ name="resultsServerType"
+ onchange="gAccountSetup.onResultServerTypeChanged();"
+ />
+ <span class="strong">POP3</span>
+ <p
+ class="result-indent"
+ data-l10n-id="account-setup-result-pop-description"
+ ></p>
+ </label>
+ <aside class="result-details">
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/inbox.svg"
+ alt=""
+ />
+ <div id="incomingTitle-pop3" class="result-details-title">
+ <h4 data-l10n-id="account-setup-incoming-title"></h4>
+ </div>
+ <div id="incomingInfo-pop3" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/outbox.svg"
+ alt=""
+ />
+ <div id="outgoingTitle-pop3" class="result-details-title">
+ <h4 data-l10n-id="account-setup-outgoing-title"></h4>
+ </div>
+ <div id="outgoingInfo-pop3" class="result-host-info"></div>
+ </section>
+
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/contact.svg"
+ alt=""
+ />
+ <div class="result-details-title">
+ <h4 data-l10n-id="account-setup-username-title"></h4>
+ </div>
+ <div id="usernameInfo-pop3" class="result-host-info"></div>
+ </section>
+ </aside>
+ </div>
+
+ <!-- EXCHANGE -->
+ <div
+ id="resultsOption-exchange"
+ class="content-blocking-category results-option"
+ >
+ <label class="toggle-container-with-text">
+ <input
+ id="resultSelect-exchange"
+ type="radio"
+ value="exchange"
+ name="resultsServerType"
+ onchange="gAccountSetup.onResultServerTypeChanged();"
+ />
+ <span class="strong"> Exchange/Office365 </span>
+ <p
+ class="result-indent"
+ data-l10n-id="account-setup-result-exchange2-description"
+ ></p>
+ </label>
+ <aside class="result-details">
+ <section class="result-details-row">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/inbox.svg"
+ alt=""
+ />
+ <div id="incomingTitle-exchange" class="result-details-title">
+ <h4 data-l10n-id="account-setup-exchange-title"></h4>
+ </div>
+ <div id="resultExchangeHostname" class="result-host-info"></div>
+ </section>
+ <aside id="installAddonInfo">
+ <p id="resultAddonIntro"></p>
+ <div id="resultAddonInstallRows"></div>
+ </aside>
+ </aside>
+ </div>
+ </section>
+ <!-- END Results area -->
+
+ <!-- Manual edit area -->
+ <section id="manualConfigArea" hidden="hidden">
+ <h4
+ class="section-title"
+ data-l10n-id="account-setup-manual-config-title"
+ ></h4>
+
+ <!-- Incoming server section -->
+ <fieldset
+ class="manual-config-grid content-blocking-category"
+ aria-describedby="manualConfigDescription"
+ >
+ <legend
+ data-l10n-id="account-setup-incoming-server-legend"
+ ></legend>
+
+ <!-- Incoming Protocol -->
+ <aside>
+ <label
+ for="incomingProtocol"
+ data-l10n-id="account-setup-protocol-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="incomingProtocol"
+ onchange="gAccountSetup.onChangedProtocolIncoming();"
+ >
+ <option value="1">IMAP</option>
+ <option value="2">POP3</option>
+ <option
+ id="incomingProtocolExchange"
+ value="3"
+ hidden="hidden"
+ >
+ Exchange
+ </option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Incoming Server -->
+ <aside>
+ <label
+ for="incomingHostname"
+ data-l10n-id="account-setup-hostname-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="incomingHostname"
+ type="text"
+ placeholder="mail.example.com"
+ onchange="gAccountSetup.onChangeHostname();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="host uri-element input-field"
+ />
+ </div>
+ </aside>
+
+ <!-- Incoming Port -->
+ <section>
+ <aside>
+ <label
+ for="incomingPort"
+ data-l10n-id="account-setup-port-label"
+ class="option-label"
+ >
+ </label>
+ <input
+ id="incomingPort"
+ type="number"
+ min="1"
+ max="65535"
+ onchange="gAccountSetup.onChangedPortIncoming();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="input-field"
+ />
+ </aside>
+ </section>
+
+ <!-- Incoming SSL -->
+ <aside>
+ <label
+ for="incomingSsl"
+ data-l10n-id="account-setup-ssl-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="incomingSsl"
+ class="security"
+ onchange="gAccountSetup.onChangedSSLIncoming();"
+ >
+ <!-- @see nsMsgSocketType -->
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="-1"
+ ></option>
+ <option
+ data-l10n-id="ssl-noencryption-option"
+ value="0"
+ ></option>
+ <option value="2">STARTTLS</option>
+ <option value="3">SSL/TLS</option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Incoming Authentication -->
+ <aside>
+ <label
+ for="incomingAuthMethod"
+ data-l10n-id="account-setup-auth-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="incomingAuthMethod"
+ class="auth"
+ onchange="gAccountSetup.onChangedInAuth();"
+ >
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="0"
+ ></option>
+ <!-- Values defined in nsMsgAuthMethod. -->
+ <option
+ data-l10n-id="ssl-cleartext-password-option"
+ value="3"
+ ></option>
+ <option
+ data-l10n-id="ssl-encrypted-password-option"
+ value="4"
+ ></option>
+ <option value="5">Kerberos / GSSAPI</option>
+ <option value="6">NTLM</option>
+ <option id="in-authMethod-oauth2" value="10" hidden="hidden">
+ OAuth2
+ </option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Incoming Username -->
+ <aside>
+ <label
+ for="incomingUsername"
+ data-l10n-id="account-setup-username-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="incomingUsername"
+ type="text"
+ data-l10n-id="account-setup-email-input"
+ oninput="gAccountSetup.onInputInUsername();"
+ class="username input-field"
+ />
+ </div>
+ </aside>
+ </fieldset>
+
+ <!-- Outgoing server section -->
+ <fieldset
+ class="manual-config-grid content-blocking-category"
+ aria-describedby="manualConfigDescription"
+ >
+ <legend
+ data-l10n-id="account-setup-outgoing-server-legend"
+ ></legend>
+
+ <!-- Outgoing Server -->
+ <aside>
+ <label
+ for="outgoingHostname"
+ data-l10n-id="account-setup-hostname-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="outgoingHostname"
+ type="text"
+ placeholder="mail.example.com"
+ onchange="gAccountSetup.onChangeHostname();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="input-field"
+ />
+ </div>
+ </aside>
+
+ <!-- Outgoing Port -->
+ <section>
+ <aside>
+ <label
+ for="outgoingPort"
+ data-l10n-id="account-setup-port-label"
+ class="option-label"
+ >
+ </label>
+ <input
+ id="outgoingPort"
+ type="number"
+ min="1"
+ max="65535"
+ onchange="gAccountSetup.onChangedPortOutgoing();"
+ oninput="gAccountSetup.manualConfigChanged();"
+ class="input-field"
+ />
+ </aside>
+ </section>
+
+ <!-- Outgoing SSL -->
+ <aside>
+ <label
+ for="outgoingSsl"
+ data-l10n-id="account-setup-ssl-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="outgoingSsl"
+ class="security"
+ onchange="gAccountSetup.onChangedSSLOutgoing();"
+ >
+ <!-- Values defined in nsMsgSocketType. -->
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="-1"
+ ></option>
+ <option
+ data-l10n-id="ssl-noencryption-option"
+ value="0"
+ ></option>
+ <option value="2">STARTTLS</option>
+ <option value="3">SSL/TLS</option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Outgoing Authentication -->
+ <aside>
+ <label
+ for="outgoingAuthMethod"
+ data-l10n-id="account-setup-auth-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <select
+ id="outgoingAuthMethod"
+ class="auth"
+ onchange="gAccountSetup.onChangedOutAuth(event);"
+ >
+ <option
+ data-l10n-id="ssl-autodetect-option"
+ value="0"
+ ></option>
+ <!-- @see incoming -->
+ <option
+ id="outNoAuth"
+ data-l10n-id="ssl-no-authentication-option"
+ value="1"
+ ></option>
+ <option
+ data-l10n-id="ssl-cleartext-password-option"
+ value="3"
+ ></option>
+ <option
+ data-l10n-id="ssl-encrypted-password-option"
+ value="4"
+ ></option>
+ <option value="5">Kerberos / GSSAPI</option>
+ <option value="6">NTLM</option>
+ <option id="out-authMethod-oauth2" value="10" hidden="hidden">
+ OAuth2
+ </option>
+ </select>
+ </div>
+ </aside>
+
+ <!-- Outgoing Username -->
+ <aside>
+ <label
+ for="outgoingUsername"
+ data-l10n-id="account-setup-username-label"
+ class="option-label"
+ >
+ </label>
+ <div class="input-control">
+ <input
+ id="outgoingUsername"
+ type="text"
+ data-l10n-id="account-setup-email-input"
+ oninput="gAccountSetup.onInputOutUsername();"
+ class="username input-field"
+ />
+ </div>
+ </aside>
+ </fieldset>
+
+ <div class="link-row">
+ <button
+ id="advancedSetupButton"
+ class="btn-link"
+ data-l10n-id="account-setup-advanced-setup-button"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.onAdvancedSetup();"
+ ></button>
+ </div>
+ </section>
+ <!-- END Manual edit area -->
+
+ <div class="action-buttons-container">
+ <aside>
+ <button
+ id="stopButton"
+ type="button"
+ data-l10n-id="account-setup-button-stop"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.onStop();"
+ hidden="hidden"
+ ></button>
+ <button
+ id="reTestButton"
+ type="button"
+ data-l10n-id="account-setup-button-retest"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.testManualConfig();"
+ hidden="hidden"
+ ></button>
+ <button
+ id="manualConfigButton"
+ type="button"
+ data-l10n-id="account-setup-button-manual-config"
+ data-l10n-attrs="accesskey"
+ class="btn-link"
+ onclick="gAccountSetup.onManualEdit();"
+ hidden="hidden"
+ ></button>
+ </aside>
+
+ <aside class="buttons-container-last">
+ <button
+ id="cancelButton"
+ type="button"
+ data-l10n-id="account-setup-button-cancel"
+ data-l10n-attrs="accesskey"
+ onclick="gAccountSetup.onCancel();"
+ ></button>
+ <button
+ id="continueButton"
+ type="button"
+ data-l10n-id="account-setup-button-continue"
+ data-l10n-attrs="accesskey"
+ class="primary"
+ onclick="gAccountSetup.onContinue();"
+ disabled="disabled"
+ ></button>
+ <button
+ id="createButton"
+ type="button"
+ data-l10n-id="account-setup-button-done"
+ data-l10n-attrs="accesskey"
+ class="primary"
+ onclick="gAccountSetup.onCreate();"
+ hidden="hidden"
+ disabled="disabled"
+ ></button>
+ </aside>
+ </div>
+
+ <p
+ id="manualConfigDescription"
+ data-l10n-id="account-setup-auto-description"
+ class="autoconfig-note tip-caption"
+ hidden="hidden"
+ ></p>
+
+ <p
+ id="footDescription"
+ data-l10n-id="account-setup-privacy-footnote2"
+ class="foot-note tip-caption"
+ ></p>
+ </aside>
+ <!-- END first column "setupView"-->
+
+ <aside
+ id="successView"
+ class="column first-column success-column"
+ hidden="hidden"
+ >
+ <section class="account-success-block">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/mail-secure.svg"
+ class="account-type-image"
+ alt=""
+ />
+ <aside>
+ <span id="newAccountName" class="account-name"></span>
+ <span id="newAccountEmail" class="account-email"></span>
+ </aside>
+ <span id="newAccountProtocol" class="protocol-type"></span>
+ </section>
+
+ <section class="quick-links">
+ <button
+ id="settingsButton"
+ type="button"
+ data-l10n-id="account-setup-settings-button"
+ class="quick-link"
+ ></button>
+ <button
+ id="encryptionButton"
+ type="button"
+ data-l10n-id="account-setup-encryption-button"
+ class="quick-link"
+ ></button>
+ <button
+ id="signatureButton"
+ type="button"
+ data-l10n-id="account-setup-signature-button"
+ class="quick-link"
+ ></button>
+ <button
+ id="dictionariesButton"
+ type="button"
+ data-l10n-id="account-setup-dictionaries-button"
+ class="quick-link"
+ onclick="openDictionariesTab();"
+ ></button>
+ </section>
+
+ <section id="linkedServices">
+ <h3 data-l10n-id="account-setup-linked-services-title"></h3>
+ <p id="linkedServicesDescription" class="tip-caption"></p>
+
+ <section id="syncNotifications" class="account-setup-notifications">
+ <!-- Notifications will be lazily loaded here. -->
+ </section>
+
+ <aside class="services-buttons-container">
+ <section
+ id="linkedAddressBooks"
+ class="content-blocking-category linked-services-section opened"
+ hidden="hidden"
+ >
+ <button
+ type="button"
+ class="linked-services-button"
+ onclick="gAccountSetup.toggleSetupContainer(event);"
+ >
+ <aside>
+ <span
+ class="account-name"
+ data-l10n-id="account-setup-address-books-button"
+ >
+ </span>
+ <p
+ id="addressBooksCountDescription"
+ class="linked-services-description"
+ ></p>
+ </aside>
+ <span class="linked-service-dropdown">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/nav-right.svg"
+ alt=""
+ />
+ </span>
+ </button>
+
+ <div id="addressBooksSetup" class="linked-services-container">
+ <ul class="linked-services-list"></ul>
+ <button
+ id="addressBooksSetupAll"
+ data-l10n-id="account-setup-connect-all-address-books"
+ class="btn-link self-center"
+ onclick="gAccountSetup.setupAllAddressBooks();"
+ hidden="hidden"
+ ></button>
+ </div>
+ </section>
+
+ <section class="indent">
+ <button
+ id="addressBookCardDAVButton"
+ type="button"
+ data-l10n-id="account-setup-address-book-carddav-button"
+ class="quick-link"
+ onclick="addNewAddressBook('CARDDAV');"
+ ></button>
+
+ <button
+ id="addressBookLDAPButton"
+ type="button"
+ data-l10n-id="account-setup-address-book-ldap-button"
+ class="quick-link"
+ onclick="addNewAddressBook('LDAP');"
+ ></button>
+ </section>
+
+ <section
+ id="linkedCalendars"
+ class="content-blocking-category linked-services-section opened"
+ hidden="hidden"
+ >
+ <button
+ type="button"
+ class="linked-services-button"
+ onclick="gAccountSetup.toggleSetupContainer(event);"
+ >
+ <aside>
+ <span
+ class="account-name"
+ data-l10n-id="account-setup-calendars-button"
+ >
+ </span>
+ <p
+ id="calendarsCountDescription"
+ class="linked-services-description"
+ ></p>
+ </aside>
+ <span class="linked-service-dropdown">
+ <img
+ src="chrome://messenger/skin/icons/new/compact/nav-right.svg"
+ alt=""
+ />
+ </span>
+ </button>
+
+ <div id="calendarsSetup" class="linked-services-container">
+ <ul class="linked-services-list"></ul>
+ <button
+ id="calendarsSetupAll"
+ data-l10n-id="account-setup-connect-all-calendars"
+ class="btn-link self-center"
+ onclick="gAccountSetup.setupAllCalendars();"
+ hidden="hidden"
+ ></button>
+ </div>
+ </section>
+
+ <section class="indent">
+ <button
+ id="createCalendarButton"
+ type="button"
+ data-l10n-id="account-setup-calendar-button"
+ class="quick-link"
+ onclick="showCalendarWizard();"
+ ></button>
+ </section>
+ </aside>
+ </section>
+
+ <section class="final-buttons-container">
+ <button
+ id="finishButton"
+ type="button"
+ data-l10n-id="account-setup-button-finish"
+ data-l10n-attrs="accesskey"
+ class="primary"
+ onclick="gAccountSetup.onFinish();"
+ ></button>
+ </section>
+ </aside>
+ <!-- END first column "successView"-->
+
+ <aside class="column second-column">
+ <article id="step1">
+ <img
+ src="chrome://messenger/skin/illustrations/octopus-setup.svg"
+ data-l10n-id="account-setup-step1-image"
+ alt=""
+ />
+ </article>
+ <article id="step2" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/sloth.svg"
+ data-l10n-id="account-setup-step2-image"
+ alt=""
+ />
+ </article>
+ <article id="step3" class="tip-caption" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/form.svg"
+ data-l10n-id="account-setup-step3-image"
+ alt=""
+ />
+ <p data-l10n-id="account-setup-selection-help"></p>
+ <a
+ href="https://support.mozilla.org/products/thunderbird/emails-thunderbird/set-up-email-thunderbird"
+ data-l10n-id="account-setup-documentation-help"
+ ></a>
+ -
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-setup-forum-help"
+ ></a>
+ -
+ <a
+ href="https://www.mozilla.org/privacy/thunderbird/"
+ data-l10n-id="account-setup-privacy-help"
+ ></a>
+ </article>
+ <article id="step4" class="tip-caption" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/connection-error.svg"
+ data-l10n-id="account-setup-step4-image"
+ alt=""
+ />
+ <p data-l10n-id="account-setup-selection-error"></p>
+ <a
+ href="https://support.mozilla.org/products/thunderbird/emails-thunderbird/set-up-email-thunderbird"
+ data-l10n-id="account-setup-documentation-help"
+ ></a>
+ -
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-setup-forum-help"
+ ></a>
+ </article>
+ <article id="step5" class="tip-caption" hidden="hidden">
+ <img
+ src="chrome://messenger/skin/illustrations/accounts.svg"
+ data-l10n-id="account-setup-step5-image"
+ alt=""
+ />
+ <p data-l10n-id="account-setup-success-help"></p>
+ <a
+ href="https://support.mozilla.org/products/thunderbird/learn-basics-get-started"
+ data-l10n-id="account-setup-getting-started"
+ ></a>
+ -
+ <a
+ href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-setup-forum-help"
+ ></a>
+ -
+ <a
+ href="https://www.mozilla.org/privacy/thunderbird/"
+ data-l10n-id="account-setup-privacy-help"
+ ></a>
+ </article>
+ </aside>
+ <!-- END second column-->
+ </section>
+ </body>
+</html>
diff --git a/comm/mail/components/accountcreation/jar.mn b/comm/mail/components/accountcreation/jar.mn
new file mode 100644
index 0000000000..0a3389020e
--- /dev/null
+++ b/comm/mail/components/accountcreation/jar.mn
@@ -0,0 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/accountcreation/accountHub.js (content/accountHub.js)
+ content/messenger/accountcreation/accountSetup.js (content/accountSetup.js)
+ content/messenger/accountcreation/accountSetup.xhtml (content/accountSetup.xhtml)
+# Custom elements
+ content/messenger/accountcreation/views/container.mjs (views/container.mjs)
+ content/messenger/accountcreation/views/email.mjs (views/email.mjs)
+ content/messenger/accountcreation/views/start.mjs (views/start.mjs)
diff --git a/comm/mail/components/accountcreation/moz.build b/comm/mail/components/accountcreation/moz.build
new file mode 100644
index 0000000000..fa8ce3c258
--- /dev/null
+++ b/comm/mail/components/accountcreation/moz.build
@@ -0,0 +1,23 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES.accountcreation += [
+ "AccountConfig.jsm",
+ "AccountCreationUtils.jsm",
+ "ConfigVerifier.jsm",
+ "CreateInBackend.jsm",
+ "ExchangeAutoDiscover.jsm",
+ "FetchConfig.jsm",
+ "FetchHTTP.jsm",
+ "GuessConfig.jsm",
+ "readFromXML.jsm",
+ "Sanitizer.jsm",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/xpcshell/xpcshell.ini",
+]
diff --git a/comm/mail/components/accountcreation/readFromXML.jsm b/comm/mail/components/accountcreation/readFromXML.jsm
new file mode 100644
index 0000000000..b853a81117
--- /dev/null
+++ b/comm/mail/components/accountcreation/readFromXML.jsm
@@ -0,0 +1,352 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["readFromXML"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountConfig",
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "AccountCreationUtils",
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Sanitizer",
+ "resource:///modules/accountcreation/Sanitizer.jsm"
+);
+
+/* eslint-disable complexity */
+/**
+ * Takes an XML snipplet (as JXON) and reads the values into
+ * a new AccountConfig object.
+ * It does so securely (or tries to), by trying to avoid remote execution
+ * and similar holes which can appear when reading too naively.
+ * Of course it cannot tell whether the actual values are correct,
+ * e.g. it can't tell whether the host name is a good server.
+ *
+ * The XML format is documented at
+ * <https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat>
+ *
+ * @param clientConfigXML {JXON} - The <clientConfig> node.
+ * @param source {String} - Used for the subSource field of AccountConfig.
+ * @returns AccountConfig object filled with the data from XML
+ */
+function readFromXML(clientConfigXML, subSource) {
+ function array_or_undef(value) {
+ return value === undefined ? [] : value;
+ }
+ var exception;
+ if (
+ typeof clientConfigXML != "object" ||
+ !("clientConfig" in clientConfigXML) ||
+ !("emailProvider" in clientConfigXML.clientConfig)
+ ) {
+ dump(
+ `client config xml = ${JSON.stringify(clientConfigXML).substr(0, 50)} \n`
+ );
+ let stringBundle = lazy.AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ );
+ throw stringBundle.GetStringFromName("no_emailProvider.error");
+ }
+ var xml = clientConfigXML.clientConfig.emailProvider;
+
+ var d = new lazy.AccountConfig();
+ d.source = lazy.AccountConfig.kSourceXML;
+ d.subSource = `xml-from-${subSource}`;
+
+ d.id = lazy.Sanitizer.hostname(xml["@id"]);
+ d.displayName = d.id;
+ try {
+ d.displayName = lazy.Sanitizer.label(xml.displayName);
+ } catch (e) {
+ console.error(e);
+ }
+ for (var domain of xml.$domain) {
+ try {
+ d.domains.push(lazy.Sanitizer.hostname(domain));
+ } catch (e) {
+ console.error(e);
+ exception = e;
+ }
+ }
+ if (d.domains.length == 0) {
+ throw exception ? exception : "need proper <domain> in XML";
+ }
+ exception = null;
+
+ // incoming server
+ for (let iX of array_or_undef(xml.$incomingServer)) {
+ // input (XML)
+ let iO = d.createNewIncoming(); // output (object)
+ try {
+ // throws if not supported
+ iO.type = lazy.Sanitizer.enum(iX["@type"], [
+ "pop3",
+ "imap",
+ "nntp",
+ "exchange",
+ ]);
+ iO.hostname = lazy.Sanitizer.hostname(iX.hostname);
+ iO.port = lazy.Sanitizer.integerRange(iX.port, 1, 65535);
+ // We need a username even for Kerberos, need it even internally.
+ iO.username = lazy.Sanitizer.string(iX.username); // may be a %VARIABLE%
+
+ if ("password" in iX) {
+ d.rememberPassword = true;
+ iO.password = lazy.Sanitizer.string(iX.password);
+ }
+
+ for (let iXsocketType of array_or_undef(iX.$socketType)) {
+ try {
+ iO.socketType = lazy.Sanitizer.translate(iXsocketType, {
+ plain: Ci.nsMsgSocketType.plain,
+ SSL: Ci.nsMsgSocketType.SSL,
+ STARTTLS: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ });
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (iO.socketType == -1) {
+ throw exception ? exception : "need proper <socketType> in XML";
+ }
+ exception = null;
+
+ for (let iXauth of array_or_undef(iX.$authentication)) {
+ try {
+ iO.auth = lazy.Sanitizer.translate(iXauth, {
+ "password-cleartext": Ci.nsMsgAuthMethod.passwordCleartext,
+ // @deprecated TODO remove
+ plain: Ci.nsMsgAuthMethod.passwordCleartext,
+ "password-encrypted": Ci.nsMsgAuthMethod.passwordEncrypted,
+ // @deprecated TODO remove
+ secure: Ci.nsMsgAuthMethod.passwordEncrypted,
+ GSSAPI: Ci.nsMsgAuthMethod.GSSAPI,
+ NTLM: Ci.nsMsgAuthMethod.NTLM,
+ OAuth2: Ci.nsMsgAuthMethod.OAuth2,
+ });
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (!iO.auth) {
+ throw exception ? exception : "need proper <authentication> in XML";
+ }
+ exception = null;
+
+ if (iO.type == "exchange") {
+ try {
+ if ("owaURL" in iX) {
+ iO.owaURL = lazy.Sanitizer.url(iX.owaURL);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ try {
+ if ("ewsURL" in iX) {
+ iO.ewsURL = lazy.Sanitizer.url(iX.ewsURL);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ try {
+ if ("easURL" in iX) {
+ iO.easURL = lazy.Sanitizer.url(iX.easURL);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ iO.oauthSettings = {
+ issuer: iO.hostname,
+ scope: iO.owaURL || iO.ewsURL || iO.easURL,
+ };
+ }
+ // defaults are in accountConfig.js
+ if (iO.type == "pop3" && "pop3" in iX) {
+ try {
+ if ("leaveMessagesOnServer" in iX.pop3) {
+ iO.leaveMessagesOnServer = lazy.Sanitizer.boolean(
+ iX.pop3.leaveMessagesOnServer
+ );
+ }
+ if ("daysToLeaveMessagesOnServer" in iX.pop3) {
+ iO.daysToLeaveMessagesOnServer = lazy.Sanitizer.integer(
+ iX.pop3.daysToLeaveMessagesOnServer
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ try {
+ if ("downloadOnBiff" in iX.pop3) {
+ iO.downloadOnBiff = lazy.Sanitizer.boolean(iX.pop3.downloadOnBiff);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ try {
+ if ("useGlobalPreferredServer" in iX) {
+ iO.useGlobalPreferredServer = lazy.Sanitizer.boolean(
+ iX.useGlobalPreferredServer
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // processed successfully, now add to result object
+ if (!d.incoming.hostname) {
+ // first valid
+ d.incoming = iO;
+ } else {
+ d.incomingAlternatives.push(iO);
+ }
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (!d.incoming.hostname) {
+ // throw exception for last server
+ throw exception ? exception : "Need proper <incomingServer> in XML file";
+ }
+ exception = null;
+
+ // outgoing server
+ for (let oX of array_or_undef(xml.$outgoingServer)) {
+ // input (XML)
+ let oO = d.createNewOutgoing(); // output (object)
+ try {
+ if (oX["@type"] != "smtp") {
+ let stringBundle = lazy.AccountCreationUtils.getStringBundle(
+ "chrome://messenger/locale/accountCreationModel.properties"
+ );
+ throw stringBundle.GetStringFromName("outgoing_not_smtp.error");
+ }
+ oO.hostname = lazy.Sanitizer.hostname(oX.hostname);
+ oO.port = lazy.Sanitizer.integerRange(oX.port, 1, 65535);
+
+ for (let oXsocketType of array_or_undef(oX.$socketType)) {
+ try {
+ oO.socketType = lazy.Sanitizer.translate(oXsocketType, {
+ plain: Ci.nsMsgSocketType.plain,
+ SSL: Ci.nsMsgSocketType.SSL,
+ STARTTLS: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ });
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (oO.socketType == -1) {
+ throw exception ? exception : "need proper <socketType> in XML";
+ }
+ exception = null;
+
+ for (let oXauth of array_or_undef(oX.$authentication)) {
+ try {
+ oO.auth = lazy.Sanitizer.translate(oXauth, {
+ // open relay
+ none: Ci.nsMsgAuthMethod.none,
+ // inside ISP or corp network
+ "client-IP-address": Ci.nsMsgAuthMethod.none,
+ // hope for the best
+ "smtp-after-pop": Ci.nsMsgAuthMethod.none,
+ "password-cleartext": Ci.nsMsgAuthMethod.passwordCleartext,
+ // @deprecated TODO remove
+ plain: Ci.nsMsgAuthMethod.passwordCleartext,
+ "password-encrypted": Ci.nsMsgAuthMethod.passwordEncrypted,
+ // @deprecated TODO remove
+ secure: Ci.nsMsgAuthMethod.passwordEncrypted,
+ GSSAPI: Ci.nsMsgAuthMethod.GSSAPI,
+ NTLM: Ci.nsMsgAuthMethod.NTLM,
+ OAuth2: Ci.nsMsgAuthMethod.OAuth2,
+ });
+
+ break; // take first that we support
+ } catch (e) {
+ exception = e;
+ }
+ }
+ if (!oO.auth) {
+ throw exception ? exception : "need proper <authentication> in XML";
+ }
+ exception = null;
+
+ if (
+ "username" in oX ||
+ // if password-based auth, we need a username,
+ // so go there anyways and throw.
+ oO.auth == Ci.nsMsgAuthMethod.passwordCleartext ||
+ oO.auth == Ci.nsMsgAuthMethod.passwordEncrypted
+ ) {
+ oO.username = lazy.Sanitizer.string(oX.username);
+ }
+
+ if ("password" in oX) {
+ d.rememberPassword = true;
+ oO.password = lazy.Sanitizer.string(oX.password);
+ }
+
+ try {
+ // defaults are in accountConfig.js
+ if ("addThisServer" in oX) {
+ oO.addThisServer = lazy.Sanitizer.boolean(oX.addThisServer);
+ }
+ if ("useGlobalPreferredServer" in oX) {
+ oO.useGlobalPreferredServer = lazy.Sanitizer.boolean(
+ oX.useGlobalPreferredServer
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // processed successfully, now add to result object
+ if (!d.outgoing.hostname) {
+ // first valid
+ d.outgoing = oO;
+ } else {
+ d.outgoingAlternatives.push(oO);
+ }
+ } catch (e) {
+ console.error(e);
+ exception = e;
+ }
+ }
+ if (!d.outgoing.hostname) {
+ // throw exception for last server
+ throw exception ? exception : "Need proper <outgoingServer> in XML file";
+ }
+ exception = null;
+
+ d.inputFields = [];
+ for (let inputField of array_or_undef(xml.$inputField)) {
+ try {
+ let fieldset = {
+ varname: lazy.Sanitizer.alphanumdash(inputField["@key"]).toUpperCase(),
+ displayName: lazy.Sanitizer.label(inputField["@label"]),
+ exampleValue: lazy.Sanitizer.label(inputField.value),
+ };
+ d.inputFields.push(fieldset);
+ } catch (e) {
+ console.error(e);
+ // For now, don't throw,
+ // because we don't support custom fields yet anyways.
+ }
+ }
+
+ return d;
+}
+/* eslint-enable complexity */
diff --git a/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml b/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml
new file mode 100644
index 0000000000..8b8c7c4ada
--- /dev/null
+++ b/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml
@@ -0,0 +1,158 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!--
+ List of all the account creation templates. Don't load any JavaScript or
+ CSS in here, those files are handled lazily in the controller and in the
+ various shadowRoots of the views.
+-->
+<html:template id="accountHubDialog" xmlns="http://www.w3.org/1999/xhtml">
+ <dialog class="account-hub-dialog">
+ <button id="closeButton" type="button">
+ <img src="" alt="" />
+ </button>
+ </dialog>
+</html:template>
+
+<html:template id="accountHubStart" xmlns="http://www.w3.org/1999/xhtml">
+ <header class="hub-header">
+ <div id="welcomeHeader" class="start-header" hidden="hidden">
+ <img src="chrome://branding/content/logo-gradient.svg" alt="" />
+ <h1 data-l10n-id="account-hub-welcome-line">
+ <span data-l10n-name="brand-name"></span>
+ </h1>
+ </div>
+ <div id="defaultHeader" class="start-header" hidden="hidden">
+ <img src="chrome://branding/content/logo-gradient.svg" alt="" />
+ <h1>
+ <span class="start-header-brand-name"
+ data-l10n-id="account-hub-brand"></span>
+ <span class="start-header-title"
+ data-l10n-id="account-hub-title"></span>
+ </h1>
+ </div>
+ </header>
+
+ <div class="hub-body">
+ <div class="hub-body-grid"></div>
+#ifdef NIGHTLY_BUILD
+ <button id="hubSyncButton"
+ data-l10n-id="account-hub-sync-button"
+ class="button button-flat"
+ type="button"
+ hidden="hidden"></button>
+#endif
+ </div>
+
+ <footer class="hub-footer">
+ <ul class="reset-list footer-links">
+ <li>
+ <a id="hubReleaseNotes"
+ data-l10n-id="account-hub-release-notes"></a>
+ </li>
+ <li>
+ <a href="https://support.mozilla.org/products/thunderbird"
+ data-l10n-id="account-hub-support"
+ onclick="openLinkExternally(this.href);"></a>
+ </li>
+ <li>
+ <a href="https://give.thunderbird.net/?utm_source=thunderbird_account_hub&amp;utm_medium=referral&amp;utm_content=hub_footer"
+ data-l10n-id="account-hub-donate"
+ onclick="openLinkExternally(this.href);"></a>
+ </li>
+ </ul>
+ </footer>
+</html:template>
+
+<html:template id="accountHubEmailSetup" xmlns="http://www.w3.org/1999/xhtml">
+ <form id="emailForm" class="account-hub-form">
+ <header class="hub-header">
+ <h1 class="sub-view-title" data-l10n-id="account-hub-email-title"></h1>
+ </header>
+
+ <div class="hub-body">
+ <label for="realName"
+ data-l10n-id="account-setup-name-label"
+ data-l10n-attrs="accesskey">
+ </label>
+ <div class="input-control">
+ <input id="realName" type="text"
+ class="input-field"
+ data-l10n-id="account-setup-name-input"
+ required="required" />
+ <img src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-name-info-icon"
+ alt=""
+ class="form-icon" />
+ <img src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-name-warning-icon"
+ alt=""
+ class="form-icon icon-warning" />
+ </div>
+
+ <label for="email"
+ data-l10n-id="account-setup-email-label"
+ data-l10n-attrs="accesskey">
+ </label>
+ <div class="input-control">
+ <input id="email" type="email"
+ data-l10n-id="account-setup-email-input"
+ class="input-field"
+ required="required" />
+ <img id="emailInfo"
+ src="chrome://messenger/skin/icons/new/compact/info.svg"
+ data-l10n-id="account-setup-email-info-icon"
+ alt=""
+ class="form-icon" />
+ <img id="emailWarning"
+ src="chrome://messenger/skin/icons/new/compact/warning.svg"
+ data-l10n-id="account-setup-email-warning-icon"
+ alt=""
+ class="form-icon icon-warning" />
+ </div>
+
+ <label for="password"
+ data-l10n-id="account-setup-password-label"
+ data-l10n-attrs="accesskey">
+ </label>
+ <div class="input-control">
+ <!-- Leave the placeholder empty for CSS visibility toggle of the
+ adjacent button. -->
+ <input id="password" type="password" class="input-field"
+ placeholder="" />
+ <button id="passwordToggleButton"
+ type="button"
+ data-l10n-id="account-setup-password-toggle-show"
+ class="form-toggle-button"
+ aria-pressed="false">
+ <img src="" alt="" class="form-icon" />
+ </button>
+ </div>
+
+ <div class="remember-button-container">
+ <label class="toggle-container-with-text">
+ <input id="rememberPassword" type="checkbox" checked="checked" />
+ <span data-l10n-id="account-setup-remember-password"
+ data-l10n-attrs="accesskey">
+ </span>
+ </label>
+ </div>
+ </div>
+
+ <footer class="hub-footer">
+ <menu class="dialog-menu-container two-columns">
+ <li>
+ <button id="emailGoBackButton" type="button"
+ data-l10n-id="account-hub-email-cancel-button"></button>
+ </li>
+ <li>
+ <button id="emailContinueButton" type="submit"
+ data-l10n-id="account-hub-email-continue-button"
+ class="primary"
+ disabled="disabled"></button>
+ </li>
+ </menu>
+ </footer>
+ </form>
+</html:template>
diff --git a/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml b/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml
new file mode 100644
index 0000000000..871ce732e2
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml
@@ -0,0 +1,21 @@
+<clientConfig version="1.1">
+ <emailProvider id="example.com">
+ <domain>example.com</domain>
+ <displayName>example.com</displayName>
+ <displayShortName>example.com</displayShortName>
+ <incomingServer type="pop3">
+ <hostname>pop.example.com</hostname>
+ <port>995</port>
+ <socketType>SSL</socketType>
+ <authentication>plain</authentication>
+ <username>%EMAILLOCALPART%</username>
+ </incomingServer>
+ <outgoingServer type="smtp">
+ <hostname>smtp.example.com</hostname>
+ <port>587</port>
+ <socketType>STARTTLS</socketType>
+ <username>%EMAILADDRESS%</username>
+ <authentication>plain</authentication>
+ </outgoingServer>
+ </emailProvider>
+</clientConfig>
diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js
new file mode 100644
index 0000000000..5b57a42ebb
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js
@@ -0,0 +1,76 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests getting a configuration file from the local isp directory and
+ * reading that file.
+ */
+
+// Globals
+
+var { AccountConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+var { FetchConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/FetchConfig.jsm"
+);
+
+var kXMLFile = "example.com.xml";
+var fetchConfigAbortable;
+var copyLocation;
+
+function onTestSuccess(config) {
+ // Check that we got the expected config.
+ AccountConfig.replaceVariables(
+ config,
+ "Yamato Nadeshiko",
+ "yamato.nadeshiko@example.com",
+ "abc12345"
+ );
+
+ Assert.equal(config.incoming.username, "yamato.nadeshiko");
+ Assert.equal(config.outgoing.username, "yamato.nadeshiko@example.com");
+ Assert.equal(config.incoming.hostname, "pop.example.com");
+ Assert.equal(config.outgoing.hostname, "smtp.example.com");
+ Assert.equal(config.identity.realname, "Yamato Nadeshiko");
+ Assert.equal(config.identity.emailAddress, "yamato.nadeshiko@example.com");
+
+ Assert.equal(config.subSource, "xml-from-disk");
+
+ do_test_finished();
+}
+
+function onTestFailure(e) {
+ do_throw(e);
+}
+
+function run_test() {
+ registerCleanupFunction(finish_test);
+
+ // Copy the xml file into place
+ let file = do_get_file("data/" + kXMLFile);
+
+ copyLocation = Services.dirsvc.get("CurProcD", Ci.nsIFile);
+ copyLocation.append("isp");
+
+ file.copyTo(copyLocation, kXMLFile);
+
+ do_test_pending();
+
+ // Now run the actual test
+ // Note we keep a global copy of this so that the abortable doesn't get
+ // garbage collected before the async operation has finished.
+ fetchConfigAbortable = FetchConfig.fromDisk(
+ "example.com",
+ onTestSuccess,
+ onTestFailure
+ );
+}
+
+function finish_test() {
+ // Remove the test config file
+ copyLocation.append(kXMLFile);
+ copyLocation.remove(false);
+}
diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js
new file mode 100644
index 0000000000..763084f750
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js
@@ -0,0 +1,319 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Tests for GuessConfig.jsm
+ *
+ * Currently tested:
+ * - getHostEntry function.
+ * - getIncomingTryOrder function.
+ * - getOutgoingTryOrder function.
+ *
+ * TODO:
+ * - Test the returned CMDS.
+ * - Figure out what else to test.
+ */
+
+// Globals
+
+var { GuessConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/GuessConfig.jsm"
+);
+
+var {
+ UNKNOWN,
+ IMAP,
+ POP,
+ SMTP,
+ NONE,
+ STARTTLS,
+ SSL,
+ getHostEntry,
+ getIncomingTryOrder,
+ getOutgoingTryOrder,
+} = GuessConfig;
+
+/*
+ * UTILITIES
+ */
+
+function assert_equal(aA, aB, aWhy) {
+ if (aA != aB) {
+ do_throw(aWhy);
+ }
+ Assert.equal(aA, aB);
+}
+
+/**
+ * Test that two host entries are the same, ignoring the commands.
+ */
+function assert_equal_host_entries(hostEntry, expected) {
+ assert_equal(hostEntry.protocol, expected[0], "Protocols are different");
+ assert_equal(hostEntry.socketType, expected[1], "SSL values are different");
+ assert_equal(hostEntry.port, expected[2], "Port values are different");
+}
+
+/**
+ * Assert that the list of tryOrders are the same.
+ */
+function assert_equal_try_orders(aA, aB) {
+ assert_equal(aA.length, aB.length, "tryOrders have different length");
+ for (let [i, subA] of aA.entries()) {
+ let subB = aB[i];
+ assert_equal_host_entries(subA, subB);
+ }
+}
+
+/**
+ * Check that the POP calculations are correct for a given host and
+ * protocol.
+ */
+function checkPop(host, protocol) {
+ // The list of protocol+ssl+port configurations should match
+ // getIncomingTryOrder() in guessConfig.js.
+
+ // port == UNKNOWN
+ // [POP, STARTTLS, 110], [POP, SSL, 995], [POP, NONE, 110]
+ // port != UNKNOWN
+ // ssl == UNKNOWN
+ // [POP, STARTTLS, port], [POP, SSL, port], [POP, NONE, port]
+ // ssl != UNKNOWN
+ // [POP, ssl, port]
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [POP, STARTTLS, 110],
+ [POP, SSL, 995],
+ [POP, NONE, 110],
+ ]);
+
+ ssl = STARTTLS;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, 110]]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, 995]]);
+
+ ssl = NONE;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, 110]]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [POP, STARTTLS, port],
+ [POP, SSL, port],
+ [POP, NONE, port],
+ ]);
+
+ for (ssl in [STARTTLS, SSL, NONE]) {
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[POP, ssl, port]]);
+ }
+}
+
+/**
+ * Check that the IMAP calculations are correct for a given host and
+ * protocol.
+ */
+function checkImap(host, protocol) {
+ // The list of protocol+ssl+port configurations should match
+ // getIncomingTryOrder() in guessConfig.js.
+
+ // port == UNKNOWN
+ // [IMAP, STARTTLS, 143], [IMAP, SSL, 993], [IMAP, NONE, 143]
+ // port != UNKNOWN
+ // ssl == UNKNOWN
+ // [IMAP, STARTTLS, port], [IMAP, SSL, port], [IMAP, NONE, port]
+ // ssl != UNKNOWN
+ // [IMAP, ssl, port];
+
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, 143],
+ [IMAP, SSL, 993],
+ [IMAP, NONE, 143],
+ ]);
+
+ ssl = STARTTLS;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, 143]]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, 993]]);
+
+ ssl = NONE;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, 143]]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, port],
+ [IMAP, SSL, port],
+ [IMAP, NONE, port],
+ ]);
+
+ for (ssl in [STARTTLS, SSL, NONE]) {
+ tryOrder = getIncomingTryOrder(host, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[IMAP, ssl, port]]);
+ }
+}
+
+/*
+ * TESTS
+ */
+
+/**
+ * Test that getHostEntry returns the correct port numbers.
+ *
+ * TODO:
+ * - Test the returned commands as well.
+ */
+function test_getHostEntry() {
+ // IMAP port numbers.
+ assert_equal_host_entries(getHostEntry(IMAP, STARTTLS, UNKNOWN), [
+ IMAP,
+ STARTTLS,
+ 143,
+ ]);
+ assert_equal_host_entries(getHostEntry(IMAP, SSL, UNKNOWN), [IMAP, SSL, 993]);
+ assert_equal_host_entries(getHostEntry(IMAP, NONE, UNKNOWN), [
+ IMAP,
+ NONE,
+ 143,
+ ]);
+
+ // POP port numbers.
+ assert_equal_host_entries(getHostEntry(POP, STARTTLS, UNKNOWN), [
+ POP,
+ STARTTLS,
+ 110,
+ ]);
+ assert_equal_host_entries(getHostEntry(POP, SSL, UNKNOWN), [POP, SSL, 995]);
+ assert_equal_host_entries(getHostEntry(POP, NONE, UNKNOWN), [POP, NONE, 110]);
+
+ // SMTP port numbers.
+ assert_equal_host_entries(getHostEntry(SMTP, STARTTLS, UNKNOWN), [
+ SMTP,
+ STARTTLS,
+ 587,
+ ]);
+ assert_equal_host_entries(getHostEntry(SMTP, SSL, UNKNOWN), [SMTP, SSL, 465]);
+ assert_equal_host_entries(getHostEntry(SMTP, NONE, UNKNOWN), [
+ SMTP,
+ NONE,
+ 587,
+ ]);
+}
+
+/**
+ * Test the getIncomingTryOrder method.
+ */
+function test_getIncomingTryOrder() {
+ // The list of protocol+ssl+port configurations should match
+ // getIncomingTryOrder() in guessConfig.js.
+
+ // protocol == POP || host starts with pop. || host starts with pop3.
+ checkPop("example.com", POP);
+ checkPop("pop.example.com", UNKNOWN);
+ checkPop("pop3.example.com", UNKNOWN);
+ checkPop("imap.example.com", POP);
+
+ // protocol == IMAP || host starts with imap.
+ checkImap("example.com", IMAP);
+ checkImap("imap.example.com", UNKNOWN);
+ checkImap("pop.example.com", IMAP);
+
+ let domain = "example.com";
+ let protocol = UNKNOWN;
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, 143],
+ [IMAP, SSL, 993],
+ [POP, STARTTLS, 110],
+ [POP, SSL, 995],
+ [IMAP, NONE, 143],
+ [POP, NONE, 110],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, SSL, 993],
+ [POP, SSL, 995],
+ ]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, STARTTLS, port],
+ [IMAP, SSL, port],
+ [POP, STARTTLS, port],
+ [POP, SSL, port],
+ [IMAP, NONE, port],
+ [POP, NONE, port],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getIncomingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [IMAP, SSL, port],
+ [POP, SSL, port],
+ ]);
+}
+
+/**
+ * Test the getOutgoingTryOrder method.
+ */
+function test_getOutgoingTryOrder() {
+ // The list of protocol+ssl+port configurations should match
+ // getOutgoingTryOrder() in guessConfig.js.
+ let domain = "example.com";
+ let protocol = SMTP;
+ let ssl = UNKNOWN;
+ let port = UNKNOWN;
+ let tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [SMTP, STARTTLS, 587],
+ [SMTP, STARTTLS, 25],
+ [SMTP, SSL, 465],
+ [SMTP, NONE, 587],
+ [SMTP, NONE, 25],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[SMTP, SSL, 465]]);
+
+ ssl = UNKNOWN;
+ port = 31337;
+ tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [
+ [SMTP, STARTTLS, port],
+ [SMTP, SSL, port],
+ [SMTP, NONE, port],
+ ]);
+
+ ssl = SSL;
+ tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port);
+ assert_equal_try_orders(tryOrder, [[SMTP, SSL, port]]);
+}
+
+function run_test() {
+ test_getHostEntry();
+ test_getIncomingTryOrder();
+ test_getOutgoingTryOrder();
+}
diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js
new file mode 100644
index 0000000000..919b0ffc2f
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js
@@ -0,0 +1,266 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests accountcreation/readFromXML.js , reading the XML files
+ * containing a mail configuration.
+ *
+ * To allow forwards-compatibility (add new stuff in the future without
+ * breaking old clients on the new files), we are now fairly tolerant when
+ * reading and allow fallback mechanisms. This test checks whether that works,
+ * and of course also whether we can read a normal config and get the proper
+ * values.
+ */
+
+// Globals
+
+var { AccountConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+);
+var { readFromXML } = ChromeUtils.import(
+ "resource:///modules/accountcreation/readFromXML.jsm"
+);
+
+var { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+/*
+ * UTILITIES
+ */
+
+function assert_equal(aA, aB, aWhy) {
+ if (aA != aB) {
+ do_throw(aWhy);
+ }
+ Assert.equal(aA, aB);
+}
+
+/**
+ * Test that two config entries are the same.
+ */
+function assert_equal_config(aA, aB, field) {
+ assert_equal(aA, aB, "Configured " + field + " is incorrect.");
+}
+
+/*
+ * TESTS
+ */
+
+/**
+ * Test that the xml reader returns a proper config and
+ * is also forwards-compatible to new additions to the data format.
+ */
+function test_readFromXML_config1() {
+ var clientConfigXML =
+ "<clientConfig>" +
+ '<emailProvider id="example.com">' +
+ "<domain>example.com</domain>" +
+ "<domain>example.net</domain>" +
+ "<displayName>Example</displayName>" +
+ "<displayShortName>Example Mail</displayShortName>" +
+ // 1. - protocol not supported
+ '<incomingServer type="imap5">' +
+ "<hostname>badprotocol.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>ssl-client-cert</authentication>" +
+ "</incomingServer>" +
+ // 2. - socket type not supported
+ '<incomingServer type="imap">' +
+ "<hostname>badsocket.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>key-from-DNSSEC</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>password-cleartext</authentication>" +
+ "</incomingServer>" +
+ // 3. - first supported incoming server
+ '<incomingServer type="imap">' +
+ "<hostname>imapmail.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>password-cleartext</authentication>" +
+ "</incomingServer>" +
+ // 4. - auth method not supported
+ '<incomingServer type="imap">' +
+ "<hostname>badauth.example.com</hostname>" +
+ "<port>993</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>ssl-client-cert</authentication>" +
+ // Throw in some elements we don"t support yet
+ "<imap>" +
+ '<rootFolder path="INBOX."/>' +
+ '<specialFolder id="sent" path="INBOX.Sent Mail"/>' +
+ "</imap>" +
+ "</incomingServer>" +
+ // 5. - second supported incoming server
+ '<incomingServer type="pop3">' +
+ "<hostname>popmail.example.com</hostname>" +
+ // alternative hostname, not yet supported, should be ignored
+ "<hostname>popbackup.example.com</hostname>" +
+ "<port>110</port>" +
+ "<port>7878</port>" +
+ // unsupported socket type
+ "<socketType>GSSAPI2</socketType>" +
+ // but fall back
+ "<socketType>plain</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<username>%EMAILADDRESS%</username>" +
+ // unsupported auth method
+ "<authentication>GSSAPI2</authentication>" +
+ // but fall back
+ "<authentication>password-encrypted</authentication>" +
+ "<pop3>" +
+ "<leaveMessagesOnServer>true</leaveMessagesOnServer>" +
+ "<daysToLeaveMessagesOnServer>999</daysToLeaveMessagesOnServer>" +
+ "</pop3>" +
+ "</incomingServer>" +
+ // outgoing server with invalid auth method
+ '<outgoingServer type="smtp">' +
+ "<hostname>badauth.example.com</hostname>" +
+ "<port>587</port>" +
+ "<socketType>STARTTLS</socketType>" +
+ "<username>%EMAILADDRESS%</username>" +
+ "<authentication>smtp-after-imap</authentication>" +
+ "</outgoingServer>" +
+ // outgoing server - supported
+ '<outgoingServer type="smtp">' +
+ "<hostname>smtpout.example.com</hostname>" +
+ "<hostname>smtpfallback.example.com</hostname>" +
+ "<port>587</port>" +
+ "<port>7878</port>" +
+ "<socketType>GSSAPI2</socketType>" +
+ "<socketType>STARTTLS</socketType>" +
+ "<username>%EMAILADDRESS%</username>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>GSSAPI2</authentication>" +
+ "<authentication>client-IP-address</authentication>" +
+ "<smtp/>" +
+ "</outgoingServer>" +
+ // Throw in some more elements we don"t support yet
+ '<enableURL url="http://foobar"/>' +
+ '<instructionsURL url="http://foobar"/>' +
+ "</emailProvider>" +
+ "</clientConfig>";
+
+ var domParser = new DOMParser();
+ var config = readFromXML(
+ JXON.build(domParser.parseFromString(clientConfigXML, "text/xml"))
+ );
+
+ Assert.equal(config instanceof AccountConfig, true);
+ Assert.equal("example.com", config.id);
+ Assert.equal("Example", config.displayName);
+ Assert.notEqual(-1, config.domains.indexOf("example.com"));
+ // 1. incoming server skipped because of an unsupported protocol
+ // 2. incoming server skipped because of an so-far unknown auth method
+ // 3. incoming server is fine for us: IMAP, SSL, cleartext password
+ let server = config.incoming;
+ Assert.equal("imapmail.example.com", server.hostname);
+ Assert.equal("imap", server.type);
+ Assert.equal(Ci.nsMsgSocketType.SSL, server.socketType);
+ Assert.equal(3, server.auth); // cleartext password
+ // only one more supported incoming server
+ Assert.equal(1, config.incomingAlternatives.length);
+ // 4. incoming server skipped because of an so-far unknown socketType
+ // 5. server: POP
+ server = config.incomingAlternatives[0];
+ Assert.equal("popmail.example.com", server.hostname);
+ Assert.equal("pop3", server.type);
+ Assert.equal(Ci.nsMsgSocketType.plain, server.socketType);
+ Assert.equal(4, server.auth); // encrypted password
+
+ // SMTP server, most preferred
+ server = config.outgoing;
+ Assert.equal("smtpout.example.com", server.hostname);
+ Assert.equal("smtp", server.type);
+ Assert.equal(Ci.nsMsgSocketType.alwaysSTARTTLS, server.socketType);
+ Assert.equal(1, server.auth); // no auth
+ // no other SMTP servers
+ Assert.equal(0, config.outgoingAlternatives.length);
+}
+
+/**
+ * Test the replaceVariables method.
+ */
+function test_replaceVariables() {
+ var clientConfigXML =
+ "<clientConfig>" +
+ '<emailProvider id="example.com">' +
+ "<domain>example.com</domain>" +
+ "<displayName>example.com</displayName>" +
+ "<displayShortName>example.com</displayShortName>" +
+ '<incomingServer type="pop3">' +
+ "<hostname>pop.%EMAILDOMAIN%</hostname>" +
+ "<port>995</port>" +
+ "<socketType>SSL</socketType>" +
+ "<username>%EMAILLOCALPART%</username>" +
+ "<authentication>plain</authentication>" +
+ "<pop3>" +
+ "<leaveMessagesOnServer>true</leaveMessagesOnServer>" +
+ "<daysToLeaveMessagesOnServer>999</daysToLeaveMessagesOnServer>" +
+ "</pop3>" +
+ "</incomingServer>" +
+ '<outgoingServer type="smtp">' +
+ "<hostname>smtp.example.com</hostname>" +
+ "<port>587</port>" +
+ "<socketType>STARTTLS</socketType>" +
+ "<username>%EMAILADDRESS%</username>" +
+ "<authentication>plain</authentication>" +
+ "<addThisServer>true</addThisServer>" +
+ "<useGlobalPreferredServer>false</useGlobalPreferredServer>" +
+ "</outgoingServer>" +
+ "</emailProvider>" +
+ "</clientConfig>";
+
+ var domParser = new DOMParser();
+ var config = readFromXML(
+ JXON.build(domParser.parseFromString(clientConfigXML, "text/xml"))
+ );
+
+ AccountConfig.replaceVariables(
+ config,
+ "Yamato Nadeshiko",
+ "yamato.nadeshiko@example.com",
+ "abc12345"
+ );
+
+ assert_equal_config(
+ config.incoming.username,
+ "yamato.nadeshiko",
+ "incoming server username"
+ );
+ assert_equal_config(
+ config.outgoing.username,
+ "yamato.nadeshiko@example.com",
+ "outgoing server username"
+ );
+ assert_equal_config(
+ config.incoming.hostname,
+ "pop.example.com",
+ "incoming server hostname"
+ );
+ assert_equal_config(
+ config.outgoing.hostname,
+ "smtp.example.com",
+ "outgoing server hostname"
+ );
+ assert_equal_config(
+ config.identity.realname,
+ "Yamato Nadeshiko",
+ "user real name"
+ );
+ assert_equal_config(
+ config.identity.emailAddress,
+ "yamato.nadeshiko@example.com",
+ "user email address"
+ );
+}
+
+function run_test() {
+ test_readFromXML_config1();
+ test_replaceVariables();
+}
diff --git a/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini b/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..8d42ab2145
--- /dev/null
+++ b/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+head =
+tail =
+support-files = data/*
+
+[test_autoconfigFetchDisk.js]
+skip-if = os == 'win' && msix # MSIX cannot write to application directory
+[test_autoconfigUtils.js]
+[test_autoconfigXML.js]
diff --git a/comm/mail/components/accountcreation/views/container.mjs b/comm/mail/components/accountcreation/views/container.mjs
new file mode 100644
index 0000000000..3bf8d9b4dc
--- /dev/null
+++ b/comm/mail/components/accountcreation/views/container.mjs
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Custom Element containing the main account hub dialog. Used to append the
+ * needed CSS files to the shadowDom to prevent style leakage.
+ * NOTE: This could directly extend an HTMLDialogElement if it had a shadowRoot.
+ */
+class AccountHubContainer extends HTMLElement {
+ /** @type {HTMLDialogElement} */
+ modal;
+
+ /** @type {DOMLocalization} */
+ l10n;
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ // Already connected, no need to run it again.
+ return;
+ }
+
+ const shadowRoot = this.attachShadow({ mode: "open" });
+
+ // Load styles in the shadowRoot so we don't leak it.
+ let style = document.createElement("link");
+ style.rel = "stylesheet";
+ style.href = "chrome://messenger/skin/accountHub.css";
+ shadowRoot.appendChild(style);
+
+ let template = document.getElementById("accountHubDialog");
+ let clonedNode = template.content.cloneNode(true);
+ shadowRoot.appendChild(clonedNode);
+ this.modal = shadowRoot.querySelector("dialog");
+
+ // We need to create an internal DOM localization in order to let fluent
+ // see the IDs inside our shadowRoot.
+ this.l10n = new DOMLocalization([
+ "branding/brand.ftl",
+ "messenger/accountcreation/accountHub.ftl",
+ "messenger/accountcreation/accountSetup.ftl",
+ ]);
+ this.l10n.connectRoot(shadowRoot);
+ }
+
+ disconnectedCallback() {
+ this.l10n.disconnectRoot(this.shadowRoot);
+ }
+}
+customElements.define("account-hub-container", AccountHubContainer);
diff --git a/comm/mail/components/accountcreation/views/email.mjs b/comm/mail/components/accountcreation/views/email.mjs
new file mode 100644
index 0000000000..f5a4d628b7
--- /dev/null
+++ b/comm/mail/components/accountcreation/views/email.mjs
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+class AccountHubEmail extends HTMLElement {
+ /**
+ * The email setup form.
+ *
+ * @type {HTMLFormElement}
+ */
+ #form;
+
+ /**
+ * The account name field.
+ *
+ * @type {HTMLInputElement}
+ */
+ #realName;
+
+ /**
+ * The email field.
+ *
+ * @type {HTMLInputElement}
+ */
+ #email;
+
+ /**
+ * The password field.
+ *
+ * @type {HTMLInputElement}
+ */
+ #password;
+
+ /**
+ * The password visibility button.
+ *
+ * @type {HTMLButtonElement}
+ */
+ #passwordToggleButton;
+
+ /**
+ * The submit form button.
+ *
+ * @type {HTMLButtonElement}
+ */
+ #continueButton;
+
+ /**
+ * The domain name extrapolated from the email address.
+ *
+ * @type {string}
+ */
+ #domain = "";
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.classList.add("account-hub-view");
+
+ let template = document.getElementById("accountHubEmailSetup");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.#form = this.querySelector("form");
+ this.#realName = this.querySelector("#realName");
+ this.#email = this.querySelector("#email");
+ this.#password = this.querySelector("#password");
+ this.#passwordToggleButton = this.querySelector("#passwordToggleButton");
+ this.#continueButton = this.querySelector("#emailContinueButton");
+
+ this.initUI();
+
+ this.setupEventListeners();
+ }
+
+ /**
+ * Initialize the UI of the email setup flow.
+ */
+ initUI() {
+ // Populate the account name if we can get some user info.
+ if ("@mozilla.org/userinfo;1" in Cc) {
+ let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo);
+ this.#realName.value = userInfo.fullname;
+ }
+
+ this.#realName.focus();
+ }
+
+ /**
+ * Set up the event listeners for this workflow only once.
+ */
+ setupEventListeners() {
+ this.#form.addEventListener("submit", event => {
+ event.preventDefault();
+ event.stopPropagation();
+ console.log("submit");
+ });
+
+ this.#realName.addEventListener("input", () => this.#checkValidForm());
+ this.#email.addEventListener("input", () => this.#checkValidForm());
+ this.#password.addEventListener("input", () => this.#onPasswordInput());
+
+ this.#passwordToggleButton.addEventListener("click", event => {
+ this.#togglePasswordInput(
+ event.target.getAttribute("aria-pressed") === "false"
+ );
+ });
+
+ // Set the Cancel/Back button.
+ this.querySelector("#emailGoBackButton").addEventListener("click", () => {
+ // If in first view, go back to start, otherwise go back in the flow.
+ this.dispatchEvent(
+ new CustomEvent("open-view", {
+ bubbles: true,
+ composed: true,
+ detail: { type: "START" },
+ })
+ );
+ });
+ }
+
+ /**
+ * Check whether the user entered the minimum amount of information needed to
+ * leave the first view and is allowed to proceed to the detection step.
+ */
+ #checkValidForm() {
+ const isValidForm =
+ this.#email.checkValidity() && this.#realName.checkValidity();
+ this.#domain = isValidForm
+ ? this.#email.value.split("@")[1].toLowerCase()
+ : "";
+
+ this.#continueButton.disabled = !isValidForm;
+ }
+
+ /**
+ * Handle the password visibility toggle on password input.
+ */
+ #onPasswordInput() {
+ if (!this.#password.value) {
+ this.#togglePasswordInput(false);
+ }
+ }
+
+ /**
+ * Toggle the password field type between `password` and `text` to allow users
+ * reading their typed password.
+ *
+ * @param {boolean} show - If the password field should become a text field.
+ */
+ #togglePasswordInput(show) {
+ this.#password.type = show ? "text" : "password";
+ this.#passwordToggleButton.setAttribute("aria-pressed", show.toString());
+ document.l10n.setAttributes(
+ this.#passwordToggleButton,
+ show
+ ? "account-setup-password-toggle-hide"
+ : "account-setup-password-toggle-show"
+ );
+ }
+
+ /**
+ * Check if any operation is currently in process and return true only if we
+ * can leave this view.
+ *
+ * @returns {boolean} - If the account hub can remove this view.
+ */
+ reset() {
+ // TODO
+ // Check for:
+ // - Non-abortable operations (autoconfig, email account setup, etc)
+
+ this.#form.reset();
+ this.#togglePasswordInput(false);
+ // TODO
+ // Before resetting we need to:
+ // - Clean up the fields.
+ // - Reset the autoconfig (cached server info).
+ // - Reset the view to the initial screen.
+ return true;
+ }
+}
+customElements.define("account-hub-email", AccountHubEmail);
diff --git a/comm/mail/components/accountcreation/views/start.mjs b/comm/mail/components/accountcreation/views/start.mjs
new file mode 100644
index 0000000000..66487cc928
--- /dev/null
+++ b/comm/mail/components/accountcreation/views/start.mjs
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global gSync */
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+class AccountHubStart extends HTMLElement {
+ #accounts = [
+ {
+ id: "email",
+ l10n: "account-hub-email-setup-button",
+ type: "MAIL",
+ },
+ {
+ id: "calendar",
+ l10n: "account-hub-calendar-setup-button",
+ type: "CALENDAR",
+ },
+ {
+ id: "addressBook",
+ l10n: "account-hub-address-book-setup-button",
+ type: "ADDRESS_BOOK",
+ },
+ {
+ id: "chat",
+ l10n: "account-hub-chat-setup-button",
+ type: "CHAT",
+ },
+ {
+ id: "feed",
+ l10n: "account-hub-feed-setup-button",
+ type: "FEED",
+ },
+ {
+ id: "newsgroup",
+ l10n: "account-hub-newsgroup-setup-button",
+ type: "NNTP",
+ },
+ // TODO: Import/Export of profiles is kinda broken so we don't want to
+ // expose it so much for now.
+ // {
+ // id: "import",
+ // l10n: "account-hub-import-setup-button",
+ // type: "IMPORT",
+ // },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.classList.add("account-hub-view");
+
+ let template = document.getElementById("accountHubStart");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.initUI();
+
+ this.setupAccountFlows();
+ }
+
+ /**
+ * Update the UI to reflect reality whenever this view is triggered.
+ */
+ initUI() {
+ const hasAccounts = MailServices.accounts.accounts.length;
+ this.querySelector("#welcomeHeader").hidden = hasAccounts;
+ this.querySelector("#defaultHeader").hidden = !hasAccounts;
+
+ if (AppConstants.NIGHTLY_BUILD) {
+ this.updateFxAButton();
+ }
+
+ // Hide the release notes link for nightly builds since we don't have any.
+ if (AppConstants.NIGHTLY_BUILD) {
+ this.querySelector("#hubReleaseNotes").closest("li").hidden = true;
+ return;
+ }
+
+ if (
+ Services.prefs.getPrefType("app.releaseNotesURL") !=
+ Services.prefs.PREF_INVALID
+ ) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL"
+ );
+ if (relNotesURL != "about:blank") {
+ this.querySelector("#hubReleaseNotes").href = relNotesURL;
+ return;
+ }
+ // Hide the release notes link if we don't have a URL to add.
+ this.querySelector("#hubReleaseNotes").closest("li").hidden = true;
+ }
+ }
+
+ /**
+ * Populate the main container fo the start view with all the available
+ * account creation flows.
+ */
+ setupAccountFlows() {
+ const fragment = new DocumentFragment();
+ for (const account of this.#accounts) {
+ const button = document.createElement("button");
+ button.id = `${account.id}Button`;
+ button.classList.add("button", "button-account");
+ document.l10n.setAttributes(button, account.l10n);
+ button.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("open-view", {
+ bubbles: true,
+ composed: true,
+ detail: {
+ type: account.type,
+ },
+ })
+ );
+ });
+ fragment.append(button);
+ }
+ this.querySelector(".hub-body-grid").replaceChildren(fragment);
+
+ if (AppConstants.NIGHTLY_BUILD) {
+ this.querySelector("#hubSyncButton").addEventListener("click", () => {
+ // FIXME: Open this in a dialog or browser inside the modal, or find a
+ // way to close the account hub without an account and open it again in
+ // case the FxA login fails to set up accounts.
+ gSync.initFxA();
+ });
+ }
+ }
+
+ /**
+ * Set up the Firefox Sync button.
+ */
+ updateFxAButton() {
+ const state = UIState.get();
+ this.querySelector("#hubSyncButton").hidden =
+ state.status == UIState.STATUS_SIGNED_IN;
+ }
+
+ /**
+ * The start view doesn't have any abortable operation that needs to be
+ * checked, so we always return true.
+ *
+ * @returns {boolean} - Always true.
+ */
+ reset() {
+ return true;
+ }
+}
+customElements.define("account-hub-start", AccountHubStart);
diff --git a/comm/mail/components/activity/Activity.jsm b/comm/mail/components/activity/Activity.jsm
new file mode 100644
index 0000000000..1b23efe1c7
--- /dev/null
+++ b/comm/mail/components/activity/Activity.jsm
@@ -0,0 +1,322 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["ActivityProcess", "ActivityEvent", "ActivityWarning"];
+
+// Base class for ActivityProcess and ActivityEvent objects
+
+function Activity() {
+ this._initLogging();
+ this._listeners = [];
+ this._subjects = [];
+}
+
+Activity.prototype = {
+ id: -1,
+ bindingName: "",
+ iconClass: "",
+ groupingStyle: Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT,
+ facet: "",
+ displayText: "",
+ initiator: null,
+ contextType: "",
+ context: "",
+ contextObj: null,
+
+ _initLogging() {
+ this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ });
+ },
+
+ addListener(aListener) {
+ this._listeners.push(aListener);
+ },
+
+ removeListener(aListener) {
+ for (let i = 0; i < this._listeners.length; i++) {
+ if (this._listeners[i] == aListener) {
+ this._listeners.splice(i, 1);
+ break;
+ }
+ }
+ },
+
+ addSubject(aSubject) {
+ this._subjects.push(aSubject);
+ },
+
+ getSubjects() {
+ return this._subjects.slice();
+ },
+};
+
+function ActivityProcess() {
+ Activity.call(this);
+ this.bindingName = "activity-process-item";
+ this.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+}
+
+ActivityProcess.prototype = {
+ __proto__: Activity.prototype,
+
+ percentComplete: -1,
+ lastStatusText: "",
+ workUnitComplete: 0,
+ totalWorkUnits: 0,
+ startTime: Date.now(),
+ _cancelHandler: null,
+ _pauseHandler: null,
+ _retryHandler: null,
+ _state: Ci.nsIActivityProcess.STATE_INPROGRESS,
+
+ init(aDisplayText, aInitiator) {
+ this.displayText = aDisplayText;
+ this.initiator = aInitiator;
+ },
+
+ get state() {
+ return this._state;
+ },
+
+ set state(val) {
+ if (val == this._state) {
+ return;
+ }
+
+ // test validity of the new state
+ //
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_INPROGRESS &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORRETRY ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORINPUT ||
+ val == Ci.nsIActivityProcess.STATE_PAUSED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ // we cannot change the state after the activity is completed,
+ // or it is canceled.
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ this._state == Ci.nsIActivityProcess.STATE_CANCELED
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_PAUSED &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ val == Ci.nsIActivityProcess.STATE_INPROGRESS ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORRETRY ||
+ val == Ci.nsIActivityProcess.STATE_WAITINGFORINPUT ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_WAITINGFORINPUT &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_INPROGRESS ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ if (
+ this._state == Ci.nsIActivityProcess.STATE_WAITINGFORRETRY &&
+ !(
+ val == Ci.nsIActivityProcess.STATE_INPROGRESS ||
+ val == Ci.nsIActivityProcess.STATE_CANCELED
+ )
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ let oldState = this._state;
+ this._state = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onStateChanged listeners");
+ for (let value of this._listeners) {
+ try {
+ value.onStateChanged(this, oldState);
+ } catch (e) {
+ this.log.error("Exception thrown by onStateChanged listener: " + e);
+ }
+ }
+ },
+
+ setProgress(aStatusText, aWorkUnitsComplete, aTotalWorkUnits) {
+ if (aTotalWorkUnits == 0) {
+ this.percentComplete = -1;
+ this.workUnitComplete = 0;
+ this.totalWorkUnits = 0;
+ } else {
+ this.percentComplete = parseInt(
+ (100.0 * aWorkUnitsComplete) / aTotalWorkUnits
+ );
+ this.workUnitComplete = aWorkUnitsComplete;
+ this.totalWorkUnits = aTotalWorkUnits;
+ }
+ this.lastStatusText = aStatusText;
+
+ // notify listeners
+ for (let value of this._listeners) {
+ try {
+ value.onProgressChanged(
+ this,
+ aStatusText,
+ aWorkUnitsComplete,
+ aTotalWorkUnits
+ );
+ } catch (e) {
+ this.log.error("Exception thrown by onProgressChanged listener: " + e);
+ }
+ }
+ },
+
+ get cancelHandler() {
+ return this._cancelHandler;
+ },
+
+ set cancelHandler(val) {
+ this._cancelHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ try {
+ value.onHandlerChanged(this);
+ } catch (e) {
+ this.log.error("Exception thrown by onHandlerChanged listener: " + e);
+ }
+ }
+ },
+
+ get pauseHandler() {
+ return this._pauseHandler;
+ },
+
+ set pauseHandler(val) {
+ this._pauseHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ get retryHandler() {
+ return this._retryHandler;
+ },
+
+ set retryHandler(val) {
+ this._retryHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityProcess", "nsIActivity"]),
+};
+
+function ActivityEvent() {
+ Activity.call(this);
+ this.bindingName = "activity-event-item";
+ this.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+}
+
+ActivityEvent.prototype = {
+ __proto__: Activity.prototype,
+
+ statusText: "",
+ startTime: 0,
+ completionTime: 0,
+ _undoHandler: null,
+
+ init(aDisplayText, aInitiator, aStatusText, aStartTime, aCompletionTime) {
+ this.displayText = aDisplayText;
+ this.statusText = aStatusText;
+ this.startTime = aStartTime;
+ if (aCompletionTime) {
+ this.completionTime = aCompletionTime;
+ } else {
+ this.completionTime = Date.now();
+ }
+ this.initiator = aInitiator;
+ this._completionTime = aCompletionTime;
+ },
+
+ get undoHandler() {
+ return this._undoHandler;
+ },
+
+ set undoHandler(val) {
+ this._undoHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityEvent", "nsIActivity"]),
+};
+
+function ActivityWarning() {
+ Activity.call(this);
+ this.bindingName = "activity-warning-item";
+ this.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+}
+
+ActivityWarning.prototype = {
+ __proto__: Activity.prototype,
+
+ recoveryTipText: "",
+ _time: 0,
+ _recoveryHandler: null,
+
+ init(aWarningText, aInitiator, aRecoveryTipText) {
+ this.displayText = aWarningText;
+ this.initiator = aInitiator;
+ this.recoveryTipText = aRecoveryTipText;
+ this._time = Date.now();
+ },
+
+ get recoveryHandler() {
+ return this._recoveryHandler;
+ },
+
+ set recoveryHandler(val) {
+ this._recoveryHandler = val;
+
+ // let the listeners know about the change
+ this.log.debug("Notifying onHandlerChanged listeners");
+ for (let value of this._listeners) {
+ value.onHandlerChanged(this);
+ }
+ },
+
+ get time() {
+ return this._time;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityWarning", "nsIActivity"]),
+};
diff --git a/comm/mail/components/activity/ActivityManager.jsm b/comm/mail/components/activity/ActivityManager.jsm
new file mode 100644
index 0000000000..c808cb3354
--- /dev/null
+++ b/comm/mail/components/activity/ActivityManager.jsm
@@ -0,0 +1,157 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["ActivityManager"];
+
+function ActivityManager() {}
+
+ActivityManager.prototype = {
+ log: console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }),
+ _listeners: [],
+ _processCount: 0,
+ _db: null,
+ _idCounter: 1,
+ _activities: new Map(),
+
+ get processCount() {
+ let count = 0;
+ for (let value of this._activities.values()) {
+ if (value instanceof Ci.nsIActivityProcess) {
+ count++;
+ }
+ }
+
+ return count;
+ },
+
+ getProcessesByContext(aContextType, aContextObj) {
+ let list = [];
+ for (let activity of this._activities.values()) {
+ if (
+ activity instanceof Ci.nsIActivityProcess &&
+ activity.contextType == aContextType &&
+ activity.contextObj == aContextObj
+ ) {
+ list.push(activity);
+ }
+ }
+ return list;
+ },
+
+ get db() {
+ return null;
+ },
+
+ get nextId() {
+ return this._idCounter++;
+ },
+
+ addActivity(aActivity) {
+ try {
+ this.log.info("adding Activity");
+ // get the next valid id for this activity
+ let id = this.nextId;
+ aActivity.id = id;
+
+ // add activity into the activities table
+ this._activities.set(id, aActivity);
+ // notify all the listeners
+ for (let value of this._listeners) {
+ try {
+ value.onAddedActivity(id, aActivity);
+ } catch (e) {
+ this.log.error("Exception calling onAddedActivity" + e);
+ }
+ }
+ return id;
+ } catch (e) {
+ // for some reason exceptions don't end up on the console if we don't
+ // explicitly log them.
+ this.log.error("Exception: " + e);
+ throw e;
+ }
+ },
+
+ removeActivity(aID) {
+ let activity = this.getActivity(aID);
+ if (!activity) {
+ return; // Nothing to remove.
+ }
+
+ // make sure that the activity is not in-progress state
+ if (
+ activity instanceof Ci.nsIActivityProcess &&
+ activity.state == Ci.nsIActivityProcess.STATE_INPROGRESS
+ ) {
+ throw Components.Exception(`Activity in progress`, Cr.NS_ERROR_FAILURE);
+ }
+
+ // remove the activity
+ this._activities.delete(aID);
+
+ // notify all the listeners
+ for (let value of this._listeners) {
+ try {
+ value.onRemovedActivity(aID);
+ } catch (e) {
+ // ignore the exception
+ }
+ }
+ },
+
+ cleanUp() {
+ // Get the list of aIDs.
+ this.log.info("cleanUp\n");
+ for (let [id, activity] of this._activities) {
+ if (activity instanceof Ci.nsIActivityProcess) {
+ // Note: The .state property will return undefined if you aren't in
+ // this if-instanceof block.
+ let state = activity.state;
+ if (
+ state != Ci.nsIActivityProcess.STATE_INPROGRESS &&
+ state != Ci.nsIActivityProcess.STATE_PAUSED &&
+ state != Ci.nsIActivityProcess.STATE_WAITINGFORINPUT &&
+ state != Ci.nsIActivityProcess.STATE_WAITINGFORRETRY
+ ) {
+ this.removeActivity(id);
+ }
+ } else {
+ this.removeActivity(id);
+ }
+ }
+ },
+
+ getActivity(aID) {
+ return this._activities.get(aID);
+ },
+
+ containsActivity(aID) {
+ return this._activities.has(aID);
+ },
+
+ getActivities() {
+ return [...this._activities.values()];
+ },
+
+ addListener(aListener) {
+ this.log.info("addListener\n");
+ this._listeners.push(aListener);
+ },
+
+ removeListener(aListener) {
+ this.log.info("removeListener\n");
+ for (let i = 0; i < this._listeners.length; i++) {
+ if (this._listeners[i] == aListener) {
+ this._listeners.splice(i, 1);
+ }
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityManager"]),
+};
diff --git a/comm/mail/components/activity/ActivityManagerUI.jsm b/comm/mail/components/activity/ActivityManagerUI.jsm
new file mode 100644
index 0000000000..b36b9b5a72
--- /dev/null
+++ b/comm/mail/components/activity/ActivityManagerUI.jsm
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["ActivityManagerUI"];
+
+const ACTIVITY_MANAGER_URL = "chrome://messenger/content/activity.xhtml";
+
+function ActivityManagerUI() {}
+
+ActivityManagerUI.prototype = {
+ show(aWindowContext, aID) {
+ // First we see if it is already visible
+ let window = this.recentWindow;
+ if (window) {
+ window.focus();
+ return;
+ }
+
+ let parent = null;
+ try {
+ if (aWindowContext) {
+ parent = aWindowContext.docShell.domWindow;
+ }
+ } catch (e) {
+ /* it's OK to not have a parent window */
+ }
+
+ Services.ww.openWindow(
+ parent,
+ ACTIVITY_MANAGER_URL,
+ "ActivityManager",
+ "chrome,dialog=no,resizable",
+ {}
+ );
+ },
+
+ get visible() {
+ return null != this.recentWindow;
+ },
+
+ get recentWindow() {
+ return Services.wm.getMostRecentWindow("Activity:Manager");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIActivityManagerUI"]),
+};
diff --git a/comm/mail/components/activity/components.conf b/comm/mail/components/activity/components.conf
new file mode 100644
index 0000000000..429fd62bb8
--- /dev/null
+++ b/comm/mail/components/activity/components.conf
@@ -0,0 +1,38 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{B2C036A3-F7CE-401C-95EE-9C21505167FD}',
+ 'contract_ids': ['@mozilla.org/activity-process;1'],
+ 'jsm': 'resource:///modules/Activity.jsm',
+ 'constructor': 'ActivityProcess',
+ },
+ {
+ 'cid': '{87AAEB20-89D9-4B95-9542-3BF72405CAB2}',
+ 'contract_ids': ['@mozilla.org/activity-event;1'],
+ 'jsm': 'resource:///modules/Activity.jsm',
+ 'constructor': 'ActivityEvent',
+ },
+ {
+ 'cid': '{968BAC9E-798B-4952-B384-86B21B8CC71E}',
+ 'contract_ids': ['@mozilla.org/activity-warning;1'],
+ 'jsm': 'resource:///modules/Activity.jsm',
+ 'constructor': 'ActivityWarning',
+ },
+ {
+ 'cid': '{8aa5972e-19cb-41cc-9696-645f8a8d1a06}',
+ 'contract_ids': ['@mozilla.org/activity-manager;1'],
+ 'jsm': 'resource:///modules/ActivityManager.jsm',
+ 'constructor': 'ActivityManager',
+ },
+ {
+ 'cid': '{5fa5974e-09cb-40cc-9696-643f8a8d9a06}',
+ 'contract_ids': ['@mozilla.org/activity-manager-ui;1'],
+ 'jsm': 'resource:///modules/ActivityManagerUI.jsm',
+ 'constructor': 'ActivityManagerUI',
+ },
+]
diff --git a/comm/mail/components/activity/content/activity-widgets.js b/comm/mail/components/activity/content/activity-widgets.js
new file mode 100644
index 0000000000..44ee16bff8
--- /dev/null
+++ b/comm/mail/components/activity/content/activity-widgets.js
@@ -0,0 +1,384 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozXULElement, activityManager */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { makeFriendlyDateAgo } = ChromeUtils.import(
+ "resource:///modules/TemplateUtils.jsm"
+ );
+
+ let activityStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ );
+
+ /**
+ * The ActivityItemBase widget is the base class for all the activity item.
+ * It initializes activity details: i.e. id, status, icon, name, progress,
+ * date etc. for the activity widgets.
+ *
+ * @abstract
+ * @augments HTMLLIElement
+ */
+ class ActivityItemBase extends HTMLLIElement {
+ connectedCallback() {
+ if (!this.hasChildNodes()) {
+ // fetch the activity and set the base attributes
+ this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ });
+ let actID = this.getAttribute("actID");
+ this._activity = activityManager.getActivity(actID);
+ this._activity.QueryInterface(this.constructor.activityInterface);
+
+ // Construct the children.
+ this.classList.add("activityitem");
+
+ let icon = document.createElement("img");
+ icon.setAttribute(
+ "src",
+ this._activity.iconClass
+ ? `chrome://messenger/skin/icons/new/activity/${this._activity.iconClass}Icon.svg`
+ : this.constructor.defaultIconSrc
+ );
+ icon.setAttribute("alt", "");
+ this.appendChild(icon);
+
+ let display = document.createElement("span");
+ display.classList.add("displayText");
+ this.appendChild(display);
+
+ if (this.isEvent || this.isWarning) {
+ let time = document.createElement("time");
+ time.classList.add("dateTime");
+ this.appendChild(time);
+ }
+
+ if (this.isProcess) {
+ let progress = document.createElement("progress");
+ progress.setAttribute("value", "0");
+ progress.setAttribute("max", "100");
+ progress.classList.add("progressmeter");
+ this.appendChild(progress);
+ }
+
+ let statusText = document.createElement("span");
+ statusText.setAttribute("role", "note");
+ statusText.classList.add("statusText");
+ this.appendChild(statusText);
+ }
+ // (Re-)Attach the listener.
+ this.attachToActivity();
+ }
+
+ disconnectedCallback() {
+ this.detachFromActivity();
+ }
+
+ get isProcess() {
+ return this.constructor.activityInterface == Ci.nsIActivityProcess;
+ }
+
+ get isEvent() {
+ return this.constructor.activityInterface == Ci.nsIActivityEvent;
+ }
+
+ get isWarning() {
+ return this.constructor.activityInterface == Ci.nsIActivityWarning;
+ }
+
+ get isGroup() {
+ return false;
+ }
+
+ get activity() {
+ return this._activity;
+ }
+
+ detachFromActivity() {
+ if (this.activityListener) {
+ this._activity.removeListener(this.activityListener);
+ }
+ }
+
+ attachToActivity() {
+ if (this.activityListener) {
+ this._activity.addListener(this.activityListener);
+ }
+ }
+
+ static _dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "long",
+ timeStyle: "short",
+ });
+
+ /**
+ * The time the activity occurred.
+ *
+ * @type {number} - The time in milliseconds since the epoch.
+ */
+ set dateTime(time) {
+ let element = this.querySelector(".dateTime");
+ if (!element) {
+ return;
+ }
+ time = new Date(parseInt(time));
+
+ element.setAttribute("datetime", time.toISOString());
+ element.textContent = makeFriendlyDateAgo(time);
+ element.setAttribute(
+ "title",
+ this.constructor._dateTimeFormatter.format(time)
+ );
+ }
+
+ /**
+ * The text that describes additional information to the user.
+ *
+ * @type {string}
+ */
+ set statusText(val) {
+ this.querySelector(".statusText").textContent = val;
+ }
+
+ get statusText() {
+ return this.querySelector(".statusText").textContent;
+ }
+
+ /**
+ * The text that describes the activity to the user.
+ *
+ * @type {string}
+ */
+ set displayText(val) {
+ this.querySelector(".displayText").textContent = val;
+ }
+
+ get displayText() {
+ return this.querySelector(".displayText").textContent;
+ }
+ }
+
+ /**
+ * The MozActivityEvent widget displays information about events (like
+ * deleting or moving the message): e.g image, name, date and description.
+ * It is typically used in Activity Manager window.
+ *
+ * @augments ActivityItemBase
+ */
+ class ActivityEventItem extends ActivityItemBase {
+ static defaultIconSrc =
+ "chrome://messenger/skin/icons/new/activity/defaultEventIcon.svg";
+ static activityInterface = Ci.nsIActivityEvent;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "activity-event-item");
+
+ this.displayText = this.activity.displayText;
+ this.statusText = this.activity.statusText;
+ this.dateTime = this.activity.completionTime;
+ }
+ }
+
+ customElements.define("activity-event-item", ActivityEventItem, {
+ extends: "li",
+ });
+
+ /**
+ * The ActivityGroupItem widget displays information about the activities of
+ * the group: e.g. name of the group, list of the activities with their name,
+ * progress and icon. It is shown in Activity Manager window. It gets removed
+ * when there is no activities from the group.
+ *
+ * @augments HTMLLIElement
+ */
+ class ActivityGroupItem extends HTMLLIElement {
+ constructor() {
+ super();
+
+ let heading = document.createElement("h2");
+ heading.classList.add("contextDisplayText");
+ this.appendChild(heading);
+
+ let list = document.createElement("ul");
+ list.classList.add("activitygroup-list", "activityview");
+ this.appendChild(list);
+
+ this.classList.add("activitygroup");
+ this.setAttribute("is", "activity-group-item");
+ }
+
+ /**
+ * The text heading for the group, as seen by the user.
+ *
+ * @type {string}
+ */
+ set contextDisplayText(val) {
+ this.querySelector(".contextDisplayText").textContent = val;
+ }
+
+ get contextDisplayText() {
+ return this.querySelctor(".contextDisplayText").textContent;
+ }
+
+ get isGroup() {
+ return true;
+ }
+ }
+
+ customElements.define("activity-group-item", ActivityGroupItem, {
+ extends: "li",
+ });
+
+ /**
+ * The ActivityProcessItem widget displays information about the internal
+ * process : e.g image, progress, name, date and description.
+ * It is typically used in Activity Manager window.
+ *
+ * @augments ActivityItemBase
+ */
+ class ActivityProcessItem extends ActivityItemBase {
+ static defaultIconSrc =
+ "chrome://messenger/skin/icons/new/activity/deafultProcessIcon.svg";
+ static activityInterface = Ci.nsIActivityProcess;
+ static textMap = {
+ paused: activityStrings.GetStringFromName("paused2"),
+ canceled: activityStrings.GetStringFromName("canceled"),
+ failed: activityStrings.GetStringFromName("failed"),
+ waitingforinput: activityStrings.GetStringFromName("waitingForInput"),
+ waitingforretry: activityStrings.GetStringFromName("waitingForRetry"),
+ };
+
+ constructor() {
+ super();
+
+ this.activityListener = {
+ onStateChanged: (activity, oldState) => {
+ // change the view of the element according to the new state
+ // default states for each item
+ let hideProgressMeter = false;
+ let statusText = this.statusText;
+
+ switch (this.activity.state) {
+ case Ci.nsIActivityProcess.STATE_INPROGRESS:
+ statusText = "";
+ break;
+ case Ci.nsIActivityProcess.STATE_COMPLETED:
+ hideProgressMeter = true;
+ statusText = "";
+ break;
+ case Ci.nsIActivityProcess.STATE_CANCELED:
+ hideProgressMeter = true;
+ statusText = this.constructor.textMap.canceled;
+ break;
+ case Ci.nsIActivityProcess.STATE_PAUSED:
+ statusText = this.constructor.textMap.paused;
+ break;
+ case Ci.nsIActivityProcess.STATE_WAITINGFORINPUT:
+ statusText = this.constructor.textMap.waitingforinput;
+ break;
+ case Ci.nsIActivityProcess.STATE_WAITINGFORRETRY:
+ hideProgressMeter = true;
+ statusText = this.constructor.textMap.waitingforretry;
+ break;
+ }
+
+ // Set the visibility
+ let meter = this.querySelector(".progressmeter");
+ meter.hidden = hideProgressMeter;
+
+ // Ensure progress meter not active when hidden
+ if (hideProgressMeter) {
+ meter.value = 0;
+ }
+
+ // Update Status text and Display Text Areas
+ // In some states we need to modify Display Text area of
+ // the process (e.g. Failure).
+ this.statusText = statusText;
+ },
+ onProgressChanged: (
+ activity,
+ statusText,
+ workUnitsComplete,
+ totalWorkUnits
+ ) => {
+ let element = document.querySelector(".progressmeter");
+ if (totalWorkUnits == 0) {
+ element.removeAttribute("value");
+ } else {
+ let _percentComplete = (100.0 * workUnitsComplete) / totalWorkUnits;
+ element.value = _percentComplete;
+ }
+ this.statusText = statusText;
+ },
+ };
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "activity-process-item");
+
+ this.displayText = this.activity.displayText;
+ // make sure that custom element reflects the latest state of the process
+ this.activityListener.onStateChanged(
+ this.activity.state,
+ Ci.nsIActivityProcess.STATE_NOTSTARTED
+ );
+ this.activityListener.onProgressChanged(
+ this.activity,
+ this.activity.lastStatusText,
+ this.activity.workUnitComplete,
+ this.activity.totalWorkUnits
+ );
+ }
+
+ get inProgress() {
+ return this.activity.state == Ci.nsIActivityProcess.STATE_INPROGRESS;
+ }
+
+ get isRemovable() {
+ return (
+ this.activity.state == Ci.nsIActivityProcess.STATE_COMPLETED ||
+ this.activity.state == Ci.nsIActivityProcess.STATE_CANCELED
+ );
+ }
+ }
+
+ customElements.define("activity-process-item", ActivityProcessItem, {
+ extends: "li",
+ });
+
+ /**
+ * The ActivityWarningItem widget displays information about
+ * warnings : e.g image, name, date and description.
+ * It is typically used in Activity Manager window.
+ *
+ * @augments ActivityItemBase
+ */
+ class ActivityWarningItem extends ActivityItemBase {
+ static defaultIconSrc =
+ "chrome://messenger/skin/icons/new/activity/warning.svg";
+ static activityInterface = Ci.nsIActivityWarning;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "activity-warning-item");
+
+ this.displayText = this.activity.displayText;
+ this.dateTime = this.activity.time;
+ this.statusText = this.activity.recoveryTipText;
+ }
+ }
+
+ customElements.define("activity-warning-item", ActivityWarningItem, {
+ extends: "li",
+ });
+}
diff --git a/comm/mail/components/activity/content/activity.js b/comm/mail/components/activity/content/activity.js
new file mode 100644
index 0000000000..dcaba3d808
--- /dev/null
+++ b/comm/mail/components/activity/content/activity.js
@@ -0,0 +1,239 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const activityManager = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+);
+
+var ACTIVITY_LIMIT = 250;
+
+var activityObject = {
+ _activityMgrListener: null,
+ _activitiesView: null,
+ _activityLogger: console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }),
+ _ignoreNotifications: false,
+ _groupCache: new Map(),
+
+ // Utility Functions for Activity element management
+
+ /**
+ * Creates the proper element for the given activity
+ */
+ createActivityWidget(type) {
+ let element = document.createElement("li", {
+ is: type.bindingName,
+ });
+
+ if (element) {
+ element.setAttribute("actID", type.id);
+ }
+
+ return element;
+ },
+
+ /**
+ * Returns the activity group element that matches the context_type
+ * and context of the given activity, if any.
+ */
+ getActivityGroupElementByContext(aContextType, aContextObj) {
+ return this._groupCache.get(aContextType + ":" + aContextObj);
+ },
+
+ /**
+ * Inserts the given element into the correct position on the
+ * activity manager window.
+ */
+ placeActivityElement(element) {
+ if (element.isGroup || element.isProcess) {
+ this._activitiesView.insertBefore(
+ element,
+ this._activitiesView.firstElementChild
+ );
+ } else {
+ let next = this._activitiesView.firstElementChild;
+ while (next && (next.isWarning || next.isProcess || next.isGroup)) {
+ next = next.nextElementSibling;
+ }
+ if (next) {
+ this._activitiesView.insertBefore(element, next);
+ } else {
+ this._activitiesView.appendChild(element);
+ }
+ }
+ if (element.isGroup) {
+ this._groupCache.set(
+ element.contextType + ":" + element.contextObj,
+ element
+ );
+ }
+ while (this._activitiesView.children.length > ACTIVITY_LIMIT) {
+ this.removeActivityElement(
+ this._activitiesView.lastElementChild.getAttribute("actID")
+ );
+ }
+ },
+
+ /**
+ * Adds a new element to activity manager window for the
+ * given activity. It is called by ActivityMgrListener when
+ * a new activity is added into the activity manager's internal
+ * list.
+ */
+ addActivityElement(aID, aActivity) {
+ try {
+ this._activityLogger.info(`Adding ActivityElement: ${aID}, ${aActivity}`);
+ // get |groupingStyle| of the activity. Grouping style determines
+ // whether we show the activity standalone or grouped by context in
+ // the activity manager window.
+ let isGroupByContext =
+ aActivity.groupingStyle == Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+
+ // find out if an activity group has already been created for this context
+ let group = null;
+ if (isGroupByContext) {
+ group = this.getActivityGroupElementByContext(
+ aActivity.contextType,
+ aActivity.contextObj
+ );
+ // create a group if it's not already created.
+ if (!group) {
+ group = document.createElement("li", {
+ is: "activity-group-item",
+ });
+ this._activityLogger.info("created group element");
+ // Set the context type and object of the newly created group
+ group.contextType = aActivity.contextType;
+ group.contextObj = aActivity.contextObj;
+ group.contextDisplayText = aActivity.contextDisplayText;
+
+ // add group into the list
+ this.placeActivityElement(group);
+ }
+ }
+
+ // create the appropriate element for the activity
+ let actElement = this.createActivityWidget(aActivity);
+ this._activityLogger.info("created activity element");
+
+ if (group) {
+ // get the inner list element of the group
+ let groupView = group.querySelector(".activitygroup-list");
+ groupView.appendChild(actElement);
+ } else {
+ this.placeActivityElement(actElement);
+ }
+ } catch (e) {
+ this._activityLogger.error("addActivityElement: " + e);
+ throw e;
+ }
+ },
+
+ /**
+ * Removes the activity element from the activity manager window.
+ * It is called by ActivityMgrListener when the activity in question
+ * is removed from the activity manager's internal list.
+ */
+ removeActivityElement(aID) {
+ this._activityLogger.info("removing Activity ID: " + aID);
+ let item = this._activitiesView.querySelector(`[actID="${aID}"]`);
+
+ if (item) {
+ let group = item.closest(".activitygroup");
+ item.remove();
+ if (group && !group.querySelector(".activityitem")) {
+ // Empty group is removed.
+ this._groupCache.delete(group.contextType + ":" + group.contextObj);
+ group.remove();
+ }
+ }
+ },
+
+ // -----------------
+ // Startup, Shutdown
+
+ startup() {
+ try {
+ this._activitiesView = document.getElementById("activityView");
+
+ let activities = activityManager.getActivities();
+ for (
+ let iActivity = Math.max(0, activities.length - ACTIVITY_LIMIT);
+ iActivity < activities.length;
+ iActivity++
+ ) {
+ let activity = activities[iActivity];
+ this.addActivityElement(activity.id, activity);
+ }
+
+ // start listening changes in the activity manager's
+ // internal list
+ this._activityMgrListener = new this.ActivityMgrListener();
+ activityManager.addListener(this._activityMgrListener);
+ } catch (e) {
+ this._activityLogger.error("Exception: " + e);
+ }
+ },
+
+ rebuild() {
+ let activities = activityManager.getActivities();
+ for (let activity of activities) {
+ this.addActivityElement(activity.id, activity);
+ }
+ },
+
+ shutdown() {
+ activityManager.removeListener(this._activityMgrListener);
+ },
+
+ // -----------------
+ // Utility Functions
+
+ /**
+ * Remove all activities not in-progress from the activity list.
+ */
+ clearActivityList() {
+ this._activityLogger.debug("clearActivityList");
+
+ this._ignoreNotifications = true;
+ // If/when we implement search, we'll want to remove just the items
+ // that are on the search display, however for now, we'll just clear up
+ // everything.
+ activityManager.cleanUp();
+
+ while (this._activitiesView.lastChild) {
+ this._activitiesView.lastChild.remove();
+ }
+
+ this._groupCache.clear();
+ this.rebuild();
+ this._ignoreNotifications = false;
+ this._activitiesView.focus();
+ },
+};
+
+// An object to monitor nsActivityManager operations. This class acts as
+// binding layer between nsActivityManager and nsActivityManagerUI objects.
+activityObject.ActivityMgrListener = function () {};
+activityObject.ActivityMgrListener.prototype = {
+ onAddedActivity(aID, aActivity) {
+ activityObject._activityLogger.info(`added activity: ${aID} ${aActivity}`);
+ if (!activityObject._ignoreNotifications) {
+ activityObject.addActivityElement(aID, aActivity);
+ }
+ },
+
+ onRemovedActivity(aID) {
+ if (!activityObject._ignoreNotifications) {
+ activityObject.removeActivityElement(aID);
+ }
+ },
+};
+
+window.addEventListener("load", () => activityObject.startup());
+window.addEventListener("unload", () => activityObject.shutdown());
diff --git a/comm/mail/components/activity/content/activity.xhtml b/comm/mail/components/activity/content/activity.xhtml
new file mode 100644
index 0000000000..cdff19cbe6
--- /dev/null
+++ b/comm/mail/components/activity/content/activity.xhtml
@@ -0,0 +1,61 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define XP_GNOME 1
+#endif
+#endif
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/activity/activity.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [
+<!ENTITY % activityManagerDTD SYSTEM "chrome://messenger/locale/activity.dtd">
+%activityManagerDTD;
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ id="activityManager" windowtype="Activity:Manager"
+ width="&window.width2;" height="&window.height;"
+ screenX="10" screenY="10"
+ persist="width height screenX screenY sizemode"
+ lightweightthemes="true">
+<head>
+ <title>&activity.title;</title>
+
+ <script defer="defer" src="chrome://messenger/content/activity.js"></script>
+ <script defer="defer" src="chrome://messenger/content/activity-widgets.js"></script>
+</head>
+<body>
+ <xul:keyset id="activityKeys">
+ <xul:key id="key_close" key="&cmd.close.commandkey;"
+ oncommand="window.close();" modifiers="accel"/>
+#ifdef XP_GNOME
+ <xul:key id="key_close2" key="&cmd.close2Unix.commandkey;"
+ oncommand="window.close();" modifiers="accel"/>
+#else
+ <xul:key id="key_close2" key="&cmd.close2.commandkey;"
+ oncommand="window.close();" modifiers="accel"/>
+#endif
+ <xul:key keycode="VK_ESCAPE" oncommand="window.close();"/>
+ </xul:keyset>
+
+ <div id="activityContainer">
+ <ul id="activityView" class="activityview"></ul>
+ <button id="clearListButton"
+ onclick="activityObject.clearActivityList();"
+ accesskey="&cmd.clearList.accesskey;"
+ title="&cmd.clearList.tooltip;">
+ &cmd.clearList.label;
+ </button>
+ </div>
+</body>
+</html>
diff --git a/comm/mail/components/activity/jar.mn b/comm/mail/components/activity/jar.mn
new file mode 100644
index 0000000000..babeeac23d
--- /dev/null
+++ b/comm/mail/components/activity/jar.mn
@@ -0,0 +1,8 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/activity.js (content/activity.js)
+ content/messenger/activity-widgets.js (content/activity-widgets.js)
+* content/messenger/activity.xhtml (content/activity.xhtml)
diff --git a/comm/mail/components/activity/modules/activityModules.jsm b/comm/mail/components/activity/modules/activityModules.jsm
new file mode 100644
index 0000000000..945f7473c2
--- /dev/null
+++ b/comm/mail/components/activity/modules/activityModules.jsm
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This module is designed to be a central place to initialise activity related
+// modules.
+
+const EXPORTED_SYMBOLS = [];
+
+const { sendLaterModule } = ChromeUtils.import(
+ "resource:///modules/activity/sendLater.jsm"
+);
+sendLaterModule.init();
+const { moveCopyModule } = ChromeUtils.import(
+ "resource:///modules/activity/moveCopy.jsm"
+);
+moveCopyModule.init();
+const { glodaIndexerActivity } = ChromeUtils.import(
+ "resource:///modules/activity/glodaIndexer.jsm"
+);
+glodaIndexerActivity.init();
+const { autosyncModule } = ChromeUtils.import(
+ "resource:///modules/activity/autosync.jsm"
+);
+autosyncModule.init();
+const { alertHook } = ChromeUtils.import(
+ "resource:///modules/activity/alertHook.jsm"
+);
+alertHook.init();
+const { pop3DownloadModule } = ChromeUtils.import(
+ "resource:///modules/activity/pop3Download.jsm"
+);
+pop3DownloadModule.init();
diff --git a/comm/mail/components/activity/modules/alertHook.jsm b/comm/mail/components/activity/modules/alertHook.jsm
new file mode 100644
index 0000000000..b3083aef0a
--- /dev/null
+++ b/comm/mail/components/activity/modules/alertHook.jsm
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["alertHook"];
+
+var nsActWarning = Components.Constructor(
+ "@mozilla.org/activity-warning;1",
+ "nsIActivityWarning",
+ "init"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// This module provides a link between the send later service and the activity
+// manager.
+var alertHook = {
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get alertService() {
+ delete this.alertService;
+ return (this.alertService = Cc["@mozilla.org/alerts-service;1"].getService(
+ Ci.nsIAlertsService
+ ));
+ },
+
+ get brandShortName() {
+ delete this.brandShortName;
+ return (this.brandShortName = Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName"));
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgUserFeedbackListener"]),
+
+ onAlert(aMessage, aUrl) {
+ // Create a new warning.
+ let warning = new nsActWarning(aMessage, this.activityMgr, "");
+
+ if (aUrl && aUrl.server && aUrl.server.prettyName) {
+ warning.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+ warning.contextType = "incomingServer";
+ warning.contextDisplayText = aUrl.server.prettyName;
+ warning.contextObj = aUrl.server;
+ } else {
+ warning.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+ }
+
+ this.activityMgr.addActivity(warning);
+
+ // If we have a message window in the url, then show a warning prompt,
+ // just like the modal code used to. Otherwise, don't.
+ try {
+ if (!aUrl || !aUrl.msgWindow) {
+ return true;
+ }
+ } catch (ex) {
+ // nsIMsgMailNewsUrl.msgWindow will throw on a null pointer, so that's
+ // what we're handling here.
+ if (
+ ex instanceof Ci.nsIException &&
+ ex.result == Cr.NS_ERROR_INVALID_POINTER
+ ) {
+ return true;
+ }
+ throw ex;
+ }
+
+ try {
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ alert.init(
+ "", // name
+ "chrome://branding/content/icon48.png",
+ this.brandShortName,
+ aMessage
+ );
+ this.alertService.showAlert(alert);
+ } catch (ex) {
+ // XXX On Linux, if libnotify isn't supported, showAlert
+ // can throw an error, so fall-back to the old method of modal dialogs.
+ return false;
+ }
+
+ return true;
+ },
+
+ init() {
+ // We shouldn't need to remove the listener as we're not being held by
+ // anyone except by the send later instance.
+ MailServices.mailSession.addUserFeedbackListener(this);
+ },
+};
diff --git a/comm/mail/components/activity/modules/autosync.jsm b/comm/mail/components/activity/modules/autosync.jsm
new file mode 100644
index 0000000000..c2483c4b53
--- /dev/null
+++ b/comm/mail/components/activity/modules/autosync.jsm
@@ -0,0 +1,433 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["autosyncModule"];
+
+var nsActProcess = Components.Constructor(
+ "@mozilla.org/activity-process;1",
+ "nsIActivityProcess",
+ "init"
+);
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+/**
+ * This code aims to mediate between the auto-sync code and the activity mgr.
+ *
+ * Not every auto-sync activity is directly mapped to a process or event.
+ * To prevent a possible event overflow, Auto-Sync monitor generates one
+ * sync'd event per account when after all its _pending_ folders are sync'd,
+ * rather than generating one event per folder sync.
+ */
+
+var autosyncModule = {
+ _inQFolderList: [],
+ _running: false,
+ _syncInfoPerFolder: new Map(),
+ _syncInfoPerServer: new Map(),
+ _lastMessage: new Map(),
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get autoSyncManager() {
+ delete this.autoSyncManager;
+ return (this.autoSyncManager = Cc[
+ "@mozilla.org/imap/autosyncmgr;1"
+ ].getService(Ci.nsIAutoSyncManager));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ createSyncMailProcess(folder) {
+ try {
+ // create an activity process for this folder
+ let msg = this.bundle.formatStringFromName("autosyncProcessDisplayText", [
+ folder.prettyName,
+ ]);
+ let process = new nsActProcess(msg, this.autoSyncManager);
+ // we want to use default auto-sync icon
+ process.iconClass = "syncMail";
+ process.addSubject(folder);
+ // group processes under folder's imap account
+ process.contextType = "account";
+ process.contextDisplayText = this.bundle.formatStringFromName(
+ "autosyncContextDisplayText",
+ [folder.server.prettyName]
+ );
+
+ process.contextObj = folder.server;
+
+ return process;
+ } catch (e) {
+ this.log.error("createSyncMailProcess: " + e);
+ throw e;
+ }
+ },
+
+ createSyncMailEvent(syncItem) {
+ try {
+ // extract the relevant parts
+ let process = syncItem.activity;
+ let folder = syncItem.syncFolder;
+
+ // create an activity event
+
+ let msg = this.bundle.formatStringFromName("autosyncEventDisplayText", [
+ folder.server.prettyName,
+ ]);
+
+ let statusMsg;
+ let numOfMessages = this._syncInfoPerServer.get(
+ folder.server
+ ).totalDownloads;
+ if (numOfMessages) {
+ statusMsg = this.bundle.formatStringFromName(
+ "autosyncEventStatusText",
+ [numOfMessages]
+ );
+ } else {
+ statusMsg = this.getString("autosyncEventStatusTextNoMsgs");
+ }
+
+ let event = new nsActEvent(
+ msg,
+ this.autoSyncManager,
+ statusMsg,
+ this._syncInfoPerServer.get(folder.server).startTime,
+ Date.now()
+ ); // completion time
+
+ // since auto-sync events do not have undo option by nature,
+ // setting these values are informational only.
+ event.contextType = process.contextType;
+ event.contextDisplayText = this.bundle.formatStringFromName(
+ "autosyncContextDisplayText",
+ [folder.server.prettyName]
+ );
+ event.contextObj = process.contextObj;
+ event.iconClass = "syncMail";
+
+ // transfer all subjects.
+ // same as above, not mandatory
+ let subjects = process.getSubjects();
+ for (let subject of subjects) {
+ event.addSubject(subject);
+ }
+
+ return event;
+ } catch (e) {
+ this.log.error("createSyncMailEvent: " + e);
+ throw e;
+ }
+ },
+
+ onStateChanged(running) {
+ try {
+ this._running = running;
+ this.log.info(
+ "OnStatusChanged: " + (running ? "running" : "sleeping") + "\n"
+ );
+ } catch (e) {
+ this.log.error("onStateChanged: " + e);
+ throw e;
+ }
+ },
+
+ onFolderAddedIntoQ(queue, folder) {
+ try {
+ if (
+ folder instanceof Ci.nsIMsgFolder &&
+ queue == Ci.nsIAutoSyncMgrListener.PriorityQueue
+ ) {
+ this._inQFolderList.push(folder);
+ this.log.info(
+ "Auto_Sync OnFolderAddedIntoQ [" +
+ this._inQFolderList.length +
+ "] " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName
+ );
+ // create an activity process for this folder
+ let process = this.createSyncMailProcess(folder);
+
+ // create a sync object to keep track of the process of this folder
+ let imapFolder = folder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ let syncItem = {
+ syncFolder: folder,
+ activity: process,
+ percentComplete: 0,
+ totalDownloaded: 0,
+ pendingMsgCount: imapFolder.autoSyncStateObj.pendingMessageCount,
+ };
+
+ // if this is the first folder of this server in the queue, then set the sync start time
+ // for activity event
+ if (!this._syncInfoPerServer.has(folder.server)) {
+ this._syncInfoPerServer.set(folder.server, {
+ startTime: Date.now(),
+ totalDownloads: 0,
+ });
+ }
+
+ // associate the sync object with the folder in question
+ // use folder.URI as key
+ this._syncInfoPerFolder.set(folder.URI, syncItem);
+ }
+ } catch (e) {
+ this.log.error("onFolderAddedIntoQ: " + e);
+ throw e;
+ }
+ },
+ onFolderRemovedFromQ(queue, folder) {
+ try {
+ if (
+ folder instanceof Ci.nsIMsgFolder &&
+ queue == Ci.nsIAutoSyncMgrListener.PriorityQueue
+ ) {
+ let i = this._inQFolderList.indexOf(folder);
+ if (i > -1) {
+ this._inQFolderList.splice(i, 1);
+ }
+
+ this.log.info(
+ "OnFolderRemovedFromQ [" +
+ this._inQFolderList.length +
+ "] " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ "\n"
+ );
+
+ let syncItem = this._syncInfoPerFolder.get(folder.URI);
+ let process = syncItem.activity;
+ let canceled = false;
+ if (process instanceof Ci.nsIActivityProcess) {
+ canceled = process.state == Ci.nsIActivityProcess.STATE_CANCELED;
+ process.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+
+ try {
+ this.activityMgr.removeActivity(process.id);
+ } catch (e) {
+ // It is OK to end up here; If the folder is queued and the
+ // message get manually downloaded by the user, we might get
+ // a folder removed notification even before a download
+ // started for this folder. This behavior stems from the fact
+ // that we add activities into the activity manager in
+ // onDownloadStarted notification rather than onFolderAddedIntoQ.
+ // This is an expected side effect.
+ // Log a warning, but do not throw an error.
+ this.log.warn("onFolderRemovedFromQ: " + e);
+ }
+
+ // remove the folder/syncItem association from the table
+ this._syncInfoPerFolder.delete(folder.URI);
+ }
+
+ // if this is the last folder of this server in the queue
+ // create a sync event and clean the sync start time
+ let found = false;
+ for (let value of this._syncInfoPerFolder.values()) {
+ if (value.syncFolder.server == folder.server) {
+ found = true;
+ break;
+ }
+ }
+ this.log.info(
+ "Auto_Sync OnFolderRemovedFromQ Last folder of the server: " + !found
+ );
+ if (!found) {
+ // create an sync event for the completed process if it's not canceled
+ if (!canceled) {
+ let key = folder.server.prettyName;
+ if (
+ this._lastMessage.has(key) &&
+ this.activityMgr.containsActivity(this._lastMessage.get(key))
+ ) {
+ this.activityMgr.removeActivity(this._lastMessage.get(key));
+ }
+ this._lastMessage.set(
+ key,
+ this.activityMgr.addActivity(this.createSyncMailEvent(syncItem))
+ );
+ }
+ this._syncInfoPerServer.delete(folder.server);
+ }
+ }
+ } catch (e) {
+ this.log.error("onFolderRemovedFromQ: " + e);
+ throw e;
+ }
+ },
+ onDownloadStarted(folder, numOfMessages, totalPending) {
+ try {
+ if (folder instanceof Ci.nsIMsgFolder) {
+ this.log.info(
+ "OnDownloadStarted (" +
+ numOfMessages +
+ "/" +
+ totalPending +
+ "): " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ "\n"
+ );
+
+ let syncItem = this._syncInfoPerFolder.get(folder.URI);
+ let process = syncItem.activity;
+
+ // Update the totalPending number. if new messages have been discovered in the folder
+ // after we added the folder into the q, totalPending might be greater than what we have
+ // initially set
+ if (totalPending > syncItem.pendingMsgCount) {
+ syncItem.pendingMsgCount = totalPending;
+ }
+
+ if (process instanceof Ci.nsIActivityProcess) {
+ // if the process has not beed added to activity manager already, add now
+ if (!this.activityMgr.containsActivity(process.id)) {
+ this.log.info(
+ "Auto_Sync OnDownloadStarted: No process, adding a new process"
+ );
+ this.activityMgr.addActivity(process);
+ }
+
+ syncItem.totalDownloaded += numOfMessages;
+
+ process.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ let percent =
+ (syncItem.totalDownloaded / syncItem.pendingMsgCount) * 100;
+ if (percent > syncItem.percentComplete) {
+ syncItem.percentComplete = percent;
+ }
+
+ let msg = this.bundle.formatStringFromName(
+ "autosyncProcessProgress2",
+ [
+ syncItem.totalDownloaded,
+ syncItem.pendingMsgCount,
+ folder.prettyName,
+ folder.server.prettyName,
+ ]
+ );
+
+ process.setProgress(
+ msg,
+ syncItem.totalDownloaded,
+ syncItem.pendingMsgCount
+ );
+
+ let serverInfo = this._syncInfoPerServer.get(
+ syncItem.syncFolder.server
+ );
+ serverInfo.totalDownloads += numOfMessages;
+ this._syncInfoPerServer.set(syncItem.syncFolder.server, serverInfo);
+ }
+ }
+ } catch (e) {
+ this.log.error("onDownloadStarted: " + e);
+ throw e;
+ }
+ },
+
+ onDownloadCompleted(folder) {
+ try {
+ if (folder instanceof Ci.nsIMsgFolder) {
+ this.log.info(
+ "OnDownloadCompleted: " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName
+ );
+
+ let process = this._syncInfoPerFolder.get(folder.URI).activity;
+ if (process instanceof Ci.nsIActivityProcess && !this._running) {
+ this.log.info(
+ "OnDownloadCompleted: Auto-Sync Manager is paused, pausing the process"
+ );
+ process.state = Ci.nsIActivityProcess.STATE_PAUSED;
+ }
+ }
+ } catch (e) {
+ this.log.error("onDownloadCompleted: " + e);
+ throw e;
+ }
+ },
+
+ onDownloadError(folder) {
+ if (folder instanceof Ci.nsIMsgFolder) {
+ this.log.error(
+ "OnDownloadError: " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ "\n"
+ );
+ }
+ },
+
+ onDiscoveryQProcessed(folder, numOfHdrsProcessed, leftToProcess) {
+ this.log.info(
+ "onDiscoveryQProcessed: Processed " +
+ numOfHdrsProcessed +
+ "/" +
+ (leftToProcess + numOfHdrsProcessed) +
+ " of " +
+ folder.prettyName +
+ "\n"
+ );
+ },
+
+ onAutoSyncInitiated(folder) {
+ this.log.info(
+ "onAutoSyncInitiated: " +
+ folder.prettyName +
+ " of " +
+ folder.server.prettyName +
+ " has been updated.\n"
+ );
+ },
+
+ init() {
+ // XXX when do we need to remove ourselves?
+ this.log.info("initing");
+ Cc["@mozilla.org/imap/autosyncmgr;1"]
+ .getService(Ci.nsIAutoSyncManager)
+ .addListener(this);
+ },
+};
diff --git a/comm/mail/components/activity/modules/glodaIndexer.jsm b/comm/mail/components/activity/modules/glodaIndexer.jsm
new file mode 100644
index 0000000000..5307d5cefa
--- /dev/null
+++ b/comm/mail/components/activity/modules/glodaIndexer.jsm
@@ -0,0 +1,251 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["glodaIndexerActivity"];
+
+var nsActProcess = Components.Constructor(
+ "@mozilla.org/activity-process;1",
+ "nsIActivityProcess",
+ "init"
+);
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Gloda: "resource:///modules/gloda/GlodaPublic.jsm",
+ GlodaConstants: "resource:///modules/gloda/GlodaConstants.jsm",
+ GlodaIndexer: "resource:///modules/gloda/GlodaIndexer.jsm",
+});
+
+/**
+ * Gloda message indexer feedback.
+ */
+var glodaIndexerActivity = {
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ init() {
+ // Register a listener with the Gloda indexer that receives notifications
+ // about Gloda indexing status. We wrap the listener in this function so we
+ // can set |this| to the GlodaIndexerActivity object inside the listener.
+ function listenerWrapper(...aArgs) {
+ glodaIndexerActivity.listener(...aArgs);
+ }
+ lazy.GlodaIndexer.addListener(listenerWrapper);
+ },
+
+ /**
+ * Information about the current job. An object with these properties:
+ *
+ * folder {String}
+ * the name of the folder being processed by the job
+ * jobNumber {Number}
+ * the index of the job in the list of jobs
+ * process {nsIActivityProcess}
+ * the activity process corresponding to the current job
+ * startTime {Date}
+ * the time at which we were first notified about the job
+ * totalItemNum {Number}
+ * the total number of messages being indexed in the job
+ * jobType {String}
+ * The IndexinbJob jobType (ex: "folder", "folderCompact")
+ */
+ currentJob: null,
+
+ listener(aStatus, aFolder, aJobNumber, aItemNumber, aTotalItemNum, aJobType) {
+ this.log.debug("Gloda Indexer Folder/Status: " + aFolder + "/" + aStatus);
+ this.log.debug("Gloda Indexer Job: " + aJobNumber);
+ this.log.debug("Gloda Indexer Item: " + aItemNumber + "/" + aTotalItemNum);
+
+ if (aStatus == lazy.GlodaConstants.kIndexerIdle) {
+ if (this.currentJob) {
+ this.onJobCompleted();
+ }
+ } else {
+ // If the job numbers have changed, the indexer has finished the job
+ // we were previously tracking, so convert the corresponding process
+ // into an event and start a new process to track the new job.
+ if (this.currentJob && aJobNumber != this.currentJob.jobNumber) {
+ this.onJobCompleted();
+ }
+
+ // If we aren't tracking a job, either this is the first time we've been
+ // called or the last job we were tracking was completed. Either way,
+ // start tracking the new job.
+ if (!this.currentJob) {
+ this.onJobBegun(aFolder, aJobNumber, aTotalItemNum, aJobType);
+ }
+
+ // If there is only one item, don't bother creating a progress item.
+ if (aTotalItemNum != 1) {
+ this.onJobProgress(aFolder, aItemNumber, aTotalItemNum);
+ }
+ }
+ },
+
+ onJobBegun(aFolder, aJobNumber, aTotalItemNum, aJobType) {
+ let displayText = aFolder
+ ? this.getString("indexingFolder").replace("#1", aFolder)
+ : this.getString("indexing");
+ let process = new nsActProcess(displayText, lazy.Gloda);
+
+ process.iconClass = "indexMail";
+ process.contextType = "account";
+ process.contextObj = aFolder;
+ process.addSubject(aFolder);
+
+ this.currentJob = {
+ folder: aFolder,
+ jobNumber: aJobNumber,
+ process,
+ startTime: new Date(),
+ totalItemNum: aTotalItemNum,
+ jobType: aJobType,
+ };
+
+ this.activityMgr.addActivity(process);
+ },
+
+ onJobProgress(aFolder, aItemNumber, aTotalItemNum) {
+ this.currentJob.process.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ // The total number of items being processed in the job can change, as can
+ // the folder being processed, since we sometimes get notified about a job
+ // before it has determined these things, so we update them here.
+ this.currentJob.folder = aFolder;
+ this.currentJob.totalItemNum = aTotalItemNum;
+
+ let statusText;
+ if (aTotalItemNum == null) {
+ statusText = aFolder
+ ? this.getString("indexingFolderStatusVague").replace("#1", aFolder)
+ : this.getString("indexingStatusVague");
+ } else {
+ let percentComplete =
+ aTotalItemNum == 0
+ ? 100
+ : parseInt((aItemNumber / aTotalItemNum) * 100);
+ // Note: we must replace the folder name placeholder last; otherwise,
+ // if the name happens to contain another one of the placeholders, we'll
+ // hork the name when replacing it.
+ statusText = this.getString(
+ aFolder ? "indexingFolderStatusExact" : "indexingStatusExact"
+ );
+ statusText = lazy.PluralForm.get(aTotalItemNum, statusText)
+ .replace("#1", aItemNumber + 1)
+ .replace("#2", aTotalItemNum)
+ .replace("#3", percentComplete)
+ .replace("#4", aFolder);
+ }
+
+ this.currentJob.process.setProgress(statusText, aItemNumber, aTotalItemNum);
+ },
+
+ onJobCompleted() {
+ this.currentJob.process.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+
+ this.activityMgr.removeActivity(this.currentJob.process.id);
+
+ // this.currentJob.totalItemNum might still be null at this point
+ // if we were first notified about the job before the indexer determined
+ // the number of messages to index and then it didn't find any to index.
+ let totalItemNum = this.currentJob.totalItemNum || 0;
+
+ // We only create activity events when specific folders get indexed,
+ // since event-driven indexing jobs are too numerous. We also only create
+ // them when we ended up indexing something in the folder, since otherwise
+ // we'd spam the activity manager with too many "indexed 0 messages" items
+ // that aren't useful enough to justify their presence in the manager.
+ // TODO: Aggregate event-driven indexing jobs into batches significant
+ // enough for us to create activity events for them.
+ if (
+ this.currentJob.jobType == "folder" &&
+ this.currentJob.folder &&
+ totalItemNum > 0
+ ) {
+ // Note: we must replace the folder name placeholder last; otherwise,
+ // if the name happens to contain another one of the placeholders, we'll
+ // hork the name when replacing it.
+ let displayText = lazy.PluralForm.get(
+ totalItemNum,
+ this.getString("indexedFolder")
+ )
+ .replace("#1", totalItemNum)
+ .replace("#2", this.currentJob.folder);
+
+ let endTime = new Date();
+ let secondsElapsed = parseInt(
+ (endTime - this.currentJob.startTime) / 1000
+ );
+
+ let statusText = lazy.PluralForm.get(
+ secondsElapsed,
+ this.getString("indexedFolderStatus")
+ ).replace("#1", secondsElapsed);
+
+ let event = new nsActEvent(
+ displayText,
+ lazy.Gloda,
+ statusText,
+ this.currentJob.startTime,
+ endTime
+ );
+ event.contextType = this.currentJob.contextType;
+ event.contextObj = this.currentJob.contextObj;
+ event.iconClass = "indexMail";
+
+ // Transfer subjects.
+ let subjects = this.currentJob.process.getSubjects();
+ for (let subject of subjects) {
+ event.addSubject(subject);
+ }
+
+ this.activityMgr.addActivity(event);
+ }
+
+ this.currentJob = null;
+ },
+};
diff --git a/comm/mail/components/activity/modules/moveCopy.jsm b/comm/mail/components/activity/modules/moveCopy.jsm
new file mode 100644
index 0000000000..de3e51d85b
--- /dev/null
+++ b/comm/mail/components/activity/modules/moveCopy.jsm
@@ -0,0 +1,396 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["moveCopyModule"];
+
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+// This module provides a link between the move/copy code and the activity
+// manager.
+var moveCopyModule = {
+ lastMessage: {},
+ lastFolder: {},
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ msgAdded(aMsg) {},
+
+ msgsDeleted(aMsgList) {
+ this.log.info("in msgsDeleted");
+
+ if (aMsgList.length <= 0) {
+ return;
+ }
+
+ let displayCount = aMsgList.length;
+ // get the folder of the deleted messages
+ let folder = aMsgList[0].folder;
+
+ let activities = this.activityMgr.getActivities();
+ if (
+ activities.length > 0 &&
+ activities[activities.length - 1].id == this.lastMessage.id &&
+ this.lastMessage.type == "deleteMail" &&
+ this.lastMessage.folder == folder.prettyName
+ ) {
+ displayCount += this.lastMessage.count;
+ this.activityMgr.removeActivity(this.lastMessage.id);
+ }
+
+ this.lastMessage = {};
+ let displayText = PluralForm.get(
+ displayCount,
+ this.getString("deletedMessages2")
+ );
+ displayText = displayText.replace("#1", displayCount);
+ this.lastMessage.count = displayCount;
+ displayText = displayText.replace("#2", folder.prettyName);
+ this.lastMessage.folder = folder.prettyName;
+
+ let statusText = folder.server.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ folder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "deleteMail";
+ this.lastMessage.type = event.iconClass;
+
+ for (let msgHdr of aMsgList) {
+ event.addSubject(msgHdr.messageId);
+ }
+
+ this.lastMessage.id = this.activityMgr.addActivity(event);
+ },
+
+ msgsMoveCopyCompleted(aMove, aSrcMsgList, aDestFolder) {
+ try {
+ this.log.info("in msgsMoveCopyCompleted");
+
+ let count = aSrcMsgList.length;
+ if (count <= 0) {
+ return;
+ }
+
+ // get the folder of the moved/copied messages
+ let folder = aSrcMsgList[0].folder;
+ this.log.info("got folder");
+
+ let displayCount = count;
+
+ let activities = this.activityMgr.getActivities();
+ if (
+ activities.length > 0 &&
+ activities[activities.length - 1].id == this.lastMessage.id &&
+ this.lastMessage.type == (aMove ? "moveMail" : "copyMail") &&
+ this.lastMessage.sourceFolder == folder.prettyName &&
+ this.lastMessage.destFolder == aDestFolder.prettyName
+ ) {
+ displayCount += this.lastMessage.count;
+ this.activityMgr.removeActivity(this.lastMessage.id);
+ }
+
+ let statusText = "";
+ if (folder.server != aDestFolder.server) {
+ statusText = this.getString("fromServerToServer");
+ statusText = statusText.replace("#1", folder.server.prettyName);
+ statusText = statusText.replace("#2", aDestFolder.server.prettyName);
+ } else {
+ statusText = folder.server.prettyName;
+ }
+
+ this.lastMessage = {};
+ let displayText;
+ if (aMove) {
+ displayText = PluralForm.get(
+ displayCount,
+ this.getString("movedMessages")
+ );
+ } else {
+ displayText = PluralForm.get(
+ displayCount,
+ this.getString("copiedMessages")
+ );
+ }
+
+ displayText = displayText.replace("#1", displayCount);
+ this.lastMessage.count = displayCount;
+ displayText = displayText.replace("#2", folder.prettyName);
+ this.lastMessage.sourceFolder = folder.prettyName;
+ displayText = displayText.replace("#3", aDestFolder.prettyName);
+ this.lastMessage.destFolder = aDestFolder.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ folder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+ event.iconClass = aMove ? "moveMail" : "copyMail";
+ this.lastMessage.type = event.iconClass;
+
+ for (let msgHdr of aSrcMsgList) {
+ event.addSubject(msgHdr.messageId);
+ }
+ this.lastMessage.id = this.activityMgr.addActivity(event);
+ } catch (e) {
+ this.log.error("Exception: " + e);
+ }
+ },
+
+ folderAdded(aFolder) {},
+
+ folderDeleted(aFolder) {
+ // When a new account is created we get this notification with an empty named
+ // folder that can't return its server. Ignore it.
+ // TODO: find out what it is.
+ let server = aFolder.server;
+ // If the account has been removed, we're going to ignore this notification.
+ if (
+ !MailServices.accounts.findServer(
+ server.username,
+ server.hostName,
+ server.type
+ )
+ ) {
+ return;
+ }
+
+ let displayText;
+ let statusText = server.prettyName;
+
+ // Display a different message depending on whether we emptied the trash
+ // or actually deleted a folder
+ if (aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, false)) {
+ displayText = this.getString("emptiedTrash");
+ } else {
+ displayText = this.getString("deletedFolder").replace(
+ "#1",
+ aFolder.prettyName
+ );
+ }
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ server,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.addSubject(aFolder);
+ event.iconClass = "deleteMail";
+
+ // When we rename, we get a delete event as well as a rename, so store
+ // the last folder we deleted
+ this.lastFolder = {};
+ this.lastFolder.URI = aFolder.URI;
+ this.lastFolder.event = this.activityMgr.addActivity(event);
+ },
+
+ folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) {
+ this.log.info("in folderMoveCopyCompleted, aMove = " + aMove);
+
+ let displayText;
+ if (aMove) {
+ displayText = this.getString("movedFolder");
+ } else {
+ displayText = this.getString("copiedFolder");
+ }
+
+ displayText = displayText.replace("#1", aSrcFolder.prettyName);
+ displayText = displayText.replace("#2", aDestFolder.prettyName);
+
+ let statusText = "";
+ if (aSrcFolder.server != aDestFolder.server) {
+ statusText = this.getString("fromServerToServer");
+ statusText = statusText.replace("#1", aSrcFolder.server.prettyName);
+ statusText = statusText.replace("#2", aDestFolder.server.prettyName);
+ } else {
+ statusText = aSrcFolder.server.prettyName;
+ }
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aSrcFolder.server,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.addSubject(aSrcFolder);
+ event.addSubject(aDestFolder);
+ event.iconClass = aMove ? "moveMail" : "copyMail";
+
+ this.activityMgr.addActivity(event);
+ },
+
+ folderRenamed(aOrigFolder, aNewFolder) {
+ this.log.info(
+ "in folderRenamed, aOrigFolder = " +
+ aOrigFolder.prettyName +
+ ", aNewFolder = " +
+ aNewFolder.prettyName
+ );
+
+ let displayText;
+ let statusText = aNewFolder.server.prettyName;
+
+ // Display a different message depending on whether we moved the folder
+ // to the trash or actually renamed the folder.
+ if (aNewFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true)) {
+ displayText = this.getString("movedFolderToTrash");
+ displayText = displayText.replace("#1", aOrigFolder.prettyName);
+ } else {
+ displayText = this.getString("renamedFolder");
+ displayText = displayText.replace("#1", aOrigFolder.prettyName);
+ displayText = displayText.replace("#2", aNewFolder.prettyName);
+ }
+
+ // When renaming a folder, a delete event is always fired first
+ if (this.lastFolder.URI == aOrigFolder.URI) {
+ this.activityMgr.removeActivity(this.lastFolder.event);
+ }
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aOrigFolder.server,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.addSubject(aOrigFolder);
+ event.addSubject(aNewFolder);
+
+ this.activityMgr.addActivity(event);
+ },
+
+ msgUnincorporatedMoved(srcFolder, msgHdr) {
+ try {
+ this.log.info("in msgUnincorporatedMoved");
+
+ // get the folder of the moved/copied messages
+ let destFolder = msgHdr.folder;
+ this.log.info("got folder");
+
+ let displayCount = 1;
+
+ let activities = this.activityMgr.getActivities();
+ if (
+ activities.length > 0 &&
+ activities[activities.length - 1].id == this.lastMessage.id &&
+ this.lastMessage.type == "moveMail" &&
+ this.lastMessage.sourceFolder == srcFolder.prettyName &&
+ this.lastMessage.destFolder == destFolder.prettyName
+ ) {
+ displayCount += this.lastMessage.count;
+ this.activityMgr.removeActivity(this.lastMessage.id);
+ }
+
+ let statusText = "";
+ if (srcFolder.server != destFolder.server) {
+ statusText = this.getString("fromServerToServer");
+ statusText = statusText.replace("#1", srcFolder.server.prettyName);
+ statusText = statusText.replace("#2", destFolder.server.prettyName);
+ } else {
+ statusText = srcFolder.server.prettyName;
+ }
+
+ this.lastMessage = {};
+ let displayText;
+ displayText = PluralForm.get(
+ displayCount,
+ this.getString("movedMessages")
+ );
+
+ displayText = displayText.replace("#1", displayCount);
+ this.lastMessage.count = displayCount;
+ displayText = displayText.replace("#2", srcFolder.prettyName);
+ this.lastMessage.sourceFolder = srcFolder.prettyName;
+ displayText = displayText.replace("#3", destFolder.prettyName);
+ this.lastMessage.destFolder = destFolder.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ srcFolder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "moveMail";
+ this.lastMessage.type = event.iconClass;
+ event.addSubject(msgHdr.messageId);
+ this.lastMessage.id = this.activityMgr.addActivity(event);
+ } catch (e) {
+ this.log.error("Exception: " + e);
+ }
+ },
+
+ init() {
+ // XXX when do we need to remove ourselves?
+ MailServices.mfn.addListener(
+ this,
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed |
+ MailServices.mfn.msgUnincorporatedMoved
+ );
+ },
+};
diff --git a/comm/mail/components/activity/modules/pop3Download.jsm b/comm/mail/components/activity/modules/pop3Download.jsm
new file mode 100644
index 0000000000..f203b33212
--- /dev/null
+++ b/comm/mail/components/activity/modules/pop3Download.jsm
@@ -0,0 +1,154 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["pop3DownloadModule"];
+
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+// This module provides a link between the pop3 service code and the activity
+// manager.
+var pop3DownloadModule = {
+ // hash table of most recent download items per folder
+ _mostRecentActivityForFolder: new Map(),
+ // hash table of prev download items per folder, so we can
+ // coalesce consecutive no new message events.
+ _prevActivityForFolder: new Map(),
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ getString(stringName) {
+ try {
+ return this.bundle.GetStringFromName(stringName);
+ } catch (e) {
+ this.log.error("error trying to get a string called: " + stringName);
+ throw e;
+ }
+ },
+
+ onDownloadStarted(aFolder) {
+ this.log.info("in onDownloadStarted");
+
+ let displayText = this.bundle.formatStringFromName(
+ "pop3EventStartDisplayText2",
+ [
+ aFolder.server.prettyName, // account name
+ aFolder.prettyName,
+ ]
+ ); // folder name
+ // remember the prev activity for this folder, if any.
+ this._prevActivityForFolder.set(
+ aFolder.URI,
+ this._mostRecentActivityForFolder.get(aFolder.URI)
+ );
+ let statusText = aFolder.server.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aFolder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "syncMail";
+
+ let downloadItem = {};
+ downloadItem.eventID = this.activityMgr.addActivity(event);
+ this._mostRecentActivityForFolder.set(aFolder.URI, downloadItem);
+ },
+
+ onDownloadProgress(aFolder, aNumMsgsDownloaded, aTotalMsgs) {
+ this.log.info("in onDownloadProgress");
+ },
+
+ onDownloadCompleted(aFolder, aNumMsgsDownloaded) {
+ this.log.info("in onDownloadCompleted");
+
+ // Remove activity if there was any.
+ // It can happen that download never started (e.g. couldn't connect to server),
+ // with onDownloadStarted, but we still get a onDownloadCompleted event
+ // when the connection is given up.
+ let recentActivity = this._mostRecentActivityForFolder.get(aFolder.URI);
+ if (recentActivity) {
+ this.activityMgr.removeActivity(recentActivity.eventID);
+ }
+
+ let displayText;
+ if (aNumMsgsDownloaded > 0) {
+ displayText = PluralForm.get(
+ aNumMsgsDownloaded,
+ this.getString("pop3EventStatusText")
+ );
+ displayText = displayText.replace("#1", aNumMsgsDownloaded);
+ } else {
+ displayText = this.getString("pop3EventStatusTextNoMsgs");
+ }
+
+ let statusText = aFolder.server.prettyName;
+
+ // create an activity event
+ let event = new nsActEvent(
+ displayText,
+ aFolder,
+ statusText,
+ Date.now(), // start time
+ Date.now()
+ ); // completion time
+
+ event.iconClass = "syncMail";
+
+ let downloadItem = { numMsgsDownloaded: aNumMsgsDownloaded };
+ this._mostRecentActivityForFolder.set(aFolder.URI, downloadItem);
+ downloadItem.eventID = this.activityMgr.addActivity(event);
+ if (!aNumMsgsDownloaded) {
+ // If we didn't download any messages this time, and the prev event
+ // for this folder also didn't download any messages, remove the
+ // prev event from the activity manager.
+ let prevItem = this._prevActivityForFolder.get(aFolder.URI);
+ if (prevItem != undefined && !prevItem.numMsgsDownloaded) {
+ if (this.activityMgr.containsActivity(prevItem.eventID)) {
+ this.activityMgr.removeActivity(prevItem.eventID);
+ }
+ }
+ }
+ },
+ init() {
+ // XXX when do we need to remove ourselves?
+ MailServices.pop3.addListener(this);
+ },
+};
diff --git a/comm/mail/components/activity/modules/sendLater.jsm b/comm/mail/components/activity/modules/sendLater.jsm
new file mode 100644
index 0000000000..37027d96f1
--- /dev/null
+++ b/comm/mail/components/activity/modules/sendLater.jsm
@@ -0,0 +1,298 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["sendLaterModule"];
+
+var nsActProcess = Components.Constructor(
+ "@mozilla.org/activity-process;1",
+ "nsIActivityProcess",
+ "init"
+);
+var nsActEvent = Components.Constructor(
+ "@mozilla.org/activity-event;1",
+ "nsIActivityEvent",
+ "init"
+);
+var nsActWarning = Components.Constructor(
+ "@mozilla.org/activity-warning;1",
+ "nsIActivityWarning",
+ "init"
+);
+
+/**
+ * This really, really, sucks. Due to mailnews widespread use of
+ * nsIMsgStatusFeedback we're bound to the UI to get any sensible feedback of
+ * mail sending operations. The current send later code can't hook into the
+ * progress listener easily to get the state of messages being sent, so we'll
+ * just have to do it here.
+ */
+var sendMsgProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgStatusFeedback",
+ "nsISupportsWeakReference",
+ ]),
+
+ showStatusString(aStatusText) {
+ sendLaterModule.onMsgStatus(aStatusText);
+ },
+
+ startMeteors() {},
+
+ stopMeteors() {},
+
+ showProgress(aPercentage) {
+ sendLaterModule.onMessageSendProgress(0, 0, aPercentage, 0);
+ },
+};
+
+// This module provides a link between the send later service and the activity
+// manager.
+var sendLaterModule = {
+ _sendProcess: null,
+ _copyProcess: null,
+ _identity: null,
+ _subject: null,
+
+ get log() {
+ delete this.log;
+ return (this.log = console.createInstance({
+ prefix: "mail.activity",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.activity.loglevel",
+ }));
+ },
+
+ get activityMgr() {
+ delete this.activityMgr;
+ return (this.activityMgr = Cc["@mozilla.org/activity-manager;1"].getService(
+ Ci.nsIActivityManager
+ ));
+ },
+
+ get bundle() {
+ delete this.bundle;
+ return (this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/activity.properties"
+ ));
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgSendLaterListener"]),
+
+ _displayTextForHeader(aLocaleStringBase, aSubject) {
+ return aSubject
+ ? this.bundle.formatStringFromName(aLocaleStringBase + "WithSubject", [
+ aSubject,
+ ])
+ : this.bundle.GetStringFromName(aLocaleStringBase);
+ },
+
+ _newProcess(aLocaleStringBase, aAddSubject) {
+ let process = new nsActProcess(
+ this._displayTextForHeader(
+ aLocaleStringBase,
+ aAddSubject ? this._subject : ""
+ ),
+ this.activityMgr
+ );
+
+ process.iconClass = "sendMail";
+ process.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+ process.contextObj = this;
+ process.contextType = "SendLater";
+ process.contextDisplayText =
+ this.bundle.GetStringFromName("sendingMessages");
+
+ return process;
+ },
+
+ // Use this to group an activity by the identity if we have one.
+ _applyIdentityGrouping(aActivity) {
+ if (this._identity) {
+ aActivity.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_BYCONTEXT;
+ aActivity.contextType = this._identity.key;
+ aActivity.contextObj = this._identity;
+ let contextDisplayText = this._identity.identityName;
+ if (!contextDisplayText) {
+ contextDisplayText = this._identity.email;
+ }
+
+ aActivity.contextDisplayText = contextDisplayText;
+ } else {
+ aActivity.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+ }
+ },
+
+ // Replaces the process with an event that reflects a completed process.
+ _replaceProcessWithEvent(aProcess) {
+ this.activityMgr.removeActivity(aProcess.id);
+
+ let event = new nsActEvent(
+ this._displayTextForHeader("sentMessage", this._subject),
+ this.activityMgr,
+ "",
+ aProcess.startTime,
+ new Date()
+ );
+
+ event.iconClass = "sendMail";
+ this._applyIdentityGrouping(event);
+
+ this.activityMgr.addActivity(event);
+ },
+
+ // Replaces the process with a warning that reflects the failed process.
+ _replaceProcessWithWarning(
+ aProcess,
+ aCopyOrSend,
+ aStatus,
+ aMsg,
+ aMessageHeader
+ ) {
+ this.activityMgr.removeActivity(aProcess.id);
+
+ let warning = new nsActWarning(
+ this._displayTextForHeader("failedTo" + aCopyOrSend, this._subject),
+ this.activityMgr,
+ ""
+ );
+
+ warning.groupingStyle = Ci.nsIActivity.GROUPING_STYLE_STANDALONE;
+ this._applyIdentityGrouping(warning);
+
+ this.activityMgr.addActivity(warning);
+ },
+
+ onStartSending(aTotalMessageCount) {
+ if (!aTotalMessageCount) {
+ this.log.error("onStartSending called with zero messages\n");
+ }
+ },
+
+ onMessageStartSending(
+ aCurrentMessage,
+ aTotalMessageCount,
+ aMessageHeader,
+ aIdentity
+ ) {
+ // We want to use the identity and subject later, so store them for now.
+ this._identity = aIdentity;
+ if (aMessageHeader) {
+ this._subject = aMessageHeader.mime2DecodedSubject;
+ }
+
+ // Create the process to display the send activity.
+ let process = this._newProcess("sendingMessage", true);
+ this._sendProcess = process;
+ this.activityMgr.addActivity(process);
+
+ // Now the one for the copy process.
+ process = this._newProcess("copyMessage", false);
+ this._copyProcess = process;
+ this.activityMgr.addActivity(process);
+ },
+
+ onMessageSendProgress(
+ aCurrentMessage,
+ aTotalMessageCount,
+ aMessageSendPercent,
+ aMessageCopyPercent
+ ) {
+ if (aMessageSendPercent < 100) {
+ // Ensure we are in progress...
+ if (this._sendProcess.state != Ci.nsIActivityProcess.STATE_INPROGRESS) {
+ this._sendProcess.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ }
+
+ // ... and update the progress.
+ this._sendProcess.setProgress(
+ this._sendProcess.lastStatusText,
+ aMessageSendPercent,
+ 100
+ );
+ } else if (aMessageSendPercent == 100) {
+ if (aMessageCopyPercent == 0) {
+ // Set send state to completed
+ if (this._sendProcess.state != Ci.nsIActivityProcess.STATE_COMPLETED) {
+ this._sendProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ }
+ this._replaceProcessWithEvent(this._sendProcess);
+
+ // Set copy state to in progress.
+ if (this._copyProcess.state != Ci.nsIActivityProcess.STATE_INPROGRESS) {
+ this._copyProcess.state = Ci.nsIActivityProcess.STATE_INPROGRESS;
+ }
+
+ // We don't know the progress of the copy, so just set to 0, and we'll
+ // display an undetermined progress meter.
+ this._copyProcess.setProgress(this._copyProcess.lastStatusText, 0, 0);
+ } else if (aMessageCopyPercent >= 100) {
+ // We need to set this to completed otherwise activity manager
+ // complains.
+ if (this._copyProcess) {
+ this._copyProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ this.activityMgr.removeActivity(this._copyProcess.id);
+ this._copyProcess = null;
+ }
+
+ this._sendProcess = null;
+ }
+ }
+ },
+
+ onMessageSendError(aCurrentMessage, aMessageHeader, aStatus, aMsg) {
+ if (
+ this._sendProcess &&
+ this._sendProcess.state != Ci.nsIActivityProcess.STATE_COMPLETED
+ ) {
+ this._sendProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ this._replaceProcessWithWarning(
+ this._sendProcess,
+ "SendMessage",
+ aStatus,
+ aMsg,
+ aMessageHeader
+ );
+ this._sendProcess = null;
+
+ if (
+ this._copyProcess &&
+ this._copyProcess.state != Ci.nsIActivityProcess.STATE_COMPLETED
+ ) {
+ this._copyProcess.state = Ci.nsIActivityProcess.STATE_COMPLETED;
+ this.activityMgr.removeActivity(this._copyProcess.id);
+ this._copyProcess = null;
+ }
+ }
+ },
+
+ onMsgStatus(aStatusText) {
+ this._sendProcess.setProgress(
+ aStatusText,
+ this._sendProcess.workUnitComplete,
+ this._sendProcess.totalWorkUnits
+ );
+ },
+
+ onStopSending(aStatus, aMsg, aTotalTried, aSuccessful) {},
+
+ init() {
+ // We should need to remove the listener as we're not being held by anyone
+ // except by the send later instance.
+ let sendLaterService = Cc[
+ "@mozilla.org/messengercompose/sendlater;1"
+ ].getService(Ci.nsIMsgSendLater);
+
+ sendLaterService.addListener(this);
+
+ // Also add the nsIMsgStatusFeedback object.
+ let statusFeedback = Cc[
+ "@mozilla.org/messenger/statusfeedback;1"
+ ].createInstance(Ci.nsIMsgStatusFeedback);
+
+ statusFeedback.setWrappedStatusFeedback(sendMsgProgressListener);
+
+ sendLaterService.statusFeedback = statusFeedback;
+ },
+};
diff --git a/comm/mail/components/activity/moz.build b/comm/mail/components/activity/moz.build
new file mode 100644
index 0000000000..efceaacf9f
--- /dev/null
+++ b/comm/mail/components/activity/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "nsIActivity.idl",
+ "nsIActivityManager.idl",
+ "nsIActivityManagerUI.idl",
+]
+
+XPIDL_MODULE = "activity"
+
+EXTRA_JS_MODULES.activity += [
+ "modules/activityModules.jsm",
+ "modules/alertHook.jsm",
+ "modules/autosync.jsm",
+ "modules/glodaIndexer.jsm",
+ "modules/moveCopy.jsm",
+ "modules/pop3Download.jsm",
+ "modules/sendLater.jsm",
+]
+
+EXTRA_JS_MODULES += [
+ "Activity.jsm",
+ "ActivityManager.jsm",
+ "ActivityManagerUI.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/activity/nsIActivity.idl b/comm/mail/components/activity/nsIActivity.idl
new file mode 100644
index 0000000000..d80e69088f
--- /dev/null
+++ b/comm/mail/components/activity/nsIActivity.idl
@@ -0,0 +1,492 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "nsISupportsPrimitives.idl"
+
+interface nsIActivityListener;
+interface nsIActivity;
+interface nsIActivityProcess;
+interface nsIActivityEvent;
+interface nsIActivityWarning;
+interface nsIVariant;
+interface nsISupportsPRTime;
+
+/**
+ * See https://wiki.mozilla.org/Thunderbird:Activity_Manager/Developer for UML
+ * diagram and sample codes.
+ */
+
+/**
+ * Background:
+ * Activity handlers define the behavioral capabilities of the activities. They
+ * are used by the Activity Manager to change the execution flow of the activity
+ * based on the user interaction. They are not mandatory, but when set, causes
+ * behavioral changes on the binding representing the activity, such as showing
+ * a cancel button, etc. The following handlers are currently supported;
+ */
+
+/**
+ * The handler to invoke when the recover button is pressed. Used by a Warning
+ * to recover from the situation causing the warning. For instance, recovery
+ * action for a "Over Quota Limit" warning, would be to cleanup some disk space,
+ * and this operation can be implemented and set by the activity developer in
+ * form of nsIActivityRecoveryHandler component.
+ */
+[scriptable, uuid(30E0A76F-880A-4093-8F3C-AF2239977A3D)]
+interface nsIActivityRecoveryHandler : nsISupports {
+ nsresult recover(in nsIActivityWarning aActivity);
+};
+
+/**
+ * The handler to invoke when the cancel button is pressed. Used by a Process to
+ * cancel the operation.
+ */
+[scriptable, uuid(35ee2461-70db-4b3a-90d0-7a68c856e911)]
+interface nsIActivityCancelHandler : nsISupports {
+ nsresult cancel(in nsIActivityProcess aActivity);
+};
+
+/**
+ * The handler to invoke when the pause button is pressed. Used by a Process to
+ * pause/resume the operation.
+ */
+[scriptable, uuid(9eee22bf-5378-460e-83a7-781cdcc9050b)]
+interface nsIActivityPauseHandler : nsISupports {
+ nsresult pause(in nsIActivityProcess aActivity);
+ nsresult resume(in nsIActivityProcess aActivity);
+};
+
+/**
+ * The handler to invoke when the retry button is pressed. Used by a Process to
+ * retry the operation in case of failure.
+ */
+[scriptable, uuid(8ec42517-951f-4bc0-aba5-fde7258b1705)]
+interface nsIActivityRetryHandler : nsISupports {
+ nsresult retry(in nsIActivityProcess aActivity);
+};
+
+/**
+ * The handler to invoke when the undo button is pressed. Used by a Event to
+ * undo the operation generated the event.
+ */
+[scriptable, uuid(b8632ac7-9d8b-4341-a349-ef000e8c89ac)]
+interface nsIActivityUndoHandler : nsISupports {
+ nsresult undo(in nsIActivityEvent aActivity);
+};
+
+/**
+ * Base interface of all activity interfaces. It is abstract in a sense that
+ * there is no component in the activity management system that solely
+ * implements this interface.
+ */
+[scriptable, uuid(6CD33E65-B2D8-4634-9B6D-B80BF1273E99)]
+interface nsIActivity : nsISupports {
+
+ /**
+ * Shows the activity as a standalone item.
+ */
+ const short GROUPING_STYLE_STANDALONE = 1;
+
+ /**
+ * Groups activity by its context.
+ */
+ const short GROUPING_STYLE_BYCONTEXT = 2;
+
+ /**
+ * Internal ID given by the activity manager when
+ * added into the activity list. Not readonly so that
+ * the activity manager can write to them, but not to be written to
+ * by anyone else.
+ */
+ attribute unsigned long id;
+
+ // Following attributes change the UI characteristics of the activity
+
+ /**
+ * A brief description of the activity, to be shown by the
+ * associated binding (XBL) in the Activity Manager window.
+ */
+ readonly attribute AString displayText;
+
+ /**
+ * Changes the default icon associated with the activity. Core activity
+ * icons are declared in |mail/themes/<themename>/mail/activity/activity.css|
+ * files.
+ *
+ * Extension developers can add and assign their own icons by setting
+ * this attribute.
+ */
+ attribute AString iconClass;
+
+ /**
+ * Textual id of the XBL binding that will be used to represent the
+ * activity in the Activity Manager window.
+ *
+ * This attribute allows to associate default activity components
+ * with custom XBL bindings. See |activity.xml| file for default
+ * activity XBL bindings, and |activity.css| file for default binding
+ * associations.
+ */
+ attribute AString bindingName;
+
+ /**
+ * Defines the grouping style of the activity when being shown in the
+ * activity manager window:
+ * GROUPING_STYLE_STANDALONE or GROUPING_STYLE_BYCONTEXT
+ */
+ attribute short groupingStyle;
+
+ /**
+ * A text value to associate a facet type with the activity. If empty,
+ * the activity will be shown in the 'Misc' section.
+ */
+ attribute AString facet;
+
+ // UI related attributes end.
+
+ /**
+ * Gets the initiator of the activity. An initiator represents an object
+ * that generates and controls the activity. For example, Copy Service can be
+ * the initiator of the copy, and move activities. Similarly Gloda can be the
+ * initiator of indexing activity, etc.
+ *
+ * This attribute is used mostly by handler components to change the execution
+ * flow of the activity such as canceling, pausing etc. Since not used by the
+ * Activity Manager, it is not mandatory to set it.
+ *
+ * An initiator can be any JS Object or an XPCOM component that provides an
+ * nsIVariant interface.
+ */
+ readonly attribute nsIVariant initiator;
+
+ /**
+ * Adds an object to the activity's internal subject list. Subject list
+ * provides argument(s) to activity handlers to complete their operation.
+ * For example, nsIActivityUndoHandler needs the source and destination
+ * folders to undo a move operation.
+ *
+ * Since subjects are not used by the Activity Manager, it is up to the
+ * activity developer to provide these objects.
+ *
+ * A subject can be any JS object or XPCOM component that supports nsIVariant
+ * interface.
+ */
+ void addSubject(in nsIVariant aSubject);
+
+ /**
+ * Retrieves all subjects associated with this activity.
+ *
+ * @return The list of subject objects associated by the activity.
+ */
+ Array<nsIVariant> getSubjects();
+
+ /*
+ * Background:
+ * A context is a generic concept that is used to group the processes and
+ * warnings having similar properties such as same imap server, same smtp
+ * server etc.
+ * A context is uniquely identified by its "type" and "object" attributes.
+ * Each activity that has the same context type and object are considered
+ * belong to the same logical group, context.
+ *
+ * There are 4 predefined context types known by the Activity Manager:
+ * Account, Smtp, Calendar, and Addressbook. The most common context type
+ * for activities is the "Account Context" and when combined with an account
+ * server instance, it allows to group different activities happening on the
+ * the same account server.
+ */
+
+ /**
+ * Sets and gets the context object of the activity. A context object can be
+ * any JS object or XPCOM component that supports nsIVariant interface.
+ */
+ attribute nsIVariant contextObj;
+
+ /**
+ * Sets and gets the context type of the activity. If this is set, then
+ * the contextDisplayText should also be set.
+ */
+ attribute AString contextType;
+
+ /**
+ * Return the displayText to be used for the context
+ **/
+ attribute AString contextDisplayText;
+
+ /**
+ * Adds a listener. See nsIActivityListener below.
+ */
+ void addListener(in nsIActivityListener aListener);
+
+ /**
+ * Removes the given listener. See nsIActivityListener below.
+ */
+ void removeListener(in nsIActivityListener aListener);
+};
+
+
+/**
+ * A Process represents an on-going activity.
+ */
+[scriptable, uuid(9DC7CA67-828D-4AFD-A5C6-3ECE091A98B8)]
+interface nsIActivityProcess : nsIActivity {
+
+ /**
+ * Default state for uninitialized process activity
+ * object.
+ */
+ const short STATE_NOTSTARTED = -1;
+
+ /**
+ * Activity is currently in progress.
+ */
+ const short STATE_INPROGRESS = 0;
+
+ /**
+ * Activity is completed.
+ */
+ const short STATE_COMPLETED = 1;
+
+ /**
+ * Activity was canceled by the user.
+ * (same as completed)
+ */
+ const short STATE_CANCELED = 2;
+
+ /**
+ * Activity was paused by the user.
+ */
+ const short STATE_PAUSED = 3;
+
+ /**
+ * Activity waits for the user input's to retry.
+ * (i.e. login password)
+ */
+ const short STATE_WAITINGFORINPUT = 4;
+
+ /**
+ * Activity is ready for an automatic or manual retry.
+ */
+ const short STATE_WAITINGFORRETRY = 5;
+
+ /**
+ * The state of the activity.
+ * See above for possible values.
+ * @exception NS_ERROR_ILLEGAL_VALUE if the state isn't one of the states
+ * defined above.
+ */
+ attribute short state;
+
+ /**
+ * The percentage of activity completed.
+ * If the max value is unknown it'll be -1 here.
+ */
+ readonly attribute long percentComplete;
+
+ /**
+ * A brief text about the process' status.
+ */
+ readonly attribute AString lastStatusText;
+
+ /**
+ * The amount of work units completed so far.
+ */
+ readonly attribute unsigned long workUnitComplete;
+
+ /**
+ * Total amount of work units.
+ */
+ readonly attribute unsigned long totalWorkUnits;
+
+ /**
+ * The starting time of the process.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long startTime;
+
+ /**
+ * The handler to invoke when the cancel button is pressed. If present
+ * (non-null), the activity can be canceled and a cancel button will be
+ * displayed to the user. If null, it cannot be canceled and no button will
+ * be displayed.
+ */
+ attribute nsIActivityCancelHandler cancelHandler;
+
+ /**
+ * The handler to invoke when the pause button is pressed. If present
+ * (non-null), the activity can be pauseable and a pause button will be
+ * displayed to the user. If null, it cannot be paused and no button will
+ * be displayed.
+ */
+ attribute nsIActivityPauseHandler pauseHandler;
+
+ /**
+ * The handler to invoke when the retry button is pressed. If present
+ * (non-null), the activity can be retryable and a retry button will be
+ * displayed to the user. If null, it cannot be retried and no button will
+ * be displayed.
+ */
+ attribute nsIActivityRetryHandler retryHandler;
+
+ /**
+ * Updates the activity progress info.
+ *
+ * @param aStatusText A localized text describing the current status of the
+ * process
+ * @param aWorkUnitComplete The amount of work units completed. Not used by
+ * Activity Manager or default binding for any
+ * purpose.
+ * @param aTotalWorkUnits Total amount of work units. Not used by
+ * Activity Manager or default binding for any
+ * purpose. If set to zero, this indicates that the
+ * number of work units is unknown, and the percentage
+ * attribute will be set to -1.
+ */
+ void setProgress(in AString aStatusText,
+ in unsigned long aWorkUnitComplete,
+ in unsigned long aTotalWorkUnits);
+
+ /**
+ * Component initialization method.
+ *
+ * @param aDisplayText A localized text to be shown on the Activity Manager
+ * window
+ * @param aInitiator The initiator of the process
+ */
+ void init(in AString aDisplayText, in nsIVariant aInitiator);
+};
+
+/**
+ * Historical actions performed by the user, by extensions or by the system.
+ */
+[scriptable, uuid(5B1B0D03-2820-4E37-8BF8-102AFDE4FC45)]
+interface nsIActivityEvent : nsIActivity {
+
+ /**
+ * Any localized textual information related to this event.
+ * It is shown at the bottom of the displayText area.
+ */
+ readonly attribute AString statusText;
+
+ /**
+ * The starting time of the event.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long startTime;
+
+ /**
+ * The completion time of the event in microseconds.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long completionTime;
+
+ /**
+ * The handler to invoke when the undo button is pressed. If present
+ * (non-null), the activity can be undoable and an undo button will be
+ * displayed to the user. If null, it cannot be undone and no button will
+ * be displayed.
+ */
+ attribute nsIActivityUndoHandler undoHandler;
+
+ /**
+ * Component initialization method.
+ *
+ * @param aDisplayText Any localized text describing the event and its context
+ * @param aInitiator The initiator of the event
+ * @param aStatusText Any localized additional information about the event
+ * @param aStartTime The starting time of the event
+ * @param aCompletionTime The completion time of the event
+ */
+ void init(in AString aDisplayText, in nsIVariant aInitiator,
+ in AString aStatusText, in long long aStartTime,
+ in long long aCompletionTime);
+};
+
+[scriptable, uuid(8265833e-c604-4585-a43c-a76bd8ed3a8c)]
+interface nsIActivityWarning : nsIActivity {
+
+ /**
+ * Any localized textual information related to this warning.
+ */
+ readonly attribute AString warningText;
+
+ /**
+ * The time of the warning.
+ * 64-bit signed integers relative to midnight (00:00:00), January 1, 1970
+ * Greenwich Mean Time (GMT). (GMT is also known as Coordinated Universal
+ * Time, UTC.). The units of time are in microseconds.
+ *
+ * In JS Date.now(), in C++ PR_Now() / PR_USEC_PER_MSEC can be used to set
+ * this value.
+ */
+ readonly attribute long long time;
+
+ /**
+ * Recovery tip of the warning, localized.
+ */
+ readonly attribute AString recoveryTipText;
+
+ /**
+ * The handler to invoke when the recover button is pressed. If present
+ * (non-null), the activity can be recoverable and a recover button will be
+ * displayed to the user. If null, it cannot be recovered and no button will
+ * be displayed.
+ */
+ attribute nsIActivityRecoveryHandler recoveryHandler;
+
+ /**
+ * Component initialization method.
+ *
+ * @param aWarningText The localized text that will be shown on the display
+ * area
+ * @param aInitiator The initiator of the warning
+ * @param aRecoveryTip A localized textual information to guide the user in
+ * order to recover from the warning situation.
+ */
+ void init(in AString aWarningText, in nsIVariant aInitiator,
+ in AString aRecoveryTip);
+};
+
+[scriptable, uuid(bd11519f-b297-4b34-a793-1861dc90d5e9)]
+interface nsIActivityListener : nsISupports {
+ /**
+ * Triggered after activity state is changed.
+ */
+ void onStateChanged(in nsIActivity aActivity, in short aOldState);
+
+ /**
+ * Triggered after the progress of the process activity is changed.
+ */
+ void onProgressChanged(in nsIActivity aActivity,
+ in AString aStatusText,
+ in unsigned long aWorkUnitsCompleted,
+ in unsigned long aTotalWorkUnits);
+
+ /**
+ * Triggered after one of the activity handler is set.
+ *
+ * This is mostly used to update the UI of the activity when
+ * one of the handler is set to null after the operation is completed.
+ * For example after the activity is undone, to make the undo button
+ * invisible.
+ */
+ void onHandlerChanged(in nsIActivity aActivity);
+};
diff --git a/comm/mail/components/activity/nsIActivityManager.idl b/comm/mail/components/activity/nsIActivityManager.idl
new file mode 100644
index 0000000000..860b4e1e2b
--- /dev/null
+++ b/comm/mail/components/activity/nsIActivityManager.idl
@@ -0,0 +1,135 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface mozIStorageConnection;
+interface nsIActivity;
+interface nsIActivityProcess;
+interface nsIVariant;
+
+/**
+ * See https://wiki.mozilla.org/Thunderbird:Activity_Manager/Developer for UML
+ * diagram and sample codes.
+ */
+
+/**
+ * An interface to get notified by the major Activity Manager events.
+ * Mostly used by UI glue code in activity.js.
+ */
+[scriptable, uuid(14cfad1c-3401-4c44-ab04-4a11b6662663)]
+interface nsIActivityMgrListener : nsISupports {
+ /**
+ * Called _after_ activity manager adds an activity into
+ * the managed list.
+ */
+ void onAddedActivity(in unsigned long aID, in nsIActivity aActivity);
+
+ /**
+ * Called _after_ activity manager removes an activity from
+ * the managed list.
+ */
+ void onRemovedActivity(in unsigned long aID);
+};
+
+/**
+ * Activity Manager is a simple component that understands how do display a
+ * combination of user activity and history. The activity manager works in
+ * conjunction with the 'Interactive Status Bar' to give the user the right
+ * level of notifications concerning what Thunderbird is doing on it's own and
+ * how Thunderbird has handled user requests.
+ *
+ * There are 3 different classifications of activity items which can be
+ * displayed in the Activity Manager Window:
+ * o Process: Processes are transient in the display. They are not written to
+ * disk as they are always acting on some data that already exists
+ * locally or remotely. If a process has finished and needs to keep
+ * some state for the user (like last sync time) it can convert
+ * itself into an event.
+ * o Event: Historical actions performed by the user and created by a process
+ * for the Activity Manager Window. Events can show up in the
+ * 'Interactive Status Bar' and be displayed to users as they are
+ * created.
+ * o Warning: Alerts sent by Thunderbird or servers (i.e. imap server) that need
+ * attention by the user. For example a Quota Alert from the imap
+ * server can be represented as a Warning to the user. They are not
+ * written to disk.
+ */
+[scriptable, uuid(9BFCC031-50E1-4D30-A35F-23509ABCB8D1)]
+interface nsIActivityManager : nsISupports {
+
+ /**
+ * Adds the given activity into the managed activities list.
+ *
+ * @param aActivity The activity that will be added.
+ *
+ * @return Unique activity identifier.
+ */
+ unsigned long addActivity(in nsIActivity aActivity);
+
+ /**
+ * Removes the activity with the given id if it's not currently
+ * in-progress.
+ *
+ * @param aID The unique ID of the activity.
+ *
+ * @throws NS_ERROR_FAILURE if the activity is in-progress.
+ */
+ void removeActivity(in unsigned long aID);
+
+ /**
+ * Retrieves an activity managed by the activity manager. This can be one that
+ * is in progress, or one that has completed in the past and is stored in the
+ * persistent store.
+ *
+ * @param aID The unique ID of the activity.
+ *
+ * @return The activity with the specified ID, or null if not found.
+ */
+ nsIActivity getActivity(in unsigned long aID);
+
+ /**
+ * Tests whether the activity in question in the activity list or not.
+ */
+ boolean containsActivity(in unsigned long aID);
+
+ /**
+ * Retrieves all activities managed by the activity manager. This can be one
+ * that is in progress (process), one that is represented as a warning, or one
+ * that has completed (event) in the past and is stored in the persistent
+ * store.
+ *
+ * @return A read-only list of activities managed by the activity manager.
+ */
+ Array<nsIActivity> getActivities();
+
+ /**
+ * Retrieves processes with given context type and object.
+ *
+ * @return A read-only list of processes matching to given criteria.
+ */
+ Array<nsIActivityProcess> getProcessesByContext(in AString aContextType,
+ in nsIVariant aContextObject);
+
+ /**
+ * Call to remove all activities apart from those that are in progress.
+ */
+ void cleanUp();
+
+ /**
+ * The number of processes in the activity list.
+ */
+ readonly attribute long processCount;
+
+ /**
+ * Adds a listener.
+ */
+ void addListener(in nsIActivityMgrListener aListener);
+
+ /**
+ * Removes the given listener.
+ */
+ void removeListener(in nsIActivityMgrListener aListener);
+};
diff --git a/comm/mail/components/activity/nsIActivityManagerUI.idl b/comm/mail/components/activity/nsIActivityManagerUI.idl
new file mode 100644
index 0000000000..07a2a30394
--- /dev/null
+++ b/comm/mail/components/activity/nsIActivityManagerUI.idl
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIInterfaceRequestor;
+
+[scriptable, uuid(ae7853b0-2e1f-4dc1-89cd-f8bbfb745d4d)]
+interface nsIActivityManagerUI : nsISupports {
+ /**
+ * The reason that should be passed when the user requests to show the
+ * activity manager's UI.
+ */
+ const short REASON_USER_INTERACTED = 0;
+
+ /**
+ * The reason that should be passed to the show method when we are displaying
+ * the UI because a new activity is being added to it.
+ */
+ const short REASON_NEW_ACTIVITY = 1;
+
+ /**
+ * Shows the Activity Manager's UI to the user.
+ *
+ * @param [optional] aWindowContext
+ * The parent window context to show the UI.
+ * @param [optional] aID
+ * The id of the activity to be preselected upon opening.
+ * @param [optional] aReason
+ * The reason to show the activity manager's UI. This defaults to
+ * REASON_USER_INTERACTED, and should be one of the previously listed
+ * constants.
+ */
+ void show([optional] in nsIInterfaceRequestor aWindowContext,
+ [optional] in unsigned long aID,
+ [optional] in short aReason);
+
+ /**
+ * Indicates if the UI is visible or not.
+ */
+ readonly attribute boolean visible;
+
+ /**
+ * Brings attention to the UI if it is already visible
+ *
+ * @throws NS_ERROR_UNEXPECTED if the UI is not visible.
+ */
+ void getAttention();
+};
diff --git a/comm/mail/components/addrbook/content/abCommon.js b/comm/mail/components/addrbook/content/abCommon.js
new file mode 100644
index 0000000000..36f251206e
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abCommon.js
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var gAbView = null;
+
+var kDefaultAscending = "ascending";
+var kDefaultDescending = "descending";
+var kAllDirectoryRoot = "moz-abdirectory://";
+var kPersonalAddressbookURI = "jsaddrbook://abook.sqlite";
+
+async function AbDelete() {
+ let types = GetSelectedCardTypes();
+ if (types == kNothingSelected) {
+ return;
+ }
+
+ let cards = GetSelectedAbCards();
+
+ // Determine strings for smart and context-sensitive user prompts
+ // for confirming deletion.
+ let action, name, list;
+ let selectedDir = gAbView.directory;
+
+ switch (types) {
+ case kListsAndCards:
+ action = "delete-mixed";
+ break;
+ case kSingleListOnly:
+ case kMultipleListsOnly:
+ action = "delete-lists";
+ name = cards[0].displayName;
+ break;
+ default: {
+ let nameFormatFromPref = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst"
+ );
+ name = cards[0].generateName(nameFormatFromPref);
+ if (selectedDir && selectedDir.isMailList) {
+ action = "remove-contacts";
+ list = selectedDir.dirName;
+ } else {
+ action = "delete-contacts";
+ }
+ break;
+ }
+ }
+
+ // Adjust strings to match translations.
+ let actionString;
+ switch (action) {
+ case "delete-contacts":
+ actionString = !cards.length
+ ? "delete-contacts-single"
+ : "delete-contacts-multi";
+ break;
+ case "remove-contacts":
+ actionString = !cards.length
+ ? "remove-contacts-single"
+ : "remove-contacts-multi";
+ break;
+ default:
+ actionString = action;
+ break;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ {
+ id: `about-addressbook-confirm-${action}-title`,
+ args: { count: cards.length },
+ },
+ {
+ id: `about-addressbook-confirm-${actionString}`,
+ args: {
+ count: cards.length,
+ name,
+ list,
+ },
+ },
+ ]);
+
+ // Finally, show our smart confirmation message, and act upon it!
+ if (!Services.prompt.confirm(window, title, message)) {
+ // Deletion cancelled by user.
+ return;
+ }
+
+ // Delete cards from address books or mailing lists.
+ gAbView.deleteSelectedCards();
+}
+
+function AbNewMessage(address) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (address) {
+ params.composeFields.to = address;
+ } else {
+ params.composeFields.to = GetSelectedAddresses();
+ }
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
+
+/**
+ * Make a mailbox string from the card, for use in the UI.
+ *
+ * @param {nsIAbCard} - The card to use.
+ * @returns {string} A mailbox representation of the card.
+ */
+function makeMailboxObjectFromCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ let email;
+ if (card.isMailList) {
+ let directory = GetDirectoryFromURI(card.mailListURI);
+ email = directory.description || card.displayName;
+ } else {
+ email = card.primaryEmail;
+ }
+
+ return MailServices.headerParser
+ .makeMailboxObject(card.displayName, email)
+ .toString();
+}
+
+function GetDirectoryFromURI(uri) {
+ if (uri.startsWith("moz-abdirectory://")) {
+ return null;
+ }
+ return MailServices.ab.getDirectory(uri);
+}
diff --git a/comm/mail/components/addrbook/content/abContactsPanel.js b/comm/mail/components/addrbook/content/abContactsPanel.js
new file mode 100644
index 0000000000..c1e3481318
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abContactsPanel.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/content/editMenuOverlay.js */
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from abCommon.js */
+
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { getSearchTokens, getModelQuery, generateQueryURI } = ChromeUtils.import(
+ "resource:///modules/ABQueryUtils.jsm"
+);
+
+// A boolean variable determining whether AB column should be shown
+// in Contacts Sidebar in compose window.
+var gShowAbColumnInComposeSidebar = false;
+var gQueryURIFormat = null;
+
+UIDensity.registerWindow(window);
+
+function GetAbViewListener() {
+ // the ab panel doesn't care if the total changes, or if the selection changes
+ return null;
+}
+
+/**
+ * Handle the command event on abContextMenuButton (click, Enter, spacebar).
+ */
+function abContextMenuButtonOnCommand(event) {
+ showContextMenu("sidebarAbContextMenu", event, [
+ event.target,
+ "after_end",
+ 0,
+ 0,
+ true,
+ ]);
+}
+
+/**
+ * Handle the context menu event of results tree (right-click, context menu key
+ * press, etc.). Show the respective context menu for selected contact(s) or
+ * results tree blank space (work around for XUL tree bug 1331377).
+ *
+ * @param aEvent a context menu event (right-click, context menu key press, etc.)
+ */
+function contactsListOnContextMenu(aEvent) {
+ let target = aEvent.target;
+ let contextMenuID;
+ let positionArray;
+
+ // For right-click on column header or column picker, don't show context menu.
+ if (target.localName == "treecol" || target.localName == "treecolpicker") {
+ return;
+ }
+
+ // On treechildren, if there's no selection, show "sidebarAbContextMenu".
+ if (gAbView.selection.count == 0) {
+ contextMenuID = gAbResultsTree.getAttribute("contextNoSelection");
+ // If "sidebarAbContextMenu" menu was activated by keyboard,
+ // position it in the topleft corner of gAbResultsTree.
+ if (!aEvent.button) {
+ positionArray = [gAbResultsTree, "overlap", 0, 0, true];
+ }
+ // If there's a selection, show "cardProperties" context menu.
+ } else {
+ contextMenuID = gAbResultsTree.getAttribute("contextSelection");
+ updateCardPropertiesMenu();
+ }
+ showContextMenu(contextMenuID, aEvent, positionArray);
+}
+
+/**
+ * Update the single row card properties context menu to show or hide the "Edit"
+ * menu item only depending on the selection type.
+ */
+function updateCardPropertiesMenu() {
+ let cards = GetSelectedAbCards();
+
+ let separator = document.getElementById("abContextBeforeEditContact");
+ let menuitem = document.getElementById("abContextEditContact");
+
+ // Only show the Edit item if one item is selected, is not a mailing list, and
+ // the contact is not part of a readOnly address book.
+ if (
+ cards.length != 1 ||
+ cards.some(c => c.isMailList) ||
+ MailServices.ab.getDirectoryFromUID(cards[0].directoryUID)?.readOnly
+ ) {
+ separator.hidden = true;
+ menuitem.hidden = true;
+ return;
+ }
+
+ separator.hidden = false;
+ menuitem.hidden = false;
+}
+
+/**
+ * Handle the click event of the results tree (workaround for XUL tree
+ * bug 1331377).
+ *
+ * @param aEvent a click event
+ */
+function contactsListOnClick(aEvent) {
+ CommandUpdate_AddressBook();
+
+ let target = aEvent.target;
+
+ // Left click on column header: Change sort direction.
+ if (target.localName == "treecol" && aEvent.button == 0) {
+ let sortDirection =
+ target.getAttribute("sortDirection") == kDefaultDescending
+ ? kDefaultAscending
+ : kDefaultDescending;
+ SortAndUpdateIndicators(target.id, sortDirection);
+ return;
+ }
+ // Any click on gAbResultsTree view (rows or blank space).
+ if (target.localName == "treechildren") {
+ let row = gAbResultsTree.getRowAt(aEvent.clientX, aEvent.clientY);
+ if (row < 0 || row >= gAbResultsTree.view.rowCount) {
+ // Any click on results tree whitespace.
+ if ((aEvent.detail == 1 && aEvent.button == 0) || aEvent.button == 2) {
+ // Single left click or any right click on results tree blank space:
+ // Clear selection. This also triggers on the first click of any
+ // double-click, but that's ok. MAC OS X doesn't return event.detail==1
+ // for single right click, so we also let this trigger for the second
+ // click of right double-click.
+ gAbView.selection.clearSelection();
+ }
+ } else if (aEvent.button == 0 && aEvent.detail == 2) {
+ // Any click on results tree rows.
+ // Double-click on a row: Go ahead and add the entry.
+ addSelectedAddresses("addr_to");
+ }
+ }
+}
+
+/**
+ * Appends the currently selected cards as new recipients in the composed message.
+ *
+ * @param recipientType Type of recipient, e.g. "addr_to".
+ */
+function addSelectedAddresses(recipientType) {
+ var cards = GetSelectedAbCards();
+
+ // Turn each card into a properly formatted address.
+ let addresses = cards.map(makeMailboxObjectFromCard).filter(addr => addr);
+ parent.addressRowAddRecipientsArray(
+ parent.document.querySelector(
+ `.address-row[data-recipienttype="${recipientType}"]`
+ ),
+ addresses
+ );
+}
+
+/**
+ * Open the address book tab and trigger the edit of the selected contact.
+ */
+function editSelectedAddress() {
+ let cards = GetSelectedAbCards();
+ window.top.toAddressBook({ action: "edit", card: cards[0] });
+}
+
+function AddressBookMenuListChange(aValue) {
+ let searchInput = document.getElementById("peopleSearchInput");
+ if (searchInput.value && !searchInput.showingSearchCriteria) {
+ onEnterInSearchBar();
+ } else {
+ ChangeDirectoryByURI(aValue);
+ }
+
+ // Hide the addressbook column if the selected addressbook isn't
+ // "All address books". Since the column is redundant in all other cases.
+ let abList = document.getElementById("addressbookList");
+ let addrbookColumn = document.getElementById("addrbook");
+ if (abList.value.startsWith(kAllDirectoryRoot + "?")) {
+ addrbookColumn.hidden = !gShowAbColumnInComposeSidebar;
+ addrbookColumn.removeAttribute("ignoreincolumnpicker");
+ } else {
+ addrbookColumn.hidden = true;
+ addrbookColumn.setAttribute("ignoreincolumnpicker", "true");
+ }
+
+ CommandUpdate_AddressBook();
+}
+
+var mutationObs = null;
+
+function AbPanelLoad() {
+ if (location.search == "?focus") {
+ document.getElementById("peopleSearchInput").focus();
+ }
+
+ document.title = parent.document.getElementById("contactsTitle").value;
+
+ // Get the URI of the directory to display.
+ let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI");
+ // If the URI is a mailing list, use the parent directory instead, since
+ // mailing lists are not displayed here.
+ startupURI = startupURI.replace(/^(jsaddrbook:\/\/[\w\.-]*)\/.*$/, "$1");
+
+ let abPopup = document.getElementById("addressbookList");
+ abPopup.value = startupURI;
+
+ // If provided directory is not on abPopup, fall back to All Address Books.
+ if (!abPopup.selectedItem) {
+ abPopup.selectedIndex = 0;
+ }
+
+ // Postpone the slow contacts load so that the sidebar document
+ // gets a chance to display quickly.
+ setTimeout(ChangeDirectoryByURI, 0, abPopup.value);
+
+ mutationObs = new MutationObserver(function (aMutations) {
+ aMutations.forEach(function (mutation) {
+ if (
+ getSelectedDirectoryURI() == kAllDirectoryRoot + "?" &&
+ mutation.type == "attributes" &&
+ mutation.attributeName == "hidden"
+ ) {
+ let curState = document.getElementById("addrbook").hidden;
+ gShowAbColumnInComposeSidebar = !curState;
+ }
+ });
+ });
+
+ document.getElementById("addrbook").hidden = !gShowAbColumnInComposeSidebar;
+
+ mutationObs.observe(document.getElementById("addrbook"), {
+ attributes: true,
+ childList: true,
+ });
+}
+
+function AbPanelUnload() {
+ mutationObs.disconnect();
+
+ // If there's no default startupURI, save the last used URI as new startupURI.
+ if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ Services.prefs.setCharPref(
+ "mail.addr_book.view.startupURI",
+ getSelectedDirectoryURI()
+ );
+ }
+
+ CloseAbView();
+}
+
+function AbResultsPaneDoubleClick(card) {
+ // double click for ab panel means "send mail to this person / list"
+ AbNewMessage();
+}
+
+function CommandUpdate_AddressBook() {
+ // Toggle disable state of to,cc,bcc buttons.
+ let disabled = GetNumSelectedCards() == 0 ? "true" : "false";
+ document.getElementById("cmd_addrTo").setAttribute("disabled", disabled);
+ document.getElementById("cmd_addrCc").setAttribute("disabled", disabled);
+ document.getElementById("cmd_addrBcc").setAttribute("disabled", disabled);
+
+ goUpdateCommand("cmd_delete");
+}
+
+/**
+ * Handle the onpopupshowing event of #sidebarAbContextMenu.
+ * Update the checkmark of #sidebarAbContext-startupDir menuitem when context
+ * menu opens, so as to always be in sync with changes from the main AB window.
+ */
+function onAbContextShowing() {
+ let startupItem = document.getElementById("sidebarAbContext-startupDir");
+ if (Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ let startupURI = Services.prefs.getCharPref(
+ "mail.addr_book.view.startupURI"
+ );
+ startupItem.setAttribute(
+ "checked",
+ startupURI == getSelectedDirectoryURI()
+ );
+ } else {
+ startupItem.setAttribute("checked", "false");
+ }
+}
+
+function onEnterInSearchBar() {
+ if (!gQueryURIFormat) {
+ // Get model query from pref. We don't want the query starting with "?"
+ // as we have to prefix "?and" to this format.
+ /* eslint-disable no-global-assign */
+ gQueryURIFormat = getModelQuery("mail.addr_book.quicksearchquery.format");
+ /* eslint-enable no-global-assign */
+ }
+
+ let searchURI = getSelectedDirectoryURI();
+ let searchQuery;
+ let searchInput = document.getElementById("peopleSearchInput");
+
+ // Use helper method to split up search query to multi-word search
+ // query against multiple fields.
+ if (searchInput) {
+ let searchWords = getSearchTokens(searchInput.value);
+ searchQuery = generateQueryURI(gQueryURIFormat, searchWords);
+ }
+
+ SetAbView(searchURI, searchQuery, searchInput ? searchInput.value : "");
+}
+
+/**
+ * Open a menupopup as a context menu
+ *
+ * @param aContextMenuID The ID of a menupopup to be shown as context menu
+ * @param aEvent The event which triggered this.
+ * @param positionArray An optional array containing the parameters for openPopup() method;
+ * if omitted, mouse pointer position will be used.
+ */
+function showContextMenu(aContextMenuID, aEvent, aPositionArray) {
+ let theContextMenu = document.getElementById(aContextMenuID);
+ if (!aPositionArray) {
+ aPositionArray = [null, "", aEvent.clientX, aEvent.clientY, true];
+ }
+ theContextMenu.openPopup(...aPositionArray);
+}
+
+/**
+ * Get the URI of the selected directory.
+ *
+ * @returns The URI of the currently selected directory
+ */
+function getSelectedDirectoryURI() {
+ return document.getElementById("addressbookList").value;
+}
+
+function abToggleSelectedDirStartup() {
+ let selectedDirURI = getSelectedDirectoryURI();
+ if (!selectedDirURI) {
+ return;
+ }
+
+ let isDefault = Services.prefs.getBoolPref(
+ "mail.addr_book.view.startupURIisDefault"
+ );
+ let startupURI = Services.prefs.getCharPref("mail.addr_book.view.startupURI");
+
+ if (isDefault && startupURI == selectedDirURI) {
+ // The current directory has been the default startup view directory;
+ // toggle that off now. So there's no default startup view directory any more.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ } else {
+ // The current directory will now be the default view
+ // when starting up the main AB window.
+ Services.prefs.setCharPref(
+ "mail.addr_book.view.startupURI",
+ selectedDirURI
+ );
+ Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true);
+ }
+
+ // Update the checkbox in the menuitem.
+ goUpdateCommand("cmd_abToggleStartupDir");
+}
+
+function ChangeDirectoryByURI(uri = kPersonalAddressbookURI) {
+ SetAbView(uri);
+
+ // Actively de-selecting if there are any pre-existing selections
+ // in the results list.
+ if (gAbView && gAbView.selection && gAbView.getCardFromRow(0)) {
+ gAbView.selection.clearSelection();
+ }
+}
diff --git a/comm/mail/components/addrbook/content/abContactsPanel.xhtml b/comm/mail/components/addrbook/content/abContactsPanel.xhtml
new file mode 100644
index 0000000000..18163eafda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abContactsPanel.xhtml
@@ -0,0 +1,234 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/abContactsPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd">
+%abResultsPaneDTD;
+<!ENTITY % abContactsPanelDTD SYSTEM "chrome://messenger/locale/addressbook/abContactsPanel.dtd" >
+%abContactsPanelDTD;
+<!ENTITY % abMainWindowDTD SYSTEM "chrome://messenger/locale/addressbook/abMainWindow.dtd" >
+%abMainWindowDTD; ]>
+
+<window
+ id="abContactsPanel"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="AbPanelLoad();"
+ onunload="AbPanelUnload();"
+>
+ <html:link
+ rel="localization"
+ href="messenger/addressbook/aboutAddressBook.ftl"
+ />
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/addressbook/abDragDrop.js" />
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abResultsPane.js" />
+ <script src="chrome://messenger/content/addressbook/abContactsPanel.js" />
+ <script src="chrome://messenger/content/jsTreeView.js" />
+ <script src="chrome://messenger/content/addressbook/abView.js" />
+
+ <commandset
+ id="CommandUpdate_AddressBook"
+ commandupdater="true"
+ events="focus,addrbook-select"
+ oncommandupdate="CommandUpdate_AddressBook()"
+ >
+ <command
+ id="cmd_addrTo"
+ oncommand="addSelectedAddresses('addr_to')"
+ disabled="true"
+ />
+ <command
+ id="cmd_addrCc"
+ oncommand="addSelectedAddresses('addr_cc')"
+ disabled="true"
+ />
+ <command
+ id="cmd_addrBcc"
+ oncommand="addSelectedAddresses('addr_bcc')"
+ disabled="true"
+ />
+ <command id="cmd_delete" oncommand="goDoCommand('cmd_delete');" />
+ </commandset>
+
+ <keyset id="keyset_abContactsPanel">
+ <!-- This key (key_delete) does not trigger any command, but it is used
+ only to show the hotkey on the corresponding menuitem. -->
+ <key id="key_delete" keycode="VK_DELETE" internal="true" />
+ </keyset>
+
+ <menupopup id="cardProperties">
+ <menuitem
+ label="&addtoToFieldMenu.label;"
+ accesskey="&addtoToFieldMenu.accesskey;"
+ command="cmd_addrTo"
+ />
+ <menuitem
+ label="&addtoCcFieldMenu.label;"
+ accesskey="&addtoCcFieldMenu.accesskey;"
+ command="cmd_addrCc"
+ />
+ <menuitem
+ label="&addtoBccFieldMenu.label;"
+ accesskey="&addtoBccFieldMenu.accesskey;"
+ command="cmd_addrBcc"
+ />
+ <menuseparator />
+ <menuitem
+ label="&deleteAddrBookCard.label;"
+ accesskey="&deleteAddrBookCard.accesskey;"
+ key="key_delete"
+ command="cmd_delete"
+ />
+ <menuseparator id="abContextBeforeEditContact" hidden="true" />
+ <menuitem
+ id="abContextEditContact"
+ label="&editContactContext.label;"
+ accesskey="&editContactContext.accesskey;"
+ oncommand="editSelectedAddress();"
+ hidden="true"
+ />
+ </menupopup>
+
+ <menupopup
+ id="sidebarAbContextMenu"
+ class="no-accel-menupopup"
+ onpopupshowing="onAbContextShowing();"
+ >
+ <menuitem
+ id="sidebarAbContext-startupDir"
+ label="&showAsDefault.label;"
+ accesskey="&showAsDefault.accesskey;"
+ type="checkbox"
+ checked="false"
+ oncommand="abToggleSelectedDirStartup();"
+ />
+ </menupopup>
+
+ <vbox id="results_box" flex="1">
+ <separator class="thin" />
+ <hbox id="AbPickerHeader" class="themeable-full">
+ <label
+ value="&addressbookPicker.label;"
+ accesskey="&addressbookPicker.accesskey;"
+ control="addressbookList"
+ />
+ <spacer flex="1" />
+ <button
+ id="abContextMenuButton"
+ tooltiptext="&abContextMenuButton.tooltip;"
+ oncommand="abContextMenuButtonOnCommand(event);"
+ />
+ </hbox>
+ <hbox id="panel-bar" class="themeable-full" align="center">
+ <menulist
+ is="menulist-addrbooks"
+ id="addressbookList"
+ alladdressbooks="true"
+ oncommand="AddressBookMenuListChange(this.value);"
+ flex="1"
+ />
+ </hbox>
+
+ <separator class="thin" />
+
+ <vbox>
+ <label
+ value="&searchContacts.label;"
+ accesskey="&searchContacts.accesskey;"
+ control="peopleSearchInput"
+ />
+ <search-textbox
+ id="peopleSearchInput"
+ class="searchBox"
+ flex="1"
+ timeout="800"
+ placeholder="&SearchNameOrEmail.label;"
+ oncommand="onEnterInSearchBar();"
+ />
+ </vbox>
+
+ <separator class="thin" />
+
+ <tree
+ id="abResultsTree"
+ flex="1"
+ class="plain"
+ sortCol="GeneratedName"
+ persist="sortCol"
+ contextSelection="cardProperties"
+ contextNoSelection="sidebarAbContextMenu"
+ oncontextmenu="contactsListOnContextMenu(event);"
+ onclick="contactsListOnClick(event);"
+ onselect="this.view.selectionChanged(); document.commandDispatcher.updateCommands('addrbook-select');"
+ >
+ <treecols>
+ <!-- these column ids must match up to the mork column names, see nsIAddrDatabase.idl -->
+ <treecol
+ id="GeneratedName"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&GeneratedName.label;"
+ primary="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="addrbook"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&Addrbook.label;"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="PrimaryEmail"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&PrimaryEmail.label;"
+ />
+ </treecols>
+ <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);" />
+ </tree>
+
+ <separator class="thin" />
+
+ <hbox pack="center">
+ <vbox>
+ <button
+ id="toButton"
+ label="&toButton.label;"
+ accesskey="&toButton.accesskey;"
+ command="cmd_addrTo"
+ />
+ <button
+ id="ccButton"
+ label="&ccButton.label;"
+ accesskey="&ccButton.accesskey;"
+ command="cmd_addrCc"
+ />
+ <button
+ id="bccButton"
+ label="&bccButton.label;"
+ accesskey="&bccButton.accesskey;"
+ command="cmd_addrBcc"
+ />
+ </vbox>
+ </hbox>
+
+ <separator class="thin" />
+ </vbox>
+</window>
diff --git a/comm/mail/components/addrbook/content/abEditListDialog.xhtml b/comm/mail/components/addrbook/content/abEditListDialog.xhtml
new file mode 100644
index 0000000000..bf775c274b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abEditListDialog.xhtml
@@ -0,0 +1,99 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&mailListWindowAdd.title;"
+ onload="OnLoadEditList();"
+ ondragover="DragOverAddressListTree(event);"
+ ondrop="DropOnAddressListTree(event);"
+>
+ <dialog id="ablistWindow">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- move needed functions into a single js file -->
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abMailListDialog.js" />
+
+ <vbox id="editlist">
+ <html:div class="grid-two-column-fr grid-items-center">
+ <label
+ control="ListName"
+ value="&ListName.label;"
+ accesskey="&ListName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListNickName"
+ value="&ListNickName.label;"
+ accesskey="&ListNickName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListNickName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListDescription"
+ value="&ListDescription.label;"
+ accesskey="&ListDescription.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListDescription" type="text" class="input-inline" />
+ </hbox>
+ </html:div>
+
+ <spacer style="height: 1em" />
+ <label
+ control="addressCol1#1"
+ value="&AddressTitle.label;"
+ accesskey="&AddressTitle.accesskey;"
+ />
+ <spacer style="height: 0.1em" />
+
+ <richlistbox
+ id="addressingWidget"
+ onclick="awClickEmptySpace(event.target, true)"
+ >
+ <richlistitem class="addressingWidgetItem" allowevents="true">
+ <hbox
+ class="addressingWidgetCell input-container"
+ flex="1"
+ role="combobox"
+ >
+ <html:label for="addressCol1#1" class="person-icon"></html:label>
+ <html:input
+ is="autocomplete-input"
+ id="addressCol1#1"
+ class="plain textbox-addressingWidget uri-element"
+ aria-labelledby="addressCol1#1"
+ autocompletesearch="addrbook ldap"
+ autocompletesearchparam="{}"
+ timeout="300"
+ maxrows="4"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="3"
+ onkeypress="awAbRecipientKeyPress(event, this);"
+ onkeydown="awRecipientKeyDown(event, this);"
+ />
+ </hbox>
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/addrbook/content/abMailListDialog.xhtml b/comm/mail/components/addrbook/content/abMailListDialog.xhtml
new file mode 100644
index 0000000000..5b0cf11dda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abMailListDialog.xhtml
@@ -0,0 +1,116 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/cardDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/addressbook/abMailListDialog.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&mailListWindowAdd.title;"
+ onload="OnLoadNewMailList();"
+ ondragover="DragOverAddressListTree(event);"
+ ondrop="DropOnAddressListTree(event);"
+>
+ <dialog id="ablistWindow">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- move needed functions into a single js file -->
+ <script src="chrome://messenger/content/addressbook/abCommon.js" />
+ <script src="chrome://messenger/content/addressbook/abMailListDialog.js" />
+
+ <hbox align="center">
+ <label
+ control="abPopup"
+ value="&addToAddressBook.label;"
+ accesskey="&addToAddressBook.accesskey;"
+ />
+ <menulist
+ is="menulist-addrbooks"
+ id="abPopup"
+ supportsmaillists="true"
+ flex="1"
+ writable="true"
+ />
+ </hbox>
+
+ <spacer style="height: 1em" />
+
+ <vbox id="editlist">
+ <html:div class="grid-two-column-fr grid-items-center">
+ <label
+ control="ListName"
+ value="&ListName.label;"
+ accesskey="&ListName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListNickName"
+ value="&ListNickName.label;"
+ accesskey="&ListNickName.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListNickName" type="text" class="input-inline" />
+ </hbox>
+ <label
+ control="ListDescription"
+ value="&ListDescription.label;"
+ accesskey="&ListDescription.accesskey;"
+ class="CardEditLabel"
+ />
+ <hbox class="CardEditWidth input-container">
+ <html:input id="ListDescription" type="text" class="input-inline" />
+ </hbox>
+ </html:div>
+
+ <spacer style="height: 1em" />
+ <label
+ control="addressCol1#1"
+ value="&AddressTitle.label;"
+ accesskey="&AddressTitle.accesskey;"
+ />
+ <spacer style="height: 0.1em" />
+
+ <richlistbox
+ id="addressingWidget"
+ onclick="awClickEmptySpace(event.target, true)"
+ >
+ <richlistitem class="addressingWidgetItem" allowevents="true">
+ <hbox
+ class="addressingWidgetCell input-container"
+ flex="1"
+ role="combobox"
+ >
+ <html:label for="addressCol1#1" class="person-icon"></html:label>
+ <html:input
+ is="autocomplete-input"
+ id="addressCol1#1"
+ class="plain textbox-addressingWidget uri-element"
+ aria-labelledby="addressCol1#1"
+ autocompletesearch="addrbook ldap"
+ autocompletesearchparam="{}"
+ timeout="300"
+ maxrows="4"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="3"
+ onkeypress="awAbRecipientKeyPress(event, this);"
+ onkeydown="awRecipientKeyDown(event, this);"
+ />
+ </hbox>
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/addrbook/content/abSearchDialog.js b/comm/mail/components/addrbook/content/abSearchDialog.js
new file mode 100644
index 0000000000..694d17c12b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abSearchDialog.js
@@ -0,0 +1,408 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../mailnews/addrbook/content/abResultsPane.js */
+/* import-globals-from ../../../../mailnews/base/content/dateFormat.js */
+/* import-globals-from ../../../../mailnews/search/content/searchTerm.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from abCommon.js */
+
+var { encodeABTermValue } = ChromeUtils.import(
+ "resource:///modules/ABQueryUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+var searchSessionContractID = "@mozilla.org/messenger/searchSession;1";
+var gSearchSession;
+
+var nsMsgSearchScope = Ci.nsMsgSearchScope;
+var nsMsgSearchOp = Ci.nsMsgSearchOp;
+var nsMsgSearchAttrib = Ci.nsMsgSearchAttrib;
+
+var gStatusText;
+var gSearchBundle;
+var gAddressBookBundle;
+
+var gSearchStopButton;
+var gPropertiesCmd;
+var gComposeCmd;
+var gDeleteCmd;
+var gSearchPhoneticName = "false";
+
+var gSearchAbViewListener = {
+ onSelectionChanged() {
+ UpdateCardView();
+ },
+ onCountChanged(aTotal) {
+ let statusText;
+ if (aTotal == 0) {
+ statusText = gAddressBookBundle.GetStringFromName("noMatchFound");
+ } else {
+ statusText = PluralForm.get(
+ aTotal,
+ gAddressBookBundle.GetStringFromName("matchesFound1")
+ ).replace("#1", aTotal);
+ }
+
+ gStatusText.setAttribute("value", statusText);
+ },
+};
+
+function searchOnLoad() {
+ initializeSearchWidgets();
+ initializeSearchWindowWidgets();
+
+ gSearchBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/search.properties"
+ );
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+ gAddressBookBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ gSearchSession = Cc[searchSessionContractID].createInstance(
+ Ci.nsIMsgSearchSession
+ );
+
+ // initialize a flag for phonetic name search
+ gSearchPhoneticName = Services.prefs.getComplexValue(
+ "mail.addr_book.show_phonetic_fields",
+ Ci.nsIPrefLocalizedString
+ ).data;
+
+ if (window.arguments && window.arguments[0]) {
+ SelectDirectory(window.arguments[0].directory);
+ } else {
+ SelectDirectory(
+ document.getElementById("abPopup-menupopup").firstElementChild.value
+ );
+ }
+
+ onMore(null);
+}
+
+function searchOnUnload() {
+ CloseAbView();
+}
+
+function disableCommands() {
+ gPropertiesCmd.setAttribute("disabled", "true");
+ gComposeCmd.setAttribute("disabled", "true");
+ gDeleteCmd.setAttribute("disabled", "true");
+}
+
+function initializeSearchWindowWidgets() {
+ gSearchStopButton = document.getElementById("search-button");
+ gPropertiesCmd = document.getElementById("cmd_properties");
+ gComposeCmd = document.getElementById("cmd_compose");
+ gDeleteCmd = document.getElementById("cmd_deleteCard");
+ gStatusText = document.getElementById("statusText");
+ disableCommands();
+ // matchAll doesn't make sense for address book search
+ hideMatchAllItem();
+}
+
+function onSearchStop() {}
+
+function onAbSearchReset(event) {
+ disableCommands();
+ CloseAbView();
+
+ onReset(event);
+ gStatusText.setAttribute("value", "");
+}
+
+function SelectDirectory(aURI) {
+ // set popup with address book names
+ let abPopup = document.getElementById("abPopup");
+ if (abPopup) {
+ if (aURI) {
+ abPopup.value = aURI;
+ } else {
+ abPopup.selectedIndex = 0;
+ }
+ }
+
+ setSearchScope(GetScopeForDirectoryURI(aURI));
+}
+
+function GetScopeForDirectoryURI(aURI) {
+ let directory;
+ if (aURI && aURI != "moz-abdirectory://?") {
+ directory = MailServices.ab.getDirectory(aURI);
+ }
+ let booleanAnd = gSearchBooleanRadiogroup.selectedItem.value == "and";
+
+ if (directory?.isRemote) {
+ if (booleanAnd) {
+ return nsMsgSearchScope.LDAPAnd;
+ }
+ return nsMsgSearchScope.LDAP;
+ }
+
+ if (booleanAnd) {
+ return nsMsgSearchScope.LocalABAnd;
+ }
+ return nsMsgSearchScope.LocalAB;
+}
+
+function onEnterInSearchTerm() {
+ // on enter
+ // if not searching, start the search
+ // if searching, stop and then start again
+ if (
+ gSearchStopButton.getAttribute("label") ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ onSearch();
+ }
+}
+
+function onSearch() {
+ gStatusText.setAttribute("value", "");
+ disableCommands();
+
+ gSearchSession.clearScopes();
+
+ var currentAbURI = document.getElementById("abPopup").getAttribute("value");
+
+ gSearchSession.addDirectoryScopeTerm(GetScopeForDirectoryURI(currentAbURI));
+ gSearchSession.searchTerms = saveSearchTerms(
+ gSearchSession.searchTerms,
+ gSearchSession
+ );
+
+ let searchUri = "?(";
+ for (let i = 0; i < gSearchSession.searchTerms.length; i++) {
+ let searchTerm = gSearchSession.searchTerms[i];
+ if (!searchTerm.value.str) {
+ continue;
+ }
+ // get the "and" / "or" value from the first term
+ if (i == 0) {
+ if (searchTerm.booleanAnd) {
+ searchUri += "and";
+ } else {
+ searchUri += "or";
+ }
+ }
+
+ var attrs;
+
+ switch (searchTerm.attrib) {
+ case nsMsgSearchAttrib.Name:
+ if (gSearchPhoneticName != "true") {
+ attrs = [
+ "DisplayName",
+ "FirstName",
+ "LastName",
+ "NickName",
+ "_AimScreenName",
+ ];
+ } else {
+ attrs = [
+ "DisplayName",
+ "FirstName",
+ "LastName",
+ "NickName",
+ "_AimScreenName",
+ "PhoneticFirstName",
+ "PhoneticLastName",
+ ];
+ }
+ break;
+ case nsMsgSearchAttrib.DisplayName:
+ attrs = ["DisplayName"];
+ break;
+ case nsMsgSearchAttrib.Email:
+ attrs = ["PrimaryEmail"];
+ break;
+ case nsMsgSearchAttrib.PhoneNumber:
+ attrs = [
+ "HomePhone",
+ "WorkPhone",
+ "FaxNumber",
+ "PagerNumber",
+ "CellularNumber",
+ ];
+ break;
+ case nsMsgSearchAttrib.Organization:
+ attrs = ["Company"];
+ break;
+ case nsMsgSearchAttrib.Department:
+ attrs = ["Department"];
+ break;
+ case nsMsgSearchAttrib.City:
+ attrs = ["WorkCity"];
+ break;
+ case nsMsgSearchAttrib.Street:
+ attrs = ["WorkAddress"];
+ break;
+ case nsMsgSearchAttrib.Nickname:
+ attrs = ["NickName"];
+ break;
+ case nsMsgSearchAttrib.WorkPhone:
+ attrs = ["WorkPhone"];
+ break;
+ case nsMsgSearchAttrib.HomePhone:
+ attrs = ["HomePhone"];
+ break;
+ case nsMsgSearchAttrib.Fax:
+ attrs = ["FaxNumber"];
+ break;
+ case nsMsgSearchAttrib.Pager:
+ attrs = ["PagerNumber"];
+ break;
+ case nsMsgSearchAttrib.Mobile:
+ attrs = ["CellularNumber"];
+ break;
+ case nsMsgSearchAttrib.Title:
+ attrs = ["JobTitle"];
+ break;
+ case nsMsgSearchAttrib.AdditionalEmail:
+ attrs = ["SecondEmail"];
+ break;
+ case nsMsgSearchAttrib.ScreenName:
+ attrs = ["_AimScreenName"];
+ break;
+ default:
+ dump("XXX " + searchTerm.attrib + " not a supported search attr!\n");
+ attrs = ["DisplayName"];
+ break;
+ }
+
+ var opStr;
+
+ switch (searchTerm.op) {
+ case nsMsgSearchOp.Contains:
+ opStr = "c";
+ break;
+ case nsMsgSearchOp.DoesntContain:
+ opStr = "!c";
+ break;
+ case nsMsgSearchOp.Is:
+ opStr = "=";
+ break;
+ case nsMsgSearchOp.Isnt:
+ opStr = "!=";
+ break;
+ case nsMsgSearchOp.BeginsWith:
+ opStr = "bw";
+ break;
+ case nsMsgSearchOp.EndsWith:
+ opStr = "ew";
+ break;
+ case nsMsgSearchOp.SoundsLike:
+ opStr = "~=";
+ break;
+ default:
+ opStr = "c";
+ break;
+ }
+
+ // currently, we can't do "and" and "or" searches at the same time
+ // (it's either all "and"s or all "or"s)
+ var max_attrs = attrs.length;
+
+ for (var j = 0; j < max_attrs; j++) {
+ // append the term(s) to the searchUri
+ searchUri +=
+ "(" +
+ attrs[j] +
+ "," +
+ opStr +
+ "," +
+ encodeABTermValue(searchTerm.value.str) +
+ ")";
+ }
+ }
+
+ searchUri += ")";
+ if (searchUri == "?()") {
+ // Empty search.
+ searchUri = "";
+ }
+ SetAbView(currentAbURI, searchUri, "");
+}
+
+// used to toggle functionality for Search/Stop button.
+function onSearchButton(event) {
+ if (
+ event.target.label ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ }
+}
+
+function GetAbViewListener() {
+ return gSearchAbViewListener;
+}
+
+function onProperties() {
+ if (!gPropertiesCmd.hasAttribute("disabled")) {
+ window.opener.toAddressBook({ action: "display", card: GetSelectedCard() });
+ }
+}
+
+function onCompose() {
+ if (!gComposeCmd.hasAttribute("disabled")) {
+ AbNewMessage();
+ }
+}
+
+function onDelete() {
+ if (!gDeleteCmd.hasAttribute("disabled")) {
+ AbDelete();
+ }
+}
+
+function AbResultsPaneKeyPress(event) {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_RETURN:
+ onProperties();
+ break;
+ case KeyEvent.DOM_VK_DELETE:
+ case KeyEvent.DOM_VK_BACK_SPACE:
+ onDelete();
+ }
+}
+
+function AbResultsPaneDoubleClick(card) {
+ // Kept for abResultsPane.js.
+}
+
+function UpdateCardView() {
+ disableCommands();
+ let numSelected = GetNumSelectedCards();
+
+ if (!numSelected) {
+ return;
+ }
+
+ if (MailServices.accounts.allIdentities.length > 0) {
+ gComposeCmd.removeAttribute("disabled");
+ }
+
+ gDeleteCmd.removeAttribute("disabled");
+ if (numSelected == 1) {
+ gPropertiesCmd.removeAttribute("disabled");
+ }
+}
diff --git a/comm/mail/components/addrbook/content/abSearchDialog.xhtml b/comm/mail/components/addrbook/content/abSearchDialog.xhtml
new file mode 100644
index 0000000000..75a40df839
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abSearchDialog.xhtml
@@ -0,0 +1,200 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/addressbook/abResultsPane.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/abSearchDialog.css" type="text/css"?>
+
+<!DOCTYPE window [
+ <!ENTITY % abResultsPaneDTD SYSTEM "chrome://messenger/locale/addressbook/abResultsPane.dtd">
+ %abResultsPaneDTD;
+ <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd">
+ %SearchDialogDTD;
+ <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd">
+ %searchTermDTD;
+]>
+<window id="searchAddressBookWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="searchOnLoad();"
+ onunload="searchOnUnload();"
+ onclose="onSearchStop();"
+ windowtype="mailnews:absearch"
+ title="&abSearchDialogTitle.label;"
+ style="min-width: 52em; min-height: 34em;"
+ lightweightthemes="true"
+ persist="screenX screenY width height sizemode">
+ <html:link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" />
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://messenger/content/addressbook/abSearchDialog.js"/>
+ <script src="chrome://messenger/content/addressbook/abResultsPane.js"/>
+ <script src="chrome://messenger/content/addressbook/abCommon.js"/>
+ <script src="chrome://messenger/content/searchTerm.js"/>
+ <script src="chrome://messenger/content/searchWidgets.js"/>
+ <script src="chrome://messenger/content/dateFormat.js"/>
+ <script src="chrome://messenger/content/jsTreeView.js"/>
+ <script src="chrome://messenger/content/addressbook/abView.js"/>
+
+ <keyset id="mailKeys">
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="onSearchStop(); window.close();"/>
+ <key keycode="VK_ESCAPE" oncommand="onSearchStop(); window.close();"/>
+ </keyset>
+
+ <commandset id="AbCommands">
+ <command id="cmd_properties" oncommand="onProperties();"/>
+ <command id="cmd_compose" oncommand="onCompose();"/>
+ <command id="cmd_deleteCard" oncommand="onDelete();"/>
+ </commandset>
+
+ <vbox id="searchTerms" class="themeable-brighttext" persist="height">
+ <vbox>
+ <hbox align="center">
+ <label value="&abSearchHeading.label;" accesskey="&abSearchHeading.accesskey;" control="abPopup"/>
+ <menulist is="menulist-addrbooks" id="abPopup"
+ oncommand="SelectDirectory(this.value);"
+ alladdressbooks="true"
+ flex="1"/>
+ <spacer style="flex: 3 3;"/>
+ <button id="search-button" oncommand="onSearchButton(event);" default="true"/>
+ </hbox>
+ <hbox align="center">
+ <spacer flex="1"/>
+ <button label="&resetButton.label;" oncommand="onAbSearchReset(event);" accesskey="&resetButton.accesskey;"/>
+ </hbox>
+ </vbox>
+
+ <hbox flex="1">
+ <vbox id="searchTermListBox" flex="1">
+#include ../../../../mailnews/search/content/searchTerm.inc.xhtml
+ </hbox>
+ </vbox>
+
+ <splitter id="gray_horizontal_splitter" orient="vertical"/>
+
+ <vbox id="searchResults" persist="height">
+ <vbox id="searchResultListBox">
+ <tree id="abResultsTree" flex="1" enableColumnDrag="true" class="plain"
+ onclick="AbResultsPaneOnClick(event);"
+ onkeypress="AbResultsPaneKeyPress(event);"
+ onselect="this.view.selectionChanged();"
+ sortCol="GeneratedName"
+ persist="sortCol">
+
+ <treecols id="abResultsTreeCols">
+ <!-- these column ids must match up to the mork column names, except for GeneratedName and ChatName, see nsIAddrDatabase.idl -->
+ <treecol id="GeneratedName"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&GeneratedName.label;"
+ primary="true"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="PrimaryEmail"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&PrimaryEmail.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="ChatName"
+ hidden="true"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&ChatName.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Company"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&Company.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="NickName"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&NickName.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="SecondEmail"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&SecondEmail.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Department"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&Department.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="JobTitle"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&JobTitle.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="CellularNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&CellularNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="PagerNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&PagerNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="FaxNumber"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&FaxNumber.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="HomePhone"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&HomePhone.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="WorkPhone"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&WorkPhone.label;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="Addrbook"
+ persist="hidden ordinal width sortDirection"
+ style="flex: 1 auto"
+ label="&Addrbook.label;"/>
+ <!-- LOCALIZATION NOTE: _PhoneticName may be enabled for Japanese builds. -->
+ <!--
+ <treecol id="_PhoneticName"
+ persist="hidden ordinal width sortDirection"
+ hidden="true"
+ style="flex: 1 auto"
+ label="&_PhoneticName.label;"/>
+ <splitter class="tree-splitter"/>
+ -->
+
+ </treecols>
+ <treechildren ondragstart="abResultsPaneObserver.onDragStart(event);"/>
+ </tree>
+ </vbox>
+ <hbox align="start">
+ <button label="&propertiesButton.label;"
+ accesskey="&propertiesButton.accesskey;"
+ command="cmd_properties"/>
+ <button label="&composeButton.label;"
+ accesskey="&composeButton.accesskey;"
+ command="cmd_compose"/>
+ <button label="&deleteCardButton.label;"
+ accesskey="&deleteCardButton.accesskey;"
+ command="cmd_deleteCard"/>
+ </hbox>
+ </vbox>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+ <label id="statusText" class="statusbarpanel" crop="end" flex="1"/>
+ </hbox>
+
+</window>
diff --git a/comm/mail/components/addrbook/content/abView-new.js b/comm/mail/components/addrbook/content/abView-new.js
new file mode 100644
index 0000000000..cb3eca969c
--- /dev/null
+++ b/comm/mail/components/addrbook/content/abView-new.js
@@ -0,0 +1,577 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals PROTO_TREE_VIEW */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+function ABView(
+ directory,
+ searchQuery,
+ searchString,
+ sortColumn,
+ sortDirection
+) {
+ this.__proto__.__proto__ = new PROTO_TREE_VIEW();
+ this.directory = directory;
+ this.searchString = searchString;
+
+ let directories = directory ? [directory] : MailServices.ab.directories;
+ if (searchQuery) {
+ this._searchesInProgress = directories.length;
+ searchQuery = searchQuery.replace(/^\?+/, "");
+ for (let dir of directories) {
+ dir.search(searchQuery, searchString, this);
+ }
+ } else {
+ for (let dir of directories) {
+ for (let card of dir.childCards) {
+ this._rowMap.push(new abViewCard(card, dir));
+ }
+ }
+ }
+ this.sortBy(sortColumn, sortDirection);
+}
+ABView.nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+);
+ABView.NOT_SEARCHING = 0;
+ABView.SEARCHING = 1;
+ABView.SEARCH_COMPLETE = 2;
+ABView.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsITreeView",
+ "nsIAbDirSearchListener",
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ directory: null,
+ _notifications: [
+ "addrbook-directory-deleted",
+ "addrbook-directory-invalidated",
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-added",
+ "addrbook-list-member-removed",
+ ],
+
+ sortColumn: "",
+ sortDirection: "",
+ collator: new Intl.Collator(undefined, { numeric: true }),
+
+ deleteSelectedCards() {
+ let directoryMap = new Map();
+ for (let i of this._tree.selectedIndices) {
+ let card = this.getCardFromRow(i);
+ let cardSet = directoryMap.get(card.directoryUID);
+ if (!cardSet) {
+ cardSet = new Set();
+ directoryMap.set(card.directoryUID, cardSet);
+ }
+ cardSet.add(card);
+ }
+
+ for (let [directoryUID, cardSet] of directoryMap) {
+ let directory;
+ if (this.directory && this.directory.isMailList) {
+ // Removes cards from the list instead of deleting them.
+ directory = this.directory;
+ } else {
+ directory = MailServices.ab.getDirectoryFromUID(directoryUID);
+ }
+
+ cardSet = [...cardSet];
+ directory.deleteCards(cardSet.filter(card => !card.isMailList));
+ for (let card of cardSet.filter(card => card.isMailList)) {
+ MailServices.ab.deleteAddressBook(card.mailListURI);
+ }
+ }
+ },
+ getCardFromRow(row) {
+ return this._rowMap[row] ? this._rowMap[row].card : null;
+ },
+ getDirectoryFromRow(row) {
+ return this._rowMap[row] ? this._rowMap[row].directory : null;
+ },
+ getIndexForUID(uid) {
+ return this._rowMap.findIndex(row => row.id == uid);
+ },
+ sortBy(sortColumn, sortDirection, resort) {
+ let selectionExists = false;
+ if (this._tree) {
+ let { selectedIndices, currentIndex } = this._tree;
+ selectionExists = selectedIndices.length;
+ // Remember what was selected.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selectedIndices.includes(i);
+ this._rowMap[i].wasCurrent = currentIndex == i;
+ }
+ }
+
+ // Do the sort.
+ if (sortColumn == this.sortColumn && !resort) {
+ if (sortDirection == this.sortDirection) {
+ return;
+ }
+ this._rowMap.reverse();
+ } else {
+ this._rowMap.sort((a, b) => {
+ let aText = a.getText(sortColumn);
+ let bText = b.getText(sortColumn);
+ if (sortDirection == "descending") {
+ return this.collator.compare(bText, aText);
+ }
+ return this.collator.compare(aText, bText);
+ });
+ }
+
+ // Restore what was selected.
+ if (this._tree) {
+ this._tree.reset();
+ if (selectionExists) {
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._tree.toggleSelectionAtIndex(
+ i,
+ this._rowMap[i].wasSelected,
+ true
+ );
+ }
+ // Can't do this until updating the selection is finished.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasCurrent) {
+ this._tree.currentIndex = i;
+ break;
+ }
+ }
+ this.selectionChanged();
+ }
+ }
+ this.sortColumn = sortColumn;
+ this.sortDirection = sortDirection;
+ },
+ get searchState() {
+ if (this._searchesInProgress === undefined) {
+ return ABView.NOT_SEARCHING;
+ }
+ return this._searchesInProgress ? ABView.SEARCHING : ABView.SEARCH_COMPLETE;
+ },
+
+ // nsITreeView
+
+ selectionChanged() {},
+ setTree(tree) {
+ this._tree = tree;
+ for (let topic of this._notifications) {
+ if (tree) {
+ Services.obs.addObserver(this, topic, true);
+ } else {
+ try {
+ Services.obs.removeObserver(this, topic);
+ } catch (ex) {
+ // `this` might not be a valid observer.
+ }
+ }
+ }
+ Services.prefs.addObserver("mail.addr_book.lastnamefirst", this, true);
+ },
+
+ // nsIAbDirSearchListener
+
+ onSearchFoundCard(card) {
+ // Instead of duplicating the insertion code below, just call it.
+ this.observe(card, "addrbook-contact-created", this.directory?.UID);
+ },
+ onSearchFinished(status, complete, secInfo, location) {
+ // Special handling for Bad Cert errors.
+ let offerCertException = false;
+ try {
+ // If code is not an NSS error, getErrorClass() will fail.
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ offerCertException = true;
+ }
+ } catch (ex) {}
+
+ if (offerCertException) {
+ // Give the user the option of adding an exception for the bad cert.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // params.exceptionAdded will be set if the user added an exception.
+ }
+
+ this._searchesInProgress--;
+ if (!this._searchesInProgress && this._tree) {
+ this._tree.dispatchEvent(new CustomEvent("searchstatechange"));
+ }
+ },
+
+ // nsIObserver
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ ABView.nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+ for (let card of this._rowMap) {
+ delete card._getTextCache.GeneratedName;
+ }
+ if (this._tree) {
+ if (this.sortColumn == "GeneratedName") {
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ } else {
+ // Remember what was selected.
+ let { selectedIndices, currentIndex } = this._tree;
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selectedIndices.includes(i);
+ this._rowMap[i].wasCurrent = currentIndex == i;
+ }
+
+ this._tree.reset();
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._tree.toggleSelectionAtIndex(
+ i,
+ this._rowMap[i].wasSelected,
+ true
+ );
+ }
+ // Can't do this until updating the selection is finished.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasCurrent) {
+ this._tree.currentIndex = i;
+ break;
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ if (this.directory && data && this.directory.UID != data) {
+ return;
+ }
+
+ // If we make it here, we're in the root directory, or the right directory.
+
+ switch (topic) {
+ case "addrbook-directory-deleted": {
+ if (this.directory) {
+ break;
+ }
+
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (this._rowMap[i].directory.UID == subject.UID) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ case "addrbook-directory-invalidated":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (subject == this.directory) {
+ this._rowMap.length = 0;
+ for (let card of this.directory.childCards) {
+ this._rowMap.push(new abViewCard(card, this.directory));
+ }
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ }
+ break;
+ case "addrbook-list-created": {
+ let parentDir = MailServices.ab.getDirectoryFromUID(data);
+ // `subject` is an nsIAbDirectory, make it the matching card instead.
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ for (let card of parentDir.childCards) {
+ if (card.UID == subject.UID) {
+ subject = card;
+ break;
+ }
+ }
+ }
+ // Falls through.
+ case "addrbook-list-member-added":
+ case "addrbook-contact-created":
+ if (topic == "addrbook-list-member-added" && !this.directory) {
+ break;
+ }
+
+ subject.QueryInterface(Ci.nsIAbCard);
+ let viewCard = new abViewCard(subject);
+ let sortText = viewCard.getText(this.sortColumn);
+ let addIndex = null;
+ for (let i = 0; addIndex === null && i < this._rowMap.length; i++) {
+ let comparison = this.collator.compare(
+ sortText,
+ this._rowMap[i].getText(this.sortColumn)
+ );
+ if (
+ (comparison < 0 && this.sortDirection == "ascending") ||
+ (comparison >= 0 && this.sortDirection == "descending")
+ ) {
+ addIndex = i;
+ }
+ }
+ if (addIndex === null) {
+ addIndex = this._rowMap.length;
+ }
+ this._rowMap.splice(addIndex, 0, viewCard);
+ if (this._tree) {
+ this._tree.rowCountChanged(addIndex, 1);
+ }
+ break;
+
+ case "addrbook-list-updated": {
+ let parentDir = this.directory;
+ if (!parentDir) {
+ parentDir = MailServices.ab.getDirectoryFromUID(data);
+ }
+ // `subject` is an nsIAbDirectory, make it the matching card instead.
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ for (let card of parentDir.childCards) {
+ if (card.UID == subject.UID) {
+ subject = card;
+ break;
+ }
+ }
+ }
+ // Falls through.
+ case "addrbook-contact-updated": {
+ subject.QueryInterface(Ci.nsIAbCard);
+ let needsSort = false;
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (
+ this._rowMap[i].card.equals(subject) &&
+ this._rowMap[i].card.directoryUID == subject.directoryUID
+ ) {
+ this._rowMap.splice(i, 1, new abViewCard(subject));
+ needsSort = true;
+ }
+ }
+ if (needsSort) {
+ this.sortBy(this.sortColumn, this.sortDirection, true);
+ }
+ break;
+ }
+
+ case "addrbook-list-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (this._rowMap[i].card.UID == subject.UID) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ case "addrbook-list-member-removed":
+ if (!this.directory) {
+ break;
+ }
+ // Falls through.
+ case "addrbook-contact-deleted": {
+ subject.QueryInterface(Ci.nsIAbCard);
+ let scrollPosition = this._tree?.getFirstVisibleIndex();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (
+ this._rowMap[i].card.equals(subject) &&
+ this._rowMap[i].card.directoryUID == subject.directoryUID
+ ) {
+ this._rowMap.splice(i, 1);
+ if (this._tree) {
+ this._tree.rowCountChanged(i, -1);
+ }
+ }
+ }
+ if (this._tree && scrollPosition !== null) {
+ this._tree.scrollToIndex(scrollPosition);
+ }
+ break;
+ }
+ }
+ },
+};
+
+/**
+ * Representation of a card, used as a table row in ABView.
+ *
+ * @param {nsIAbCard} card - contact or mailing list card for this row.
+ * @param {nsIAbDirectory} [directoryHint] - the directory containing card,
+ * if available (this is a performance optimization only).
+ */
+function abViewCard(card, directoryHint) {
+ this.card = card;
+ this._getTextCache = {};
+ if (directoryHint) {
+ this._directory = directoryHint;
+ } else {
+ this._directory = MailServices.ab.getDirectoryFromUID(
+ this.card.directoryUID
+ );
+ }
+}
+abViewCard.listFormatter = new Services.intl.ListFormat(
+ Services.appinfo.name == "xpcshell" ? "en-US" : undefined,
+ { type: "unit" }
+);
+abViewCard.prototype = {
+ _getText(columnID) {
+ try {
+ let { getProperty, supportsVCard, vCardProperties } = this.card;
+
+ if (this.card.isMailList) {
+ if (columnID == "GeneratedName") {
+ return this.card.displayName;
+ }
+ if (["NickName", "Notes"].includes(columnID)) {
+ return getProperty(columnID, "");
+ }
+ if (columnID == "addrbook") {
+ return MailServices.ab.getDirectoryFromUID(this.card.directoryUID)
+ .dirName;
+ }
+ return "";
+ }
+
+ switch (columnID) {
+ case "addrbook":
+ return this._directory.dirName;
+ case "GeneratedName":
+ return this.card.generateName(ABView.nameFormat);
+ case "EmailAddresses":
+ return abViewCard.listFormatter.format(this.card.emailAddresses);
+ case "PhoneNumbers": {
+ let phoneNumbers;
+ if (supportsVCard) {
+ phoneNumbers = vCardProperties.getAllValues("tel");
+ } else {
+ phoneNumbers = [
+ getProperty("WorkPhone", ""),
+ getProperty("HomePhone", ""),
+ getProperty("CellularNumber", ""),
+ getProperty("FaxNumber", ""),
+ getProperty("PagerNumber", ""),
+ ];
+ }
+ return abViewCard.listFormatter.format(phoneNumbers.filter(Boolean));
+ }
+ case "Addresses": {
+ let addresses;
+ if (supportsVCard) {
+ addresses = vCardProperties
+ .getAllValues("adr")
+ .map(v => v.join(" ").trim());
+ } else {
+ addresses = [
+ this.formatAddress("Work"),
+ this.formatAddress("Home"),
+ ];
+ }
+ return abViewCard.listFormatter.format(addresses.filter(Boolean));
+ }
+ case "JobTitle":
+ case "Title":
+ if (supportsVCard) {
+ return vCardProperties.getFirstValue("title");
+ }
+ return getProperty("JobTitle", "");
+ case "Department":
+ if (supportsVCard) {
+ let vCardValue = vCardProperties.getFirstValue("org");
+ if (Array.isArray(vCardValue)) {
+ return vCardValue[1] || "";
+ }
+ return "";
+ }
+ return getProperty(columnID, "");
+ case "Company":
+ case "Organization":
+ if (supportsVCard) {
+ let vCardValue = vCardProperties.getFirstValue("org");
+ if (Array.isArray(vCardValue)) {
+ return vCardValue[0] || "";
+ }
+ return vCardValue;
+ }
+ return getProperty("Company", "");
+ default:
+ return getProperty(columnID, "");
+ }
+ } catch (ex) {
+ return "";
+ }
+ },
+ getText(columnID) {
+ if (!(columnID in this._getTextCache)) {
+ this._getTextCache[columnID] = this._getText(columnID)?.trim() ?? "";
+ }
+ return this._getTextCache[columnID];
+ },
+ get id() {
+ return this.card.UID;
+ },
+ get open() {
+ return false;
+ },
+ get level() {
+ return 0;
+ },
+ get children() {
+ return [];
+ },
+ getProperties() {
+ return "";
+ },
+ get directory() {
+ return this._directory;
+ },
+
+ /**
+ * Creates a string representation of an address from card properties.
+ *
+ * @param {"Work"|"Home"} prefix
+ * @returns {string}
+ */
+ formatAddress(prefix) {
+ return Array.from(
+ ["Address", "Address2", "City", "State", "ZipCode", "Country"],
+ field => this.card.getProperty(`${prefix}${field}`, "")
+ )
+ .join(" ")
+ .trim();
+ },
+};
diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.js b/comm/mail/components/addrbook/content/aboutAddressBook.js
new file mode 100644
index 0000000000..8f0eeca693
--- /dev/null
+++ b/comm/mail/components/addrbook/content/aboutAddressBook.js
@@ -0,0 +1,4445 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals ABView */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "ABQueryUtils", function () {
+ return ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+});
+XPCOMUtils.defineLazyGetter(this, "AddrBookUtils", function () {
+ return ChromeUtils.import("resource:///modules/AddrBookUtils.jsm");
+});
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ AddrBookUtils: "resource:///modules/AddrBookUtils.jsm",
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalMetronome: "resource:///modules/CalMetronome.jsm",
+ CardDAVDirectory: "resource:///modules/CardDAVDirectory.jsm",
+ GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ ICAL: "resource:///modules/calendar/Ical.jsm",
+ MailE10SUtils: "resource:///modules/MailE10SUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+});
+XPCOMUtils.defineLazyGetter(this, "SubDialog", function () {
+ const { SubDialogManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/SubDialog.sys.mjs"
+ );
+ return new SubDialogManager({
+ dialogStack: document.getElementById("dialogStack"),
+ dialogTemplate: document.getElementById("dialogTemplate"),
+ dialogOptions: {
+ styleSheets: [
+ "chrome://messenger/skin/preferences/dialog.css",
+ "chrome://messenger/skin/shared/preferences/subdialog.css",
+ "chrome://messenger/skin/abFormFields.css",
+ ],
+ resizeCallback: ({ title, frame }) => {
+ UIFontSize.registerWindow(frame.contentWindow);
+
+ // Resize the dialog to fit the content with edited font size.
+ requestAnimationFrame(() => {
+ let dialogs = frame.ownerGlobal.SubDialog._dialogs;
+ let dialog = dialogs.find(
+ d => d._frame.contentDocument == frame.contentDocument
+ );
+ if (dialog) {
+ UIFontSize.resizeSubDialog(dialog);
+ }
+ });
+ },
+ },
+ });
+});
+
+UIDensity.registerWindow(window);
+UIFontSize.registerWindow(window);
+
+var booksList;
+
+window.addEventListener("load", () => {
+ document
+ .getElementById("toolbarCreateBook")
+ .addEventListener("command", event => {
+ let type = event.target.value || "JS_DIRECTORY_TYPE";
+ createBook(Ci.nsIAbManager[type]);
+ });
+ document
+ .getElementById("toolbarCreateContact")
+ .addEventListener("command", () => createContact());
+ document
+ .getElementById("toolbarCreateList")
+ .addEventListener("command", () => createList());
+ document
+ .getElementById("toolbarImport")
+ .addEventListener("command", () => importBook());
+
+ document.getElementById("bookContext").addEventListener("command", event => {
+ switch (event.target.id) {
+ case "bookContextProperties":
+ booksList.showPropertiesOfSelected();
+ break;
+ case "bookContextSynchronize":
+ booksList.synchronizeSelected();
+ break;
+ case "bookContextPrint":
+ booksList.printSelected();
+ break;
+ case "bookContextExport":
+ booksList.exportSelected();
+ break;
+ case "bookContextDelete":
+ booksList.deleteSelected();
+ break;
+ case "bookContextRemove":
+ booksList.deleteSelected();
+ break;
+ case "bookContextStartupDefault":
+ if (event.target.hasAttribute("checked")) {
+ booksList.setSelectedAsStartupDefault();
+ } else {
+ booksList.clearStartupDefault();
+ }
+ break;
+ }
+ });
+
+ booksList = document.getElementById("books");
+ cardsPane.init();
+ detailsPane.init();
+ photoDialog.init();
+
+ setKeyboardShortcuts();
+
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ let startupURI = Services.prefs.getStringPref(
+ "mail.addr_book.view.startupURI",
+ ""
+ );
+ if (startupURI) {
+ for (let index = 0; index < booksList.rows.length; index++) {
+ let row = booksList.rows[index];
+ if (row._book?.URI == startupURI || row._list?.URI == startupURI) {
+ booksList.selectedIndex = index;
+ break;
+ }
+ }
+ }
+
+ if (booksList.selectedIndex == 0) {
+ // Index 0 was selected before we started listening.
+ booksList.dispatchEvent(new CustomEvent("select"));
+ }
+
+ cardsPane.searchInput.focus();
+
+ window.dispatchEvent(new CustomEvent("about-addressbook-ready"));
+});
+
+window.addEventListener("unload", () => {
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ if (!Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ let pref = "mail.addr_book.view.startupURI";
+ if (booksList.selectedIndex === 0) {
+ Services.prefs.clearUserPref(pref);
+ } else {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let directory = row._book || row._list;
+ Services.prefs.setCharPref(pref, directory.URI);
+ }
+ }
+
+ // Disconnect the view (if there is one) and tree, so that the view cleans
+ // itself up and stops listening for observer service notifications.
+ cardsPane.cardsList.view = null;
+ detailsPane.uninit();
+});
+
+window.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used.
+ if (
+ event.key == " " &&
+ detailsPane.isEditing &&
+ document.activeElement.tagName == "body"
+ ) {
+ event.preventDefault();
+ }
+});
+
+/**
+ * Add a keydown document event listener for international keyboard shortcuts.
+ */
+async function setKeyboardShortcuts() {
+ let [newContactKey] = await document.l10n.formatValues([
+ { id: "about-addressbook-new-contact-key" },
+ ]);
+
+ document.addEventListener("keydown", event => {
+ if (
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ ["Shift", "Control", "Meta"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // Always use lowercase to compare the key and avoid OS inconsistencies:
+ // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A".
+ switch (event.key.toLowerCase()) {
+ // Always prevent the default behavior of the keydown if we intercepted
+ // the key in order to avoid triggering OS specific shortcuts.
+ case newContactKey.toLowerCase(): {
+ // Ctrl/Cmd+n.
+ event.preventDefault();
+ if (!detailsPane.isEditing) {
+ createContact();
+ }
+ break;
+ }
+ }
+ });
+}
+
+/**
+ * Called on load from `toAddressBook` to create, display or edit a card.
+ *
+ * @param {"create"|"display"|"edit"|"create_ab_*"} action - What to do with the args given.
+ * @param {?string} address - Create a new card with this email address.
+ * @param {?string} vCard - Create a new card from this vCard.
+ * @param {?nsIAbCard} card - Display or edit this card.
+ */
+function externalAction({ action, address, card, vCard } = {}) {
+ if (action == "create") {
+ if (address) {
+ detailsPane.editNewContact(
+ `BEGIN:VCARD\r\nEMAIL:${address}\r\nEND:VCARD\r\n`
+ );
+ } else {
+ detailsPane.editNewContact(vCard);
+ }
+ } else if (action == "display" || action == "edit") {
+ if (!card || !card.directoryUID) {
+ return;
+ }
+
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ if (!book) {
+ return;
+ }
+
+ booksList.selectedIndex = booksList.getIndexForUID(card.directoryUID);
+ cardsPane.cardsList.selectedIndex = cardsPane.cardsList.view.getIndexForUID(
+ card.UID
+ );
+
+ if (action == "edit" && book && !book.readOnly) {
+ detailsPane.editCurrentContact();
+ }
+ } else if (action == "print") {
+ if (document.activeElement == booksList) {
+ booksList.printSelected();
+ } else {
+ cardsPane.printSelected();
+ }
+ } else if (action == "create_ab_JS") {
+ createBook();
+ } else if (action == "create_ab_CARDDAV") {
+ createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ } else if (action == "create_ab_LDAP") {
+ createBook(Ci.nsIAbManager.LDAP_DIRECTORY_TYPE);
+ }
+}
+
+/**
+ * Show UI to create a new address book of the type specified.
+ *
+ * @param {integer} [type=Ci.nsIAbManager.JS_DIRECTORY_TYPE] - One of the
+ * nsIAbManager directory type constants.
+ */
+function createBook(type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) {
+ const typeURLs = {
+ [Ci.nsIAbManager.LDAP_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/pref-directory-add.xhtml",
+ [Ci.nsIAbManager.JS_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml",
+ [Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE]:
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml",
+ };
+
+ let url = typeURLs[type];
+ if (!url) {
+ throw new Components.Exception(
+ `Unexpected type: ${type}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let params = {};
+ SubDialog.open(
+ url,
+ {
+ features: "resizable=no",
+ closedCallback: () => {
+ if (params.newDirectoryUID) {
+ booksList.selectedIndex = booksList.getIndexForUID(
+ params.newDirectoryUID
+ );
+ booksList.focus();
+ }
+ },
+ },
+ params
+ );
+}
+
+/**
+ * Show UI to create a new contact in the current address book.
+ */
+function createContact() {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+
+ if (bookUID) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ }
+
+ detailsPane.editNewContact();
+}
+
+/**
+ * Show UI to create a new list in the current address book.
+ * For now this loads the old list UI, the intention is to replace it.
+ *
+ * @param {nsIAbCard[]} cards - The contacts, if any, to add to the list.
+ */
+function createList(cards) {
+ let row = booksList.getRowAtIndex(booksList.selectedIndex);
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+
+ let params = { cards };
+ if (bookUID) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ if (!book.supportsMailingLists) {
+ throw new Components.Exception(
+ "Address book does not support lists",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ params.selectedAB = book.URI;
+ }
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml",
+ {
+ features: "resizable=no",
+ closedCallback: () => {
+ if (params.newListUID) {
+ booksList.selectedIndex = booksList.getIndexForUID(params.newListUID);
+ booksList.focus();
+ }
+ },
+ },
+ params
+ );
+}
+
+/**
+ * Import an address book from a file. This shows the generic Thunderbird
+ * import wizard, which isn't ideal but better than nothing.
+ */
+function importBook() {
+ let createdDirectory;
+ let observer = function (subject) {
+ // It might be possible for more than one directory to be imported, select
+ // the first one.
+ if (!createdDirectory) {
+ createdDirectory = subject.QueryInterface(Ci.nsIAbDirectory);
+ }
+ };
+
+ Services.obs.addObserver(observer, "addrbook-directory-created");
+ window.browsingContext.topChromeWindow.toImport("addressBook");
+ Services.obs.removeObserver(observer, "addrbook-directory-created");
+
+ // Select the directory after the import UI closes, so the user sees the change.
+ if (createdDirectory) {
+ booksList.selectedIndex = booksList.getIndexForUID(createdDirectory.UID);
+ }
+}
+
+/**
+ * Sets the total count for the current selected address book at the bottom
+ * of the address book view.
+ */
+async function updateAddressBookCount() {
+ let cardCount = document.getElementById("cardCount");
+ let { rowCount: count, directory } = cardsPane.cardsList.view;
+
+ if (directory) {
+ document.l10n.setAttributes(cardCount, "about-addressbook-card-count", {
+ name: directory.dirName,
+ count,
+ });
+ } else {
+ document.l10n.setAttributes(cardCount, "about-addressbook-card-count-all", {
+ count,
+ });
+ }
+}
+
+/**
+ * Update the shared splitter between the cardsPane and detailsPane in order to
+ * properly set its properties to handle the correct pane based on the layout.
+ *
+ * @param {boolean} isTableLayout - If the current body layout is a table.
+ */
+function updateSharedSplitter(isTableLayout) {
+ let splitter = document.getElementById("sharedSplitter");
+ splitter.resizeDirection = isTableLayout ? "vertical" : "horizontal";
+ splitter.resizeElement = document.getElementById(
+ isTableLayout ? "detailsPane" : "cardsPane"
+ );
+
+ splitter.isCollapsed =
+ document.getElementById("detailsPane").hidden && isTableLayout;
+}
+
+// Books
+
+/**
+ * The list of address books.
+ *
+ * @augments {TreeListbox}
+ */
+class AbTreeListbox extends customElements.get("tree-listbox") {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+ this.setAttribute("is", "ab-tree-listbox");
+
+ this.addEventListener("select", this);
+ this.addEventListener("collapsed", this);
+ this.addEventListener("expanded", this);
+ this.addEventListener("keypress", this);
+ this.addEventListener("contextmenu", this);
+ this.addEventListener("dragover", this);
+ this.addEventListener("dragleave", this);
+ this.addEventListener("drop", this);
+
+ for (let book of MailServices.ab.directories) {
+ this.appendChild(this._createBookRow(book));
+ }
+
+ this._abObserver.observe = this._abObserver.observe.bind(this);
+ for (let topic of this._abObserver._notifications) {
+ Services.obs.addObserver(this._abObserver, topic, true);
+ }
+
+ window.addEventListener("unload", this);
+
+ // Add event listener to update the total count of the selected address
+ // book.
+ this.addEventListener("select", e => {
+ updateAddressBookCount();
+ });
+
+ // Row 0 is the "All Address Books" item.
+ document.body.classList.toggle("all-ab-selected", this.selectedIndex === 0);
+ }
+
+ destroy() {
+ this.removeEventListener("select", this);
+ this.removeEventListener("collapsed", this);
+ this.removeEventListener("expanded", this);
+ this.removeEventListener("keypress", this);
+ this.removeEventListener("contextmenu", this);
+ this.removeEventListener("dragover", this);
+ this.removeEventListener("dragleave", this);
+ this.removeEventListener("drop", this);
+
+ for (let topic of this._abObserver._notifications) {
+ Services.obs.removeObserver(this._abObserver, topic);
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+
+ switch (event.type) {
+ case "select":
+ this._onSelect(event);
+ break;
+ case "collapsed":
+ this._onCollapsed(event);
+ break;
+ case "expanded":
+ this._onExpanded(event);
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "dragleave":
+ this._clearDropTarget(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "unload":
+ this.destroy();
+ break;
+ }
+ }
+
+ _createBookRow(book) {
+ let row = document
+ .getElementById("bookRow")
+ .content.firstElementChild.cloneNode(true);
+ row.id = `book-${book.UID}`;
+ row.setAttribute("aria-label", book.dirName);
+ row.title = book.dirName;
+ if (
+ Services.xulStore.getValue(cardsPane.URL, row.id, "collapsed") == "true"
+ ) {
+ row.classList.add("collapsed");
+ }
+ if (book.isRemote) {
+ row.classList.add("remote");
+ }
+ if (book.readOnly) {
+ row.classList.add("readOnly");
+ }
+ if (
+ ["ldap_2.servers.history", "ldap_2.servers.pab"].includes(book.dirPrefId)
+ ) {
+ row.classList.add("noDelete");
+ }
+ if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) {
+ row.classList.add("carddav");
+ }
+ row.dataset.uid = book.UID;
+ row._book = book;
+ row.querySelector("span").textContent = book.dirName;
+
+ for (let list of book.childNodes) {
+ row.querySelector("ul").appendChild(this._createListRow(book.UID, list));
+ }
+ return row;
+ }
+
+ _createListRow(bookUID, list) {
+ let row = document
+ .getElementById("listRow")
+ .content.firstElementChild.cloneNode(true);
+ row.id = `list-${list.UID}`;
+ row.setAttribute("aria-label", list.dirName);
+ row.title = list.dirName;
+ row.dataset.uid = list.UID;
+ row.dataset.book = bookUID;
+ row._list = list;
+ row.querySelector("span").textContent = list.dirName;
+ return row;
+ }
+
+ /**
+ * Get the index of the row representing a book or list.
+ *
+ * @param {string|null} uid - The UID of the book or list to find, or null
+ * for All Address Books.
+ * @returns {integer} - Index of the book or list.
+ */
+ getIndexForUID(uid) {
+ if (!uid) {
+ return 0;
+ }
+ return this.rows.findIndex(r => r.dataset.uid == uid);
+ }
+
+ /**
+ * Get the row representing a book or list.
+ *
+ * @param {string|null} uid - The UID of the book or list to find, or null
+ * for All Address Books.
+ * @returns {HTMLLIElement} - Row of the book or list.
+ */
+ getRowForUID(uid) {
+ if (!uid) {
+ return this.firstElementChild;
+ }
+ return this.querySelector(`li[data-uid="${uid}"]`);
+ }
+
+ /**
+ * Show UI to modify the selected address book or list.
+ */
+ showPropertiesOfSelected() {
+ if (this.selectedIndex === 0) {
+ throw new Components.Exception(
+ "Cannot modify the All Address Books item",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let row = this.rows[this.selectedIndex];
+
+ if (row.classList.contains("listRow")) {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.book);
+ let list = book.childNodes.find(l => l.UID == row.dataset.uid);
+
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml",
+ { features: "resizable=no" },
+ { listURI: list.URI }
+ );
+ return;
+ }
+
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+
+ SubDialog.open(
+ book.propertiesChromeURI,
+ { features: "resizable=no" },
+ { selectedDirectory: book }
+ );
+ }
+
+ /**
+ * Synchronize the selected address book. (CardDAV only.)
+ */
+ synchronizeSelected() {
+ let row = this.rows[this.selectedIndex];
+ if (!row.classList.contains("carddav")) {
+ throw new Components.Exception(
+ "Attempting to synchronize a non-CardDAV book.",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let directory = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+ directory = CardDAVDirectory.forFile(directory.fileName);
+ directory.syncWithServer().then(res => {
+ updateAddressBookCount();
+ });
+ }
+
+ /**
+ * Print the selected address book.
+ */
+ printSelected() {
+ if (this.selectedIndex === 0) {
+ printHandler.printDirectory();
+ return;
+ }
+
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("listRow")) {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.book);
+ let list = book.childNodes.find(l => l.UID == row.dataset.uid);
+ printHandler.printDirectory(list);
+ } else {
+ let book = MailServices.ab.getDirectoryFromUID(row.dataset.uid);
+ printHandler.printDirectory(book);
+ }
+ }
+
+ /**
+ * Export the selected address book to a file.
+ */
+ exportSelected() {
+ if (this.selectedIndex == 0) {
+ return;
+ }
+
+ let row = this.getRowAtIndex(this.selectedIndex);
+ let directory = row._book || row._list;
+ AddrBookUtils.exportDirectory(directory);
+ }
+
+ /**
+ * Prompt the user and delete the selected address book.
+ */
+ async deleteSelected() {
+ if (this.selectedIndex === 0) {
+ throw new Components.Exception(
+ "Cannot delete the All Address Books item",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("noDelete")) {
+ throw new Components.Exception(
+ "Refusing to delete a built-in address book",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let action, name, uri;
+ if (row.classList.contains("listRow")) {
+ action = "delete-lists";
+ name = row._list.dirName;
+ uri = row._list.URI;
+ } else {
+ if (
+ [
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE,
+ ].includes(row._book.dirType)
+ ) {
+ action = "remove-remote-book";
+ } else {
+ action = "delete-book";
+ }
+
+ name = row._book.dirName;
+ uri = row._book.URI;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-confirm-${action}-title`, args: { count: 1 } },
+ {
+ id: `about-addressbook-confirm-${action}`,
+ args: { name, count: 1 },
+ },
+ ]);
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) === 0
+ ) {
+ MailServices.ab.deleteAddressBook(uri);
+ }
+ }
+
+ /**
+ * Set the selected directory to be the one opened when the page opens.
+ */
+ setSelectedAsStartupDefault() {
+ // Once the old Address Book has gone away, this should be changed to use
+ // UIDs instead of URIs. It's just easier to keep as-is for now.
+ Services.prefs.setBoolPref("mail.addr_book.view.startupURIisDefault", true);
+ if (this.selectedIndex === 0) {
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURI");
+ return;
+ }
+
+ let row = this.rows[this.selectedIndex];
+ let directory = row._book || row._list;
+ Services.prefs.setStringPref(
+ "mail.addr_book.view.startupURI",
+ directory.URI
+ );
+ }
+
+ /**
+ * Clear the directory to be opened when the page opens. Instead, the
+ * last-selected directory will be opened.
+ */
+ clearStartupDefault() {
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ }
+
+ _onSelect() {
+ let row = this.rows[this.selectedIndex];
+ if (row.classList.contains("listRow")) {
+ cardsPane.displayList(row.dataset.book, row.dataset.uid);
+ } else {
+ cardsPane.displayBook(row.dataset.uid);
+ }
+
+ // Row 0 is the "All Address Books" item.
+ if (this.selectedIndex === 0) {
+ document.getElementById("toolbarCreateContact").disabled = false;
+ document.getElementById("toolbarCreateList").disabled = false;
+ document.body.classList.add("all-ab-selected");
+ } else {
+ let bookUID = row.dataset.book ?? row.dataset.uid;
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+
+ document.getElementById("toolbarCreateContact").disabled = book.readOnly;
+ document.getElementById("toolbarCreateList").disabled =
+ book.readOnly || !book.supportsMailingLists;
+ document.body.classList.remove("all-ab-selected");
+ }
+ }
+
+ _onCollapsed(event) {
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ event.target.id,
+ "collapsed",
+ "true"
+ );
+ }
+
+ _onExpanded(event) {
+ Services.xulStore.removeValue(cardsPane.URL, event.target.id, "collapsed");
+ }
+
+ _onKeyPress(event) {
+ if (event.altKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Delete":
+ this.deleteSelected();
+ break;
+ }
+ }
+
+ _onClick(event) {
+ super._onClick(event);
+
+ // Only handle left-clicks. Right-clicking on the menu button will cause
+ // the menu to appear anyway, and other buttons can be ignored.
+ if (
+ event.button !== 0 ||
+ !event.target.closest(".bookRow-menu, .listRow-menu")
+ ) {
+ return;
+ }
+
+ this._showContextMenu(event);
+ }
+
+ _onContextMenu(event) {
+ this._showContextMenu(event);
+ }
+
+ _onDragOver(event) {
+ let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ if (!cards) {
+ return;
+ }
+ if (cards.some(c => c.isMailList)) {
+ return;
+ }
+
+ // TODO: Handle dropping a vCard here.
+
+ let row = event.target.closest("li");
+ if (!row || row.classList.contains("readOnly")) {
+ return;
+ }
+
+ let rowIsList = row.classList.contains("listRow");
+ event.dataTransfer.effectAllowed = rowIsList ? "link" : "copyMove";
+
+ if (rowIsList) {
+ let bookUID = row.dataset.book;
+ for (let card of cards) {
+ if (card.directoryUID != bookUID) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "link";
+ } else {
+ let bookUID = row.dataset.uid;
+ for (let card of cards) {
+ // Prevent dropping a card where it already is.
+ if (card.directoryUID == bookUID) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = event.ctrlKey ? "copy" : "move";
+ }
+
+ this._clearDropTarget();
+ row.classList.add("drop-target");
+
+ event.preventDefault();
+ }
+
+ _clearDropTarget() {
+ this.querySelector(".drop-target")?.classList.remove("drop-target");
+ }
+
+ _onDrop(event) {
+ this._clearDropTarget();
+ if (event.dataTransfer.dropEffect == "none") {
+ // Somehow this is possible. It should not be possible.
+ return;
+ }
+
+ let cards = event.dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ let row = event.target.closest("li");
+
+ if (row.classList.contains("listRow")) {
+ for (let card of cards) {
+ row._list.addCard(card);
+ }
+ } else if (event.dataTransfer.dropEffect == "copy") {
+ for (let card of cards) {
+ row._book.dropCard(card, true);
+ }
+ } else {
+ let booksMap = new Map();
+ let bookUID = row.dataset.uid;
+ for (let card of cards) {
+ if (bookUID == card.directoryUID) {
+ continue;
+ }
+ row._book.dropCard(card, false);
+ let bookSet = booksMap.get(card.directoryUID);
+ if (!bookSet) {
+ bookSet = new Set();
+ booksMap.set(card.directoryUID, bookSet);
+ }
+ bookSet.add(card);
+ }
+ for (let [uid, bookSet] of booksMap) {
+ MailServices.ab.getDirectoryFromUID(uid).deleteCards([...bookSet]);
+ }
+ }
+
+ event.preventDefault();
+ }
+
+ _showContextMenu(event) {
+ let row =
+ event.target == this
+ ? this.rows[this.selectedIndex]
+ : event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ let popup = document.getElementById("bookContext");
+ let synchronizeItem = document.getElementById("bookContextSynchronize");
+ let exportItem = document.getElementById("bookContextExport");
+ let deleteItem = document.getElementById("bookContextDelete");
+ let removeItem = document.getElementById("bookContextRemove");
+ let startupDefaultItem = document.getElementById(
+ "bookContextStartupDefault"
+ );
+
+ let isDefault = Services.prefs.getBoolPref(
+ "mail.addr_book.view.startupURIisDefault"
+ );
+
+ this.selectedIndex = this.rows.indexOf(row);
+ this.focus();
+ if (this.selectedIndex === 0) {
+ // All Address Books - only the startup default item is relevant.
+ for (let item of popup.children) {
+ item.hidden = item != startupDefaultItem;
+ }
+
+ isDefault =
+ isDefault &&
+ !Services.prefs.prefHasUserValue("mail.addr_book.view.startupURI");
+ } else {
+ for (let item of popup.children) {
+ item.hidden = false;
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("bookContextProperties"),
+ row.classList.contains("listRow")
+ ? "about-addressbook-books-context-edit-list"
+ : "about-addressbook-books-context-properties"
+ );
+
+ synchronizeItem.hidden = !row.classList.contains("carddav");
+ exportItem.hidden = row.classList.contains("remote");
+
+ deleteItem.disabled = row.classList.contains("noDelete");
+ deleteItem.hidden = row.classList.contains("carddav");
+
+ removeItem.disabled = row.classList.contains("noDelete");
+ removeItem.hidden = !row.classList.contains("carddav");
+
+ let directory = row._book || row._list;
+ isDefault =
+ isDefault &&
+ Services.prefs.getStringPref("mail.addr_book.view.startupURI") ==
+ directory.URI;
+ }
+
+ if (isDefault) {
+ startupDefaultItem.setAttribute("checked", "true");
+ } else {
+ startupDefaultItem.removeAttribute("checked");
+ }
+
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // This is a click on the menu button, or the context menu key was
+ // pressed. Open near the menu button.
+ popup.openPopup(
+ row.querySelector(".bookRow-container, .listRow-container"),
+ {
+ triggerEvent: event,
+ position: "end_before",
+ x: -26,
+ y: 30,
+ }
+ );
+ }
+ event.preventDefault();
+ }
+
+ _abObserver = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-directory-request-start",
+ "addrbook-directory-request-end",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ ],
+
+ // Bound to `booksList`.
+ observe(subject, topic, data) {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ switch (topic) {
+ case "addrbook-directory-created": {
+ let row = this._createBookRow(subject);
+ let next = this.children[1];
+ while (next) {
+ if (
+ AddrBookUtils.compareAddressBooks(
+ subject,
+ MailServices.ab.getDirectoryFromUID(next.dataset.uid)
+ ) < 0
+ ) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ this.insertBefore(row, next);
+ break;
+ }
+ case "addrbook-directory-updated":
+ case "addrbook-list-updated": {
+ let row = this.getRowForUID(subject.UID);
+ row.querySelector(".bookRow-name, .listRow-name").textContent =
+ subject.dirName;
+ row.setAttribute("aria-label", subject.dirName);
+ if (cardsPane.cardsList.view.directory?.UID == subject.UID) {
+ document.l10n.setAttributes(
+ cardsPane.searchInput,
+ "about-addressbook-search",
+ { name: subject.dirName }
+ );
+ }
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ this.getRowForUID(subject.UID).remove();
+ break;
+ }
+ case "addrbook-directory-request-start":
+ this.getRowForUID(data).classList.add("requesting");
+ break;
+ case "addrbook-directory-request-end":
+ this.getRowForUID(data).classList.remove("requesting");
+ break;
+ case "addrbook-list-created": {
+ let row = this.getRowForUID(data);
+ let childList = row.querySelector("ul");
+ if (!childList) {
+ childList = row.appendChild(document.createElement("ul"));
+ }
+
+ let listRow = this._createListRow(data, subject);
+ let next = childList.firstElementChild;
+ while (next) {
+ if (AddrBookUtils.compareAddressBooks(subject, next._list) < 0) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ childList.insertBefore(listRow, next);
+ break;
+ }
+ case "addrbook-list-deleted": {
+ let row = this.getRowForUID(data);
+ let childList = row.querySelector("ul");
+ let listRow = childList.querySelector(`[data-uid="${subject.UID}"]`);
+ listRow.remove();
+ if (childList.childElementCount == 0) {
+ setTimeout(() => childList.remove());
+ }
+ break;
+ }
+ }
+ },
+ };
+}
+customElements.define("ab-tree-listbox", AbTreeListbox, { extends: "ul" });
+
+// Cards
+
+/**
+ * Search field for card list. An HTML port of MozSearchTextbox.
+ */
+class AbCardSearchInput extends HTMLInputElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this._fireCommand = this._fireCommand.bind(this);
+
+ this.addEventListener("input", this);
+ this.addEventListener("keypress", this);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "input":
+ this._onInput(event);
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ }
+ }
+
+ _onInput() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+ this._timer = setTimeout(this._fireCommand, 500, this);
+ }
+
+ _onKeyPress(event) {
+ switch (event.key) {
+ case "Escape":
+ if (this._clearSearch()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ break;
+ case "Return":
+ this._enterSearch();
+ event.preventDefault();
+ event.stopPropagation();
+ break;
+ }
+ }
+
+ _fireCommand() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+ this._timer = null;
+ this.dispatchEvent(new CustomEvent("command"));
+ }
+
+ _enterSearch() {
+ this._fireCommand();
+ }
+
+ _clearSearch() {
+ if (this.value) {
+ this.value = "";
+ this._fireCommand();
+ return true;
+ }
+ return false;
+ }
+}
+customElements.define("ab-card-search-input", AbCardSearchInput, {
+ extends: "input",
+});
+
+customElements.whenDefined("tree-view-table-row").then(() => {
+ /**
+ * A row in the list of cards.
+ *
+ * @augments {TreeViewTableRow}
+ */
+ class AbCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 46;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ this.cell = document.createElement("td");
+
+ let container = this.cell.appendChild(document.createElement("div"));
+ container.classList.add("card-container");
+
+ this.avatar = container.appendChild(document.createElement("div"));
+ this.avatar.classList.add("recipient-avatar");
+ let dataContainer = container.appendChild(document.createElement("div"));
+ dataContainer.classList.add("ab-card-row-data");
+
+ this.firstLine = dataContainer.appendChild(document.createElement("p"));
+ this.firstLine.classList.add("ab-card-first-line");
+ this.name = this.firstLine.appendChild(document.createElement("span"));
+ this.name.classList.add("name");
+
+ let secondLine = dataContainer.appendChild(document.createElement("p"));
+ secondLine.classList.add("ab-card-second-line");
+ this.address = secondLine.appendChild(document.createElement("span"));
+ this.address.classList.add("address");
+
+ this.appendChild(this.cell);
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ /**
+ * Override the row setter to generate the layout.
+ *
+ * @note This element could be recycled, make sure you set or clear all
+ * properties.
+ */
+ set index(index) {
+ super.index = index;
+
+ let card = this.view.getCardFromRow(index);
+ this.name.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+
+ // Add the address book name for All Address Books if in the sort Context
+ // Address Book is checked. This is done for the list view only.
+ if (
+ document.getElementById("books").selectedIndex == "0" &&
+ document
+ .getElementById("sortContext")
+ .querySelector(`menuitem[value="addrbook"]`)
+ .getAttribute("checked") === "true"
+ ) {
+ let addressBookName = this.querySelector(".address-book-name");
+ if (!addressBookName) {
+ addressBookName = document.createElement("span");
+ addressBookName.classList.add("address-book-name");
+ this.firstLine.appendChild(addressBookName);
+ }
+ addressBookName.textContent = this.view.getCellText(index, {
+ id: "addrbook",
+ });
+ } else {
+ this.querySelector(".address-book-name")?.remove();
+ }
+
+ // Don't try to fetch the avatar or show the parent AB if this is a list.
+ if (!card.isMailList) {
+ this.classList.remove("MailList");
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = this.name.textContent;
+ img.src = photoURL;
+ this.avatar.replaceChildren(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(
+ this.name.textContent
+ )[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ this.avatar.replaceChildren(letter);
+ }
+ this.address.textContent = card.primaryEmail;
+ } else {
+ this.classList.add("MailList");
+ let img = document.createElement("img");
+ img.alt = "";
+ img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg";
+ this.avatar.replaceChildren(img);
+ this.avatar.classList.add("is-mail-list");
+ this.address.textContent = "";
+ }
+
+ this.cell.setAttribute("aria-label", this.name.textContent);
+ }
+ }
+ customElements.define("ab-card-row", AbCardRow, { extends: "tr" });
+
+ /**
+ * A row in the table list of cards.
+ *
+ * @augments {TreeViewTableRow}
+ */
+ class AbTableCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 22;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ for (let column of cardsPane.COLUMNS) {
+ this.appendChild(document.createElement("td")).classList.add(
+ `${column.id.toLowerCase()}-column`
+ );
+ }
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ /**
+ * Override the row setter to generate the layout.
+ *
+ * @note This element could be recycled, make sure you set or clear all
+ * properties.
+ */
+ set index(index) {
+ super.index = index;
+
+ let card = this.view.getCardFromRow(index);
+ this.classList.toggle("MailList", card.isMailList);
+
+ for (let column of cardsPane.COLUMNS) {
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ if (!column.hidden) {
+ cell.textContent = this.view.getCellText(index, { id: column.id });
+ continue;
+ }
+
+ cell.hidden = true;
+ }
+
+ this.setAttribute("aria-label", this.firstElementChild.textContent);
+ }
+ }
+ customElements.define("ab-table-card-row", AbTableCardRow, {
+ extends: "tr",
+ });
+});
+
+var cardsPane = {
+ /**
+ * The document URL for saving and retrieving values in the XUL Store.
+ *
+ * @type {string}
+ */
+ URL: "about:addressbook",
+
+ /**
+ * The array of columns for the table layout.
+ *
+ * @type {Array}
+ */
+ COLUMNS: [
+ {
+ id: "GeneratedName",
+ l10n: {
+ header: "about-addressbook-column-header-generatedname2",
+ menuitem: "about-addressbook-column-label-generatedname2",
+ },
+ },
+ {
+ id: "EmailAddresses",
+ l10n: {
+ header: "about-addressbook-column-header-emailaddresses2",
+ menuitem: "about-addressbook-column-label-emailaddresses2",
+ },
+ },
+ {
+ id: "NickName",
+ l10n: {
+ header: "about-addressbook-column-header-nickname2",
+ menuitem: "about-addressbook-column-label-nickname2",
+ },
+ hidden: true,
+ },
+ {
+ id: "PhoneNumbers",
+ l10n: {
+ header: "about-addressbook-column-header-phonenumbers2",
+ menuitem: "about-addressbook-column-label-phonenumbers2",
+ },
+ },
+ {
+ id: "Addresses",
+ l10n: {
+ header: "about-addressbook-column-header-addresses2",
+ menuitem: "about-addressbook-column-label-addresses2",
+ },
+ },
+ {
+ id: "Title",
+ l10n: {
+ header: "about-addressbook-column-header-title2",
+ menuitem: "about-addressbook-column-label-title2",
+ },
+ hidden: true,
+ },
+ {
+ id: "Department",
+ l10n: {
+ header: "about-addressbook-column-header-department2",
+ menuitem: "about-addressbook-column-label-department2",
+ },
+ hidden: true,
+ },
+ {
+ id: "Organization",
+ l10n: {
+ header: "about-addressbook-column-header-organization2",
+ menuitem: "about-addressbook-column-label-organization2",
+ },
+ hidden: true,
+ },
+ {
+ id: "addrbook",
+ l10n: {
+ header: "about-addressbook-column-header-addrbook2",
+ menuitem: "about-addressbook-column-label-addrbook2",
+ },
+ hidden: true,
+ },
+ ],
+
+ /**
+ * Make the list rows density aware.
+ */
+ densityChange() {
+ let rowClass = customElements.get("ab-card-row");
+ let tableRowClass = customElements.get("ab-table-card-row");
+ switch (UIDensity.prefValue) {
+ case UIDensity.MODE_COMPACT:
+ rowClass.ROW_HEIGHT = 36;
+ tableRowClass.ROW_HEIGHT = 18;
+ break;
+ case UIDensity.MODE_TOUCH:
+ rowClass.ROW_HEIGHT = 60;
+ tableRowClass.ROW_HEIGHT = 32;
+ break;
+ default:
+ rowClass.ROW_HEIGHT = 46;
+ tableRowClass.ROW_HEIGHT = 22;
+ break;
+ }
+ this.cardsList.reset();
+ },
+
+ searchInput: null,
+
+ cardsList: null,
+
+ init() {
+ this.searchInput = document.getElementById("searchInput");
+ this.displayButton = document.getElementById("displayButton");
+ this.sortContext = document.getElementById("sortContext");
+ this.cardContext = document.getElementById("cardContext");
+
+ this.cardsList = document.getElementById("cards");
+ this.table = this.cardsList.table;
+ this.table.editable = true;
+ this.table.setBodyID("cardsBody");
+ this.cardsList.setAttribute("rows", "ab-card-row");
+
+ if (
+ Services.xulStore.getValue(cardsPane.URL, "cardsPane", "layout") ==
+ "table"
+ ) {
+ this.toggleLayout(true);
+ }
+
+ let nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+ this.sortContext
+ .querySelector(`[name="format"][value="${nameFormat}"]`)
+ ?.setAttribute("checked", "true");
+
+ let columns = Services.xulStore.getValue(cardsPane.URL, "cards", "columns");
+ if (columns) {
+ columns = columns.split(",");
+ for (let column of cardsPane.COLUMNS) {
+ column.hidden = !columns.includes(column.id);
+ }
+ }
+
+ this.table.setColumns(cardsPane.COLUMNS);
+ this.table.restoreColumnsWidths(cardsPane.URL);
+
+ // Only add the address book toggle to the filter button outside the table
+ // layout view. All other toggles are only for a table context.
+ let abColumn = cardsPane.COLUMNS.find(c => c.id == "addrbook");
+ let menuitem = this.sortContext.insertBefore(
+ document.createXULElement("menuitem"),
+ this.sortContext.querySelector("menuseparator:last-of-type")
+ );
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("name", "toggle");
+ menuitem.setAttribute("value", abColumn.id);
+ menuitem.setAttribute("closemenu", "none");
+ if (abColumn.l10n?.menuitem) {
+ document.l10n.setAttributes(menuitem, abColumn.l10n.menuitem);
+ }
+ if (!abColumn.hidden) {
+ menuitem.setAttribute("checked", "true");
+ }
+
+ menuitem.addEventListener("command", event =>
+ this._onColumnsChanged({ target: menuitem, value: abColumn.id })
+ );
+
+ this.searchInput.addEventListener("command", this);
+ this.displayButton.addEventListener("click", this);
+ this.sortContext.addEventListener("command", this);
+ this.table.addEventListener("columns-changed", this);
+ this.table.addEventListener("sort-changed", this);
+ this.table.addEventListener("column-resized", this);
+ this.cardsList.addEventListener("select", this);
+ this.cardsList.addEventListener("keydown", this);
+ this.cardsList.addEventListener("dblclick", this);
+ this.cardsList.addEventListener("dragstart", this);
+ this.cardsList.addEventListener("contextmenu", this);
+ this.cardsList.addEventListener("rowcountchange", () => {
+ if (
+ document.activeElement == this.cardsList &&
+ this.cardsList.view.rowCount == 0
+ ) {
+ this.searchInput.focus();
+ }
+ });
+ this.cardsList.addEventListener("searchstatechange", () =>
+ this._updatePlaceholder()
+ );
+ this.cardContext.addEventListener("command", this);
+
+ window.addEventListener("uidensitychange", () => cardsPane.densityChange());
+ customElements
+ .whenDefined("ab-table-card-row")
+ .then(() => cardsPane.densityChange());
+
+ document
+ .getElementById("placeholderCreateContact")
+ .addEventListener("click", () => createContact());
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "command":
+ this._onCommand(event);
+ break;
+ case "click":
+ this._onClick(event);
+ break;
+ case "select":
+ this._onSelect(event);
+ break;
+ case "keydown":
+ this._onKeyDown(event);
+ break;
+ case "dblclick":
+ this._onDoubleClick(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "columns-changed":
+ this._onColumnsChanged(event.detail);
+ break;
+ case "sort-changed":
+ this._onSortChanged(event);
+ break;
+ case "column-resized":
+ this._onColumnResized(event);
+ break;
+ }
+ },
+
+ /**
+ * Store the resized column value in the xul store.
+ *
+ * @param {DOMEvent} event - The dom event bubbling from the resized action.
+ */
+ _onColumnResized(event) {
+ this.table.setColumnsWidths(cardsPane.URL, event);
+ },
+
+ _onSortChanged(event) {
+ const { sortColumn, sortDirection } = this.cardsList.view;
+ const column = event.detail.column;
+ this.sortRows(
+ column,
+ sortColumn == column && sortDirection == "ascending"
+ ? "descending"
+ : "ascending"
+ );
+ },
+
+ _onColumnsChanged(data) {
+ let column = data.value;
+ let checked = data.target.hasAttribute("checked");
+
+ for (let columnDef of cardsPane.COLUMNS) {
+ if (columnDef.id == column) {
+ columnDef.hidden = !checked;
+ break;
+ }
+ }
+
+ this.table.updateColumns(cardsPane.COLUMNS);
+ this.cardsList.reset();
+
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cards",
+ "columns",
+ cardsPane.COLUMNS.filter(c => !c.hidden)
+ .map(c => c.id)
+ .join(",")
+ );
+ },
+
+ /**
+ * Switch between list and table layouts.
+ *
+ * @param {?boolean} isTableLayout - Use table layout if `true` or list
+ * layout if `false`. If unspecified, switch layouts.
+ */
+ toggleLayout(isTableLayout) {
+ isTableLayout = document.body.classList.toggle(
+ "layout-table",
+ isTableLayout
+ );
+
+ updateSharedSplitter(isTableLayout);
+
+ this.cardsList.setAttribute(
+ "rows",
+ isTableLayout ? "ab-table-card-row" : "ab-card-row"
+ );
+ this.cardsList.setSpacersColspan(
+ isTableLayout ? cardsPane.COLUMNS.filter(c => !c.hidden).length : 0
+ );
+ if (isTableLayout) {
+ this.sortContext
+ .querySelector("#sortContextTableLayout")
+ .setAttribute("checked", "true");
+ } else {
+ this.sortContext
+ .querySelector("#sortContextTableLayout")
+ .removeAttribute("checked");
+ }
+
+ if (this.cardsList.selectedIndex > -1) {
+ this.cardsList.scrollToIndex(this.cardsList.selectedIndex);
+ }
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cardsPane",
+ "layout",
+ isTableLayout ? "table" : "list"
+ );
+ },
+
+ /**
+ * Gets an address book query string based on the value of the search input.
+ *
+ * @returns {string}
+ */
+ getQuery() {
+ if (!this.searchInput.value) {
+ return null;
+ }
+
+ let searchWords = ABQueryUtils.getSearchTokens(this.searchInput.value);
+ let queryURIFormat = ABQueryUtils.getModelQuery(
+ "mail.addr_book.quicksearchquery.format"
+ );
+ return ABQueryUtils.generateQueryURI(queryURIFormat, searchWords);
+ },
+
+ /**
+ * Display an address book, or all address books.
+ *
+ * @param {string|null} uid - The UID of the book or list to display, or null
+ * for All Address Books.
+ */
+ displayBook(uid) {
+ let book = uid ? MailServices.ab.getDirectoryFromUID(uid) : null;
+ if (book) {
+ document.l10n.setAttributes(
+ this.searchInput,
+ "about-addressbook-search",
+ { name: book.dirName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ this.searchInput,
+ "about-addressbook-search-all"
+ );
+ }
+ let sortColumn =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") ||
+ "GeneratedName";
+ let sortDirection =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") ||
+ "ascending";
+ this.cardsList.view = new ABView(
+ book,
+ this.getQuery(),
+ this.searchInput.value,
+ sortColumn,
+ sortDirection
+ );
+ this.sortRows(sortColumn, sortDirection);
+ this._updatePlaceholder();
+
+ detailsPane.displayCards();
+ },
+
+ /**
+ * Display a list.
+ *
+ * @param {bookUID} uid - The UID of the address book containing the list.
+ * @param {string} uid - The UID of the list to display.
+ */
+ displayList(bookUID, uid) {
+ let book = MailServices.ab.getDirectoryFromUID(bookUID);
+ let list = book.childNodes.find(l => l.UID == uid);
+ document.l10n.setAttributes(this.searchInput, "about-addressbook-search", {
+ name: list.dirName,
+ });
+ let sortColumn =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortColumn") ||
+ "GeneratedName";
+ let sortDirection =
+ Services.xulStore.getValue(cardsPane.URL, "cards", "sortDirection") ||
+ "ascending";
+ this.cardsList.view = new ABView(
+ list,
+ this.getQuery(),
+ this.searchInput.value,
+ sortColumn,
+ sortDirection
+ );
+ this.sortRows(sortColumn, sortDirection);
+ this._updatePlaceholder();
+
+ detailsPane.displayCards();
+ },
+
+ get selectedCards() {
+ return this.cardsList.selectedIndices.map(i =>
+ this.cardsList.view.getCardFromRow(i)
+ );
+ },
+
+ /**
+ * Display the right message in the cards list placeholder. The placeholder
+ * is only visible if there are no cards in the list, but it's kept
+ * up-to-date at all times, so we don't have to keep track of the size of
+ * the list.
+ */
+ _updatePlaceholder() {
+ let { directory, searchState } = this.cardsList.view;
+
+ let idsToShow;
+ switch (searchState) {
+ case ABView.NOT_SEARCHING:
+ if (directory?.isRemote && !Services.io.offline) {
+ idsToShow = ["placeholderSearchOnly"];
+ } else {
+ idsToShow = ["placeholderEmptyBook"];
+ if (!directory?.readOnly && !directory?.isMailList) {
+ idsToShow.push("placeholderCreateContact");
+ }
+ }
+ break;
+ case ABView.SEARCHING:
+ idsToShow = ["placeholderSearching"];
+ break;
+ case ABView.SEARCH_COMPLETE:
+ idsToShow = ["placeholderNoSearchResults"];
+ break;
+ }
+
+ this.cardsList.updatePlaceholders(idsToShow);
+ },
+
+ /**
+ * Set the name format to be displayed.
+ *
+ * @param {integer} format - One of the nsIAbCard.GENERATE_* constants.
+ */
+ setNameFormat(event) {
+ // ABView will detect this change and update automatically.
+ Services.prefs.setIntPref(
+ "mail.addr_book.lastnamefirst",
+ event.target.value
+ );
+ },
+
+ /**
+ * Change the sort order of the rows being displayed. If `column` and
+ * `direction` match the existing values no sorting occurs but the UI items
+ * are always updated.
+ *
+ * @param {string} column
+ * @param {"ascending"|"descending"} direction
+ */
+ sortRows(column, direction) {
+ // Uncheck the sort button menu item for the previously sorted column, if
+ // there is one, then check the sort button menu item for the column to be
+ // sorted.
+ this.sortContext
+ .querySelector(`[name="sort"][checked]`)
+ ?.removeAttribute("checked");
+ this.sortContext
+ .querySelector(`[name="sort"][value="${column} ${direction}"]`)
+ ?.setAttribute("checked", "true");
+
+ // Unmark the header of previously sorted column, then mark the header of
+ // the column to be sorted.
+ this.table
+ .querySelector(".sorting")
+ ?.classList.remove("sorting", "ascending", "descending");
+ this.table
+ .querySelector(`#${column} button`)
+ ?.classList.add("sorting", direction);
+
+ if (
+ this.cardsList.view.sortColumn == column &&
+ this.cardsList.view.sortDirection == direction
+ ) {
+ return;
+ }
+
+ this.cardsList.view.sortBy(column, direction);
+
+ Services.xulStore.setValue(cardsPane.URL, "cards", "sortColumn", column);
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "cards",
+ "sortDirection",
+ direction
+ );
+ },
+
+ /**
+ * Start a new message to the given addresses.
+ *
+ * @param {string[]} addresses
+ */
+ writeTo(addresses) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.composeFields.to = addresses.join(",");
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ },
+
+ /**
+ * Start a new message to the selected contact(s) and/or mailing list(s).
+ */
+ writeToSelected() {
+ let selectedAddresses = [];
+
+ for (let card of this.selectedCards) {
+ let email;
+ if (card.isMailList) {
+ email = card.getProperty("Notes", "") || card.displayName;
+ } else {
+ email = card.emailAddresses[0];
+ }
+
+ if (email) {
+ selectedAddresses.push(
+ MailServices.headerParser.makeMimeAddress(card.displayName, email)
+ );
+ }
+ }
+
+ this.writeTo(selectedAddresses);
+ },
+
+ /**
+ * Print delete the selected card(s).
+ */
+ printSelected() {
+ let selectedCards = this.selectedCards;
+ if (selectedCards.length) {
+ // Some cards are selected. Print them.
+ printHandler.printCards(selectedCards);
+ } else if (this.cardsList.view.searchString) {
+ // Nothing's selected, so print everything. But this is a search, so we
+ // can't just print the selected book/list.
+ let allCards = [];
+ for (let i = 0; i < this.cardsList.view.rowCount; i++) {
+ allCards.push(this.cardsList.view.getCardFromRow(i));
+ }
+ printHandler.printCards(allCards);
+ } else {
+ // Nothing's selected, so print the selected book/list.
+ booksList.printSelected();
+ }
+ },
+
+ /**
+ * Export the selected mailing list to a file.
+ */
+ exportSelected() {
+ let card = this.selectedCards[0];
+ if (!card || !card.isMailList) {
+ return;
+ }
+ let row = booksList.getRowForUID(card.UID);
+ AddrBookUtils.exportDirectory(row._list);
+ },
+
+ _canModifySelected() {
+ if (this.cardsList.view.directory?.readOnly) {
+ return false;
+ }
+
+ let seenDirectories = new Set();
+ for (let index of this.cardsList.selectedIndices) {
+ let { directoryUID } = this.cardsList.view.getCardFromRow(index);
+ if (seenDirectories.has(directoryUID)) {
+ continue;
+ }
+ if (MailServices.ab.getDirectoryFromUID(directoryUID).readOnly) {
+ return false;
+ }
+ seenDirectories.add(directoryUID);
+ }
+ return true;
+ },
+
+ /**
+ * Prompt the user and delete the selected card(s).
+ */
+ async deleteSelected() {
+ if (!this._canModifySelected()) {
+ return;
+ }
+
+ let selectedLists = [];
+ let selectedContacts = [];
+
+ for (let index of this.cardsList.selectedIndices) {
+ let card = this.cardsList.view.getCardFromRow(index);
+ if (card.isMailList) {
+ selectedLists.push(card);
+ } else {
+ selectedContacts.push(card);
+ }
+ }
+
+ if (selectedLists.length + selectedContacts.length == 0) {
+ return;
+ }
+
+ // Determine strings for smart and context-sensitive user prompts
+ // for confirming deletion.
+ let action, name, list;
+ let count = selectedLists.length + selectedContacts.length;
+ let selectedDir = this.cardsList.view.directory;
+
+ if (selectedLists.length && selectedContacts.length) {
+ action = "delete-mixed";
+ } else if (selectedLists.length) {
+ action = "delete-lists";
+ name = selectedLists[0].displayName;
+ } else {
+ let nameFormatFromPref = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst"
+ );
+ name = selectedContacts[0].generateName(nameFormatFromPref);
+ if (selectedDir && selectedDir.isMailList) {
+ action = "remove-contacts";
+ list = selectedDir.dirName;
+ } else {
+ action = "delete-contacts";
+ }
+ }
+
+ // Adjust strings to match translations.
+ let actionString;
+ switch (action) {
+ case "delete-contacts":
+ actionString =
+ count > 1 ? "delete-contacts-multi" : "delete-contacts-single";
+ break;
+ case "remove-contacts":
+ actionString =
+ count > 1 ? "remove-contacts-multi" : "remove-contacts-single";
+ break;
+ default:
+ actionString = action;
+ break;
+ }
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-confirm-${action}-title`, args: { count } },
+ {
+ id: `about-addressbook-confirm-${actionString}`,
+ args: { count, name, list },
+ },
+ ]);
+
+ // Finally, show our smart confirmation message, and act upon it!
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) !== 0
+ ) {
+ // Deletion cancelled by user.
+ return;
+ }
+
+ // TODO: Setting the index should be unnecessary.
+ let indexAfterDelete = this.cardsList.currentIndex;
+ // Delete cards from address books or mailing lists.
+ this.cardsList.view.deleteSelectedCards();
+ this.cardsList.currentIndex = Math.min(
+ indexAfterDelete,
+ this.cardsList.view.rowCount - 1
+ );
+ },
+
+ _onContextMenu(event) {
+ this._showContextMenu(event);
+ },
+
+ _showContextMenu(event) {
+ let row;
+ if (event.target == this.cardsList.table.body) {
+ row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
+ } else {
+ row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ }
+ if (!row) {
+ return;
+ }
+ if (!this.cardsList.selectedIndices.includes(row.index)) {
+ this.cardsList.selectedIndex = row.index;
+ // Re-fetch the row in case it was replaced.
+ row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
+ }
+
+ this.cardsList.table.body.focus();
+
+ let writeMenuItem = document.getElementById("cardContextWrite");
+ let writeMenu = document.getElementById("cardContextWriteMenu");
+ let writeMenuSeparator = document.getElementById(
+ "cardContextWriteSeparator"
+ );
+ let editItem = document.getElementById("cardContextEdit");
+ // Always reset the edit item to its default string.
+ document.l10n.setAttributes(
+ editItem,
+ "about-addressbook-books-context-edit"
+ );
+ let exportItem = document.getElementById("cardContextExport");
+ if (this.cardsList.selectedIndices.length == 1) {
+ let card = this.cardsList.view.getCardFromRow(
+ this.cardsList.selectedIndex
+ );
+ if (card.isMailList) {
+ writeMenuItem.hidden = writeMenuSeparator.hidden = false;
+ writeMenu.hidden = true;
+ editItem.hidden = !this._canModifySelected();
+ document.l10n.setAttributes(
+ editItem,
+ "about-addressbook-books-context-edit-list"
+ );
+ exportItem.hidden = false;
+ } else {
+ let addresses = card.emailAddresses;
+
+ if (addresses.length == 0) {
+ writeMenuItem.hidden =
+ writeMenu.hidden =
+ writeMenuSeparator.hidden =
+ true;
+ } else if (addresses.length == 1) {
+ writeMenuItem.hidden = writeMenuSeparator.hidden = false;
+ writeMenu.hidden = true;
+ } else {
+ while (writeMenu.menupopup.lastChild) {
+ writeMenu.menupopup.lastChild.remove();
+ }
+
+ for (let address of addresses) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.label = MailServices.headerParser.makeMimeAddress(
+ card.displayName,
+ address
+ );
+ menuitem.addEventListener("command", () =>
+ this.writeTo([menuitem.label])
+ );
+ writeMenu.menupopup.appendChild(menuitem);
+ }
+
+ writeMenuItem.hidden = true;
+ writeMenu.hidden = writeMenuSeparator.hidden = false;
+ }
+
+ editItem.hidden = !this._canModifySelected();
+ exportItem.hidden = true;
+ }
+ } else {
+ writeMenuItem.hidden = false;
+ writeMenu.hidden = true;
+ editItem.hidden = true;
+ exportItem.hidden = true;
+ }
+
+ let deleteItem = document.getElementById("cardContextDelete");
+ let removeItem = document.getElementById("cardContextRemove");
+
+ let inMailList = this.cardsList.view.directory?.isMailList;
+ deleteItem.hidden = inMailList;
+ removeItem.hidden = !inMailList;
+ deleteItem.disabled = removeItem.disabled = !this._canModifySelected();
+
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ this.cardContext.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // This is a context menu key press. Open near the middle of the row.
+ this.cardContext.openPopup(row, {
+ triggerEvent: event,
+ position: "overlap",
+ x: row.clientWidth / 2,
+ y: row.clientHeight / 2,
+ });
+ }
+ event.preventDefault();
+ },
+
+ _onCommand(event) {
+ if (event.target == this.searchInput) {
+ this.cardsList.view = new ABView(
+ this.cardsList.view.directory,
+ this.getQuery(),
+ this.searchInput.value,
+ this.cardsList.view.sortColumn,
+ this.cardsList.view.sortDirection
+ );
+ this._updatePlaceholder();
+ detailsPane.displayCards();
+ return;
+ }
+
+ switch (event.target.id) {
+ case "sortContextTableLayout":
+ this.toggleLayout(event.target.getAttribute("checked") === "true");
+ break;
+ case "cardContextWrite":
+ this.writeToSelected();
+ return;
+ case "cardContextEdit":
+ detailsPane.editCurrent();
+ return;
+ case "cardContextPrint":
+ this.printSelected();
+ return;
+ case "cardContextExport":
+ this.exportSelected();
+ return;
+ case "cardContextDelete":
+ this.deleteSelected();
+ return;
+ case "cardContextRemove":
+ this.deleteSelected();
+ return;
+ }
+
+ if (event.target.getAttribute("name") == "format") {
+ this.setNameFormat(event);
+ }
+ if (event.target.getAttribute("name") == "sort") {
+ let [column, direction] = event.target.value.split(" ");
+ this.sortRows(column, direction);
+ }
+ },
+
+ _onClick(event) {
+ if (event.target.closest("button") == this.displayButton) {
+ this.sortContext.openPopup(this.displayButton, { triggerEvent: event });
+ event.preventDefault();
+ }
+ },
+
+ _onSelect(event) {
+ detailsPane.displayCards(this.selectedCards);
+ },
+
+ _onKeyDown(event) {
+ if (event.altKey || event.shiftKey) {
+ return;
+ }
+
+ let modifier = event.ctrlKey;
+ let antiModifier = event.metaKey;
+ if (AppConstants.platform == "macosx") {
+ [modifier, antiModifier] = [antiModifier, modifier];
+ }
+ if (antiModifier) {
+ return;
+ }
+
+ switch (event.key) {
+ case "a":
+ if (modifier) {
+ this.cardsList.view.selection.selectAll();
+ this.cardsList.dispatchEvent(new CustomEvent("select"));
+ event.preventDefault();
+ }
+ break;
+ case "Delete":
+ if (!modifier) {
+ this.deleteSelected();
+ event.preventDefault();
+ }
+ break;
+ case "Enter":
+ if (!modifier) {
+ if (this.cardsList.currentIndex >= 0) {
+ this._activateRow(this.cardsList.currentIndex);
+ }
+ event.preventDefault();
+ }
+ break;
+ }
+ },
+
+ _onDoubleClick(event) {
+ if (
+ event.button != 0 ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ return;
+ }
+ let row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ if (row) {
+ this._activateRow(row.index);
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * "Activate" the row by opening the corresponding card for editing. This will
+ * necessarily change the selection to the given index.
+ *
+ * @param {number} index - The index of the row to activate.
+ */
+ _activateRow(index) {
+ if (detailsPane.isEditing) {
+ return;
+ }
+ // Change selection to just the target.
+ this.cardsList.selectedIndex = index;
+ // We expect the selection to change the detailsPane immediately.
+ detailsPane.editCurrent();
+ },
+
+ _onDragStart(event) {
+ function makeMimeAddressFromCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ let email;
+ if (card.isMailList) {
+ let directory = MailServices.ab.getDirectory(card.mailListURI);
+ email = directory.description || card.displayName;
+ } else {
+ email = card.emailAddresses[0];
+ }
+ if (!email) {
+ return "";
+ }
+ return MailServices.headerParser.makeMimeAddress(card.displayName, email);
+ }
+
+ let row = event.target.closest(
+ `tr[is="ab-card-row"], tr[is="ab-table-card-row"]`
+ );
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let indices = this.cardsList.selectedIndices;
+ if (!indices.includes(row.index)) {
+ indices = [row.index];
+ }
+ let cards = indices.map(index => this.cardsList.view.getCardFromRow(index));
+
+ let addresses = cards.map(makeMimeAddressFromCard);
+ event.dataTransfer.mozSetDataAt("moz/abcard-array", cards, 0);
+ event.dataTransfer.setData("text/x-moz-address", addresses);
+ event.dataTransfer.setData("text/plain", addresses);
+
+ let card = this.cardsList.view.getCardFromRow(row.index);
+ if (card && card.displayName && !card.isMailList) {
+ try {
+ // A card implementation may throw NS_ERROR_NOT_IMPLEMENTED.
+ // Don't break drag-and-drop if that happens.
+ let vCard = card.translateTo("vcard");
+ event.dataTransfer.setData("text/vcard", decodeURIComponent(vCard));
+ event.dataTransfer.setData(
+ "application/x-moz-file-promise-dest-filename",
+ `${card.displayName}.vcf`.replace(/(.{74}).*(.{10})$/u, "$1...$2")
+ );
+ event.dataTransfer.setData(
+ "application/x-moz-file-promise-url",
+ "data:text/vcard," + vCard
+ );
+ event.dataTransfer.setData(
+ "application/x-moz-file-promise",
+ this._flavorDataProvider
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ event.dataTransfer.effectAllowed = "all";
+ let bcr = row.getBoundingClientRect();
+ event.dataTransfer.setDragImage(
+ row,
+ event.clientX - bcr.x,
+ event.clientY - bcr.y
+ );
+ },
+
+ _flavorDataProvider: {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(transferable, flavor, data) {
+ if (flavor == "application/x-moz-file-promise") {
+ let primitive = {};
+ transferable.getTransferData("text/vcard", primitive);
+ let vCard = primitive.value.QueryInterface(Ci.nsISupportsString).data;
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dest-filename",
+ primitive
+ );
+ let leafName = primitive.value.QueryInterface(
+ Ci.nsISupportsString
+ ).data;
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ primitive
+ );
+ let localFile = primitive.value.QueryInterface(Ci.nsIFile).clone();
+ localFile.append(leafName);
+
+ let ofStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ ofStream.init(localFile, -1, -1, 0);
+ let converter = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converter.init(ofStream, null);
+ converter.writeString(vCard);
+ converter.close();
+
+ data.value = localFile;
+ }
+ },
+ },
+};
+
+/**
+ * Object holding the contact view pane to show all vcard info and handle data
+ * changes and mutations between the view and edit state of a contact.
+ */
+var detailsPane = {
+ currentCard: null,
+
+ dirtyFields: new Set(),
+
+ _notifications: [
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-removed",
+ ],
+
+ init() {
+ let booksSplitter = document.getElementById("booksSplitter");
+ let booksSplitterWidth = Services.xulStore.getValue(
+ cardsPane.URL,
+ "booksSplitter",
+ "width"
+ );
+ if (booksSplitterWidth) {
+ booksSplitter.width = booksSplitterWidth;
+ }
+ booksSplitter.addEventListener("splitter-resized", () =>
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "booksSplitter",
+ "width",
+ booksSplitter.width
+ )
+ );
+
+ let isTableLayout = document.body.classList.contains("layout-table");
+ updateSharedSplitter(isTableLayout);
+
+ this.splitter = document.getElementById("sharedSplitter");
+ let sharedSplitterWidth = Services.xulStore.getValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "width"
+ );
+ if (sharedSplitterWidth) {
+ this.splitter.width = sharedSplitterWidth;
+ }
+ let sharedSplitterHeight = Services.xulStore.getValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "height"
+ );
+ if (sharedSplitterHeight) {
+ this.splitter.height = sharedSplitterHeight;
+ }
+ this.splitter.addEventListener("splitter-resized", () => {
+ if (isTableLayout) {
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "height",
+ this.splitter.height
+ );
+ return;
+ }
+ Services.xulStore.setValue(
+ cardsPane.URL,
+ "sharedSplitter",
+ "width",
+ this.splitter.width
+ );
+ });
+
+ this.node = document.getElementById("detailsPane");
+ this.actions = document.getElementById("detailsActions");
+ this.writeButton = document.getElementById("detailsWriteButton");
+ this.eventButton = document.getElementById("detailsEventButton");
+ this.searchButton = document.getElementById("detailsSearchButton");
+ this.newListButton = document.getElementById("detailsNewListButton");
+ this.editButton = document.getElementById("editButton");
+ this.selectedCardsSection = document.getElementById("selectedCards");
+ this.form = document.getElementById("editContactForm");
+ this.vCardEdit = this.form.querySelector("vcard-edit");
+ this.deleteButton = document.getElementById("detailsDeleteButton");
+ this.addContactBookList = document.getElementById("addContactBookList");
+ this.cancelEditButton = document.getElementById("cancelEditButton");
+ this.saveEditButton = document.getElementById("saveEditButton");
+
+ this.actions.addEventListener("click", this);
+ document.getElementById("detailsFooter").addEventListener("click", this);
+
+ let photoImage = document.getElementById("viewContactPhoto");
+ photoImage.addEventListener("error", event => {
+ if (!detailsPane.currentCard) {
+ return;
+ }
+
+ let vCard = detailsPane.currentCard.getProperty("_vCard", "");
+ let match = /^PHOTO.*/im.exec(vCard);
+ if (match) {
+ console.warn(
+ `Broken contact photo, vCard data starts with: ${match[0]}`
+ );
+ } else {
+ console.warn(`Broken contact photo, source is: ${photoImage.src}`);
+ }
+ });
+
+ this.form.addEventListener("input", event => {
+ let { type, checked, value, _originalValue } = event.target;
+ let changed;
+ if (type == "checkbox") {
+ changed = checked != _originalValue;
+ } else {
+ changed = value != _originalValue;
+ }
+ if (changed) {
+ this.dirtyFields.add(event.target);
+ } else {
+ this.dirtyFields.delete(event.target);
+ }
+
+ // If there are no dirty fields, clear the flag, otherwise set it.
+ this.isDirty = this.dirtyFields.size > 0;
+ });
+ this.form.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on a button or
+ // checkbox.
+ if (
+ event.key == " " &&
+ ["button", "checkbox"].includes(document.activeElement.type)
+ ) {
+ event.preventDefault();
+ }
+
+ if (event.key != "Escape") {
+ return;
+ }
+
+ event.preventDefault();
+ this.form.reset();
+ });
+ this.form.addEventListener("reset", async event => {
+ event.preventDefault();
+ if (this.isDirty) {
+ let [title, message] = await document.l10n.formatValues([
+ { id: `about-addressbook-unsaved-changes-prompt-title` },
+ { id: `about-addressbook-unsaved-changes-prompt` },
+ ]);
+
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPrompt.BUTTON_TITLE_SAVE * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE * Ci.nsIPrompt.BUTTON_POS_2,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (buttonPressed === 0) {
+ // Don't call this.form.submit, the submit event won't fire.
+ this.validateBeforeSaving();
+ return;
+ } else if (buttonPressed === 1) {
+ return;
+ }
+ }
+ this.isEditing = false;
+ if (this.currentCard) {
+ // Refresh the card from the book to get exactly what was saved.
+ let book = MailServices.ab.getDirectoryFromUID(
+ this.currentCard.directoryUID
+ );
+ let card = book.childCards.find(c => c.UID == this.currentCard.UID);
+ this.displayContact(card);
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ this.editButton.focus();
+ }
+ } else {
+ this.displayCards(cardsPane.selectedCards);
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ cardsPane.searchInput.focus();
+ }
+ }
+ });
+ this.form.addEventListener("submit", event => {
+ event.preventDefault();
+ this.validateBeforeSaving();
+ });
+
+ this.photoInput = document.getElementById("photoInput");
+ // NOTE: We put the paste handler on the button parent because the
+ // html:button will not be targeted by the paste event.
+ this.photoInput.addEventListener("paste", photoDialog);
+ this.photoInput.addEventListener("dragover", photoDialog);
+ this.photoInput.addEventListener("drop", photoDialog);
+
+ let photoButton = document.getElementById("photoButton");
+ photoButton.addEventListener("click", () => {
+ if (this._photoDetails.sourceURL) {
+ photoDialog.showWithURL(
+ this._photoDetails.sourceURL,
+ this._photoDetails.cropRect,
+ true
+ );
+ } else {
+ photoDialog.showEmpty();
+ }
+ });
+
+ this.cancelEditButton.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on this button.
+ if (event.key == " ") {
+ event.preventDefault();
+ }
+ });
+ this.saveEditButton.addEventListener("keypress", event => {
+ // Prevent scrolling of the html tag when space is used on this button.
+ if (event.key == " ") {
+ event.preventDefault();
+ }
+ });
+
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ },
+
+ uninit() {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ this._onClick(event);
+ break;
+ }
+ },
+
+ async observe(subject, topic, data) {
+ let hadFocus =
+ this.node.contains(document.activeElement) ||
+ document.activeElement == document.body;
+
+ switch (topic) {
+ case "addrbook-contact-created":
+ subject.QueryInterface(Ci.nsIAbCard);
+ updateAddressBookCount();
+ if (
+ !this.currentCard ||
+ this.currentCard.directoryUID != data ||
+ this.currentCard.UID != subject.getProperty("_originalUID", "")
+ ) {
+ break;
+ }
+
+ // The card being displayed had its UID changed by the server. Select
+ // the new card to display it. (If we're already editing the new card
+ // when the server responds, that's just tough luck.)
+ this.isEditing = false;
+ cardsPane.cardsList.selectedIndex =
+ cardsPane.cardsList.view.getIndexForUID(subject.UID);
+ break;
+ case "addrbook-contact-updated":
+ subject.QueryInterface(Ci.nsIAbCard);
+ if (
+ !this.currentCard ||
+ this.currentCard.directoryUID != data ||
+ !this.currentCard.equals(subject)
+ ) {
+ break;
+ }
+
+ // If there's editing in progress, we could attempt to update the
+ // editing interface with the changes, which is difficult, or alert
+ // the user. For now, changes will be overwritten if the edit is saved.
+
+ if (!this.isEditing) {
+ this.displayContact(subject);
+ }
+ break;
+ case "addrbook-contact-deleted":
+ case "addrbook-list-member-removed":
+ subject.QueryInterface(Ci.nsIAbCard);
+ updateAddressBookCount();
+
+ const directoryUID =
+ topic == "addrbook-contact-deleted"
+ ? this.currentCard?.directoryUID
+ : cardsPane.cardsList.view.directory?.UID;
+ if (directoryUID == data && this.currentCard?.equals(subject)) {
+ // The card being displayed was deleted.
+ this.isEditing = false;
+ this.displayCards();
+
+ if (hadFocus) {
+ // Ensure this happens *after* the view handles this notification.
+ Services.tm.dispatchToMainThread(() => {
+ if (cardsPane.cardsList.view.rowCount == 0) {
+ cardsPane.searchInput.focus();
+ } else {
+ cardsPane.cardsList.table.body.focus();
+ }
+ });
+ }
+ } else if (!this.selectedCardsSection.hidden) {
+ for (let li of this.selectedCardsSection.querySelectorAll("li")) {
+ if (li._card.equals(subject)) {
+ // A selected card was deleted.
+ this.displayCards(cardsPane.selectedCards);
+ break;
+ }
+ }
+ }
+ break;
+ case "addrbook-list-updated":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (this.currentList && this.currentList.mailListURI == subject.URI) {
+ this.displayList(this.currentList);
+ }
+ break;
+ case "addrbook-list-deleted":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ if (this.currentList && this.currentList.mailListURI == subject.URI) {
+ // The list being displayed was deleted.
+ this.displayCards();
+
+ if (hadFocus) {
+ if (cardsPane.cardsList.view.rowCount == 0) {
+ cardsPane.searchInput.focus();
+ } else {
+ cardsPane.cardsList.table.body.focus();
+ }
+ }
+ } else if (!this.selectedCardsSection.hidden) {
+ for (let li of this.selectedCardsSection.querySelectorAll("li")) {
+ if (
+ li._card.directoryUID == data &&
+ li._card.mailListURI == subject.URI
+ ) {
+ // A selected list was deleted.
+ this.displayCards(cardsPane.selectedCards);
+ break;
+ }
+ }
+ }
+ break;
+ }
+ },
+
+ /**
+ * Is a card being edited?
+ *
+ * @type {boolean}
+ */
+ get isEditing() {
+ return document.body.classList.contains("is-editing");
+ },
+
+ set isEditing(editing) {
+ if (editing == this.isEditing) {
+ return;
+ }
+
+ document.body.classList.toggle("is-editing", editing);
+
+ // Disable the toolbar buttons when starting to edit. Remember their state
+ // to restore it when editing stops.
+ for (let toolbarButton of document.querySelectorAll(
+ "#toolbox > toolbar > toolbarbutton"
+ )) {
+ if (editing) {
+ toolbarButton._wasDisabled = toolbarButton.disabled;
+ toolbarButton.disabled = true;
+ } else {
+ toolbarButton.disabled = toolbarButton._wasDisabled;
+ delete toolbarButton._wasDisabled;
+ }
+ }
+
+ // Remove these elements from (or add them back to) the tab focus cycle.
+ for (let id of ["books", "searchInput", "displayButton", "cardsBody"]) {
+ document.getElementById(id).tabIndex = editing ? -1 : 0;
+ }
+
+ if (editing) {
+ this.addContactBookList.hidden = !!this.currentCard;
+ this.addContactBookList.previousElementSibling.hidden =
+ !!this.currentCard;
+
+ let book = booksList
+ .getRowAtIndex(booksList.selectedIndex)
+ .closest(".bookRow")._book;
+ if (book) {
+ // TODO: convert this to UID.
+ this.addContactBookList.value = book.URI;
+ }
+ } else {
+ this.isDirty = false;
+ }
+ },
+
+ /**
+ * If a card is being edited, has any field changed?
+ *
+ * @type {boolean}
+ */
+ get isDirty() {
+ return this.isEditing && document.body.classList.contains("is-dirty");
+ },
+
+ set isDirty(dirty) {
+ if (!dirty) {
+ this.dirtyFields.clear();
+ }
+ document.body.classList.toggle("is-dirty", this.isEditing && dirty);
+ },
+
+ clearDisplay() {
+ this.currentCard = null;
+ this.currentList = null;
+
+ for (let section of document.querySelectorAll(
+ "#viewContact :is(.contact-header, .list-header, .selection-header), #detailsBody > section"
+ )) {
+ section.hidden = true;
+ }
+ },
+
+ displayCards(cards = []) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+
+ if (cards.length == 0) {
+ this.node.hidden = true;
+ this.splitter.isCollapsed =
+ document.body.classList.contains("layout-table");
+ return;
+ }
+ if (cards.length == 1) {
+ if (cards[0].isMailList) {
+ this.displayList(cards[0]);
+ } else {
+ this.displayContact(cards[0]);
+ }
+ return;
+ }
+
+ let contacts = cards.filter(c => !c.isMailList);
+ let contactsWithAddresses = contacts.filter(c => c.primaryEmail);
+ let lists = cards.filter(c => c.isMailList);
+
+ document.querySelector("#viewContact .selection-header").hidden = false;
+ let headerString;
+ if (contacts.length) {
+ if (lists.length) {
+ headerString = "about-addressbook-selection-mixed-header2";
+ } else {
+ headerString = "about-addressbook-selection-contacts-header2";
+ }
+ } else {
+ headerString = "about-addressbook-selection-lists-header2";
+ }
+ document.l10n.setAttributes(
+ document.getElementById("viewSelectionCount"),
+ headerString,
+ { count: cards.length }
+ );
+
+ this.writeButton.hidden = contactsWithAddresses.length + lists.length == 0;
+ this.eventButton.hidden =
+ !contactsWithAddresses.length ||
+ !cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar).length;
+ this.searchButton.hidden = true;
+ this.newListButton.hidden = contactsWithAddresses.length == 0;
+ this.editButton.hidden = true;
+
+ this.actions.hidden = this.writeButton.hidden;
+
+ let list = this.selectedCardsSection.querySelector("ul");
+ list.replaceChildren();
+ let template =
+ document.getElementById("selectedCard").content.firstElementChild;
+ for (let card of cards) {
+ let li = list.appendChild(template.cloneNode(true));
+ li._card = card;
+ let avatar = li.querySelector(".recipient-avatar");
+ let name = li.querySelector(".name");
+ let address = li.querySelector(".address");
+
+ if (!card.isMailList) {
+ name.textContent = card.generateName(ABView.nameFormat);
+ address.textContent = card.primaryEmail;
+
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = name.textContent;
+ img.src = photoURL;
+ avatar.appendChild(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(name.textContent)[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ avatar.appendChild(letter);
+ }
+ } else {
+ name.textContent = card.displayName;
+
+ let img = avatar.appendChild(document.createElement("img"));
+ img.alt = "";
+ img.src = "chrome://messenger/skin/icons/new/compact/user-list-alt.svg";
+ avatar.classList.add("is-mail-list");
+ }
+ }
+ this.selectedCardsSection.hidden = false;
+
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ /**
+ * Show a read-only representation of a card in the details pane.
+ *
+ * @param {nsIAbCard?} card - The card to display. This should not be a
+ * mailing list card. Pass null to hide the details pane.
+ */
+ displayContact(card) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+ if (!card || card.isMailList) {
+ return;
+ }
+ this.currentCard = card;
+
+ this.fillContactDetails(document.getElementById("viewContact"), card);
+ document.getElementById("viewContactPhoto").hidden = document.querySelector(
+ "#viewContact .contact-headings"
+ ).hidden = false;
+ document.querySelector("#viewContact .contact-header").hidden = false;
+
+ this.writeButton.hidden = this.searchButton.hidden = !card.primaryEmail;
+ this.eventButton.hidden =
+ !card.primaryEmail ||
+ !cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar).length;
+ this.newListButton.hidden = true;
+
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ this.editButton.hidden = book.readOnly;
+ this.actions.hidden = this.writeButton.hidden && this.editButton.hidden;
+
+ this.isEditing = false;
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ /**
+ * Set all the values for displaying a contact.
+ *
+ * @param {HTMLElement} element - The element to fill, either the on-screen
+ * contact display or a clone of the printing template.
+ * @param {nsIAbCard} card - The card to display. This should not be a
+ * mailing list card.
+ */
+ fillContactDetails(element, card) {
+ let vCardProperties = card.supportsVCard
+ ? card.vCardProperties
+ : VCardProperties.fromPropertyMap(
+ new Map(card.properties.map(p => [p.name, p.value]))
+ );
+
+ element.querySelector(".contact-photo").src =
+ card.photoURL || "chrome://messenger/skin/icons/new/compact/user.svg";
+ element.querySelector(".contact-heading-name").textContent =
+ card.generateName(ABView.nameFormat);
+ let nickname = element.querySelector(".contact-heading-nickname");
+ let nicknameValue = vCardProperties.getFirstValue("nickname");
+ nickname.hidden = !nicknameValue;
+ nickname.textContent = nicknameValue;
+ element.querySelector(".contact-heading-email").textContent =
+ card.primaryEmail;
+
+ let template = document.getElementById("entryItem");
+ let createEntryItem = function (name) {
+ let li = template.content.firstElementChild.cloneNode(true);
+ if (name) {
+ document.l10n.setAttributes(
+ li.querySelector(".entry-type"),
+ `about-addressbook-entry-name-${name}`
+ );
+ }
+ return li;
+ };
+ let setEntryType = function (li, entry, allowed = ["work", "home"]) {
+ if (!entry.params.type) {
+ return;
+ }
+ let lowerTypes = Array.isArray(entry.params.type)
+ ? entry.params.type.map(t => t.toLowerCase())
+ : [entry.params.type.toLowerCase()];
+ let lowerType = lowerTypes.find(t => allowed.includes(t));
+ if (!lowerType) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ li.querySelector(".entry-type"),
+ `about-addressbook-entry-type-${lowerType}`
+ );
+ };
+
+ let section = element.querySelector(".details-email-addresses");
+ let list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("email")) {
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let addr = MailServices.headerParser.makeMimeAddress(
+ card.displayName,
+ entry.value
+ );
+ let a = document.createElement("a");
+ a.href = "mailto:" + encodeURIComponent(addr);
+ a.textContent = entry.value;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-phone-numbers");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("tel")) {
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry, ["work", "home", "fax", "cell", "pager"]);
+ let a = document.createElement("a");
+ // Handle tel: uri, some other scheme, or plain text number.
+ let number = entry.value.replace(/^[a-z\+]{3,}:/, "");
+ let scheme = entry.value.split(/([a-z\+]{3,}):/)[1] || "tel";
+ a.href = `${scheme}:${number.replaceAll(/[^\d\+]/g, "")}`;
+ a.textContent = number;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-addresses");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+ for (let entry of vCardProperties.getAllEntries("adr")) {
+ let parts = entry.value.flat();
+ // Put extended address after street address.
+ parts[2] = parts.splice(1, 1, parts[2])[0];
+
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let span = li.querySelector(".entry-value");
+ for (let part of parts.filter(Boolean)) {
+ if (span.firstChild) {
+ span.appendChild(document.createElement("br"));
+ }
+ span.appendChild(document.createTextNode(part));
+ }
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-notes");
+ let note = vCardProperties.getFirstValue("note");
+ if (note) {
+ section.querySelector("div").textContent = note;
+ section.hidden = false;
+ } else {
+ section.hidden = true;
+ }
+
+ section = element.querySelector(".details-websites");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ for (let entry of vCardProperties.getAllEntries("url")) {
+ let value = entry.value;
+ if (!/https?:\/\//.test(value)) {
+ continue;
+ }
+
+ let li = list.appendChild(createEntryItem());
+ setEntryType(li, entry);
+ let a = document.createElement("a");
+ a.href = value;
+ let url = new URL(value);
+ a.textContent =
+ url.pathname == "/" && !url.search
+ ? url.host
+ : `${url.host}${url.pathname}${url.search}`;
+ li.querySelector(".entry-value").appendChild(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-instant-messaging");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ this._screenNamesToIMPPs(card);
+ for (let entry of vCardProperties.getAllEntries("impp")) {
+ let li = list.appendChild(createEntryItem());
+ let url;
+ try {
+ url = new URL(entry.value);
+ } catch (e) {
+ li.querySelector(".entry-value").textContent = entry.value;
+ continue;
+ }
+ let a = document.createElement("a");
+ a.href = entry.value;
+ a.target = "_blank";
+ a.textContent = url.toString();
+ li.querySelector(".entry-value").append(a);
+ }
+ section.hidden = list.childElementCount == 0;
+
+ section = element.querySelector(".details-other-info");
+ list = section.querySelector("ul");
+ list.replaceChildren();
+
+ let formatDate = function (date) {
+ try {
+ date = ICAL.VCardTime.fromDateAndOrTimeString(date);
+ } catch (ex) {
+ console.error(ex);
+ return "";
+ }
+ if (date.year && date.month && date.day) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(new Date(date.year, date.month - 1, date.day));
+ }
+ if (date.year && date.month) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ }).format(new Date(date.year, date.month - 1, 1));
+ }
+ if (date.year) {
+ return date.year;
+ }
+ if (date.month && date.day) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ month: "long",
+ day: "numeric",
+ }).format(new Date(2024, date.month - 1, date.day));
+ }
+ if (date.month) {
+ return new Services.intl.DateTimeFormat(undefined, {
+ month: "long",
+ }).format(new Date(2024, date.month - 1, 1));
+ }
+ if (date.day) {
+ return date.day;
+ }
+ return "";
+ };
+
+ let bday = vCardProperties.getFirstValue("bday");
+ if (bday) {
+ let value = formatDate(bday);
+ if (value) {
+ let li = list.appendChild(createEntryItem("birthday"));
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ let anniversary = vCardProperties.getFirstValue("anniversary");
+ if (anniversary) {
+ let value = formatDate(anniversary);
+ if (value) {
+ let li = list.appendChild(createEntryItem("anniversary"));
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ let title = vCardProperties.getFirstValue("title");
+ if (title) {
+ let li = list.appendChild(createEntryItem("title"));
+ li.querySelector(".entry-value").textContent = title;
+ }
+
+ let role = vCardProperties.getFirstValue("role");
+ if (role) {
+ let li = list.appendChild(createEntryItem("role"));
+ li.querySelector(".entry-value").textContent = role;
+ }
+
+ let org = vCardProperties.getFirstValue("org");
+ if (Array.isArray(org)) {
+ let li = list.appendChild(createEntryItem("organization"));
+ let span = li.querySelector(".entry-value");
+ for (let part of org.filter(Boolean).reverse()) {
+ if (span.firstChild) {
+ span.append(" • ");
+ }
+ span.appendChild(document.createTextNode(part));
+ }
+ } else if (org) {
+ let li = list.appendChild(createEntryItem("organization"));
+ li.querySelector(".entry-value").textContent = org;
+ }
+
+ let tz = vCardProperties.getFirstValue("tz");
+ if (tz) {
+ let li = list.appendChild(createEntryItem("time-zone"));
+ try {
+ li.querySelector(".entry-value").textContent =
+ cal.timezoneService.getTimezone(tz).displayName;
+ } catch {
+ li.querySelector(".entry-value").textContent = tz;
+ }
+ li.querySelector(".entry-value").appendChild(
+ document.createElement("br")
+ );
+
+ let time = document.createElement("span", { is: "active-time" });
+ time.setAttribute("tz", tz);
+ li.querySelector(".entry-value").appendChild(time);
+ }
+
+ for (let key of ["custom1", "custom2", "custom3", "custom4"]) {
+ let value = vCardProperties.getFirstValue(`x-${key}`);
+ if (value) {
+ let li = list.appendChild(createEntryItem(key));
+ li.querySelector(".entry-type").style.setProperty(
+ "white-space",
+ "nowrap"
+ );
+ li.querySelector(".entry-value").textContent = value;
+ }
+ }
+
+ section.hidden = list.childElementCount == 0;
+ },
+
+ /**
+ * Show this given contact photo in the edit form.
+ *
+ * @param {?string} url - The URL of the photo to display, or null to
+ * display none.
+ */
+ showEditPhoto(url) {
+ this.photoInput.querySelector(".contact-photo").src =
+ url || "chrome://messenger/skin/icons/new/compact/user.svg";
+ },
+
+ /**
+ * Store the given photo details to save later, and display the photo in the
+ * edit form.
+ *
+ * @param {?object} details - The photo details to save, or null to remove the
+ * photo.
+ * @param {Blob} details.blob - The image blob of the photo to save.
+ * @param {string} details.sourceURL - The image basis of the photo, before
+ * cropping.
+ * @param {DOMRect} details.cropRect - The cropping rectangle for the photo.
+ */
+ setPhoto(details) {
+ this._photoChanged = true;
+ this._photoDetails = details || {};
+ this.showEditPhoto(
+ details?.blob ? URL.createObjectURL(details.blob) : null
+ );
+ this.dirtyFields.add(this.photoInput);
+ this.isDirty = true;
+ },
+
+ /**
+ * Show controls for editing a new card.
+ *
+ * @param {?string} vCard - A vCard containing properties for the new card.
+ */
+ async editNewContact(vCard) {
+ this.currentCard = null;
+ this.editCurrentContact(vCard);
+ if (!vCard) {
+ this.vCardEdit.contactNameHeading.textContent =
+ await document.l10n.formatValue("about-addressbook-new-contact-header");
+ }
+ },
+
+ /**
+ * Takes old nsIAbCard chat names and put them on the card as IMPP URIs.
+ *
+ * @param {nsIAbCard?} card - The card to change.
+ */
+ _screenNamesToIMPPs(card) {
+ if (!card.supportsVCard) {
+ return;
+ }
+
+ let existingIMPPValues = card.vCardProperties.getAllValues("impp");
+ for (let key of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ let value = card.getProperty(key, "");
+ if (!value) {
+ continue;
+ }
+ switch (key) {
+ case "_GoogleTalk":
+ value = `gtalk:chat?jid=${value}`;
+ break;
+ case "_AimScreenName":
+ value = `aim:goim?screenname=${value}`;
+ break;
+ case "_Yahoo":
+ value = `ymsgr:sendIM?${value}`;
+ break;
+ case "_Skype":
+ value = `skype:${value}`;
+ break;
+ case "_QQ":
+ value = `mqq://${value}`;
+ break;
+ case "_MSN":
+ value = `msnim:chat?contact=${value}`;
+ break;
+ case "_ICQ":
+ value = `icq:message?uin=${value}`;
+ break;
+ case "_JabberId":
+ value = `xmpp:${value}`;
+ break;
+ case "_IRC":
+ // Guess host, in case we have an irc account configured.
+ let host =
+ IMServices.accounts
+ .getAccounts()
+ .find(a => a.protocol.normalizedName == "irc")
+ ?.name.split("@", 2)[1] || "irc.example.org";
+ value = `ircs://${host}/${value},isuser`;
+ break;
+ }
+ if (!existingIMPPValues.includes(value)) {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry(`impp`, {}, "uri", value)
+ );
+ }
+ }
+ },
+
+ /**
+ * Show controls for editing the currently displayed card.
+ *
+ * @param {?string} vCard - A vCard containing properties for a new card.
+ */
+ editCurrentContact(vCard) {
+ let card = this.currentCard;
+ this.deleteButton.hidden = !card;
+ if (card && card.supportsVCard) {
+ this._screenNamesToIMPPs(card);
+
+ this.vCardEdit.vCardProperties = card.vCardProperties;
+ // getProperty may return a "1" or "0" string, we want a boolean.
+ this.vCardEdit.preferDisplayName.checked =
+ // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
+ card.getProperty("PreferDisplayName", true) == true;
+ } else {
+ this.vCardEdit.vCardString = vCard ?? "";
+ card = new AddrBookCard();
+ card.setProperty("_vCard", vCard);
+ }
+
+ this.showEditPhoto(card?.photoURL);
+ this._photoDetails = { sourceURL: card?.photoURL };
+ this._photoChanged = false;
+ this.isEditing = true;
+ this.node.hidden = this.splitter.isCollapsed = false;
+ this.form.querySelector(".contact-details-scroll").scrollTo(0, 0);
+ // If we enter editing directly from the cards list we want to return to it
+ // once we are done.
+ this._focusOnCardsList =
+ document.activeElement == cardsPane.cardsList.table.body;
+ this.vCardEdit.setFocus();
+ },
+
+ /**
+ * Edit the currently displayed contact or list.
+ */
+ editCurrent() {
+ // The editButton is disabled if the book is readOnly.
+ if (this.editButton.hidden) {
+ return;
+ }
+ if (this.currentCard) {
+ this.editCurrentContact();
+ } else if (this.currentList) {
+ SubDialog.open(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml",
+ { features: "resizable=no" },
+ { listURI: this.currentList.mailListURI }
+ );
+ }
+ },
+
+ /**
+ * Properly handle a failed form validation.
+ */
+ handleInvalidForm() {
+ // FIXME: Drop this in favor of an inline notification with fluent strings.
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ Services.prompt.alert(
+ window,
+ bundle.GetStringFromName("cardRequiredDataMissingTitle"),
+ bundle.GetStringFromName("cardRequiredDataMissingMessage")
+ );
+ },
+
+ /**
+ * Make sure the data is valid before saving the contact.
+ */
+ validateBeforeSaving() {
+ // Make sure the minimum required data is present.
+ if (!this.vCardEdit.checkMinimumRequirements()) {
+ this.handleInvalidForm();
+ return;
+ }
+
+ // Make sure the dates are filled properly.
+ if (!this.vCardEdit.validateDates()) {
+ // Simply return as the validateDates() will handle focus and visual cue.
+ return;
+ }
+
+ // Extra validation for any form field that has validatity requirements
+ // set on them (through pattern etc.).
+ if (!this.form.checkValidity()) {
+ this.form.querySelector("input:invalid").focus();
+ return;
+ }
+
+ this.saveCurrentContact();
+ },
+
+ /**
+ * Save the currently displayed card.
+ */
+ async saveCurrentContact() {
+ let card = this.currentCard;
+ let book;
+
+ if (card) {
+ book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+ } else {
+ card = new AddrBookCard();
+
+ // TODO: convert this to UID.
+ book = MailServices.ab.getDirectory(this.addContactBookList.value);
+ if (book.getBoolValue("carddav.vcard3", false)) {
+ // This is a CardDAV book, and the server discards photos unless the
+ // vCard 3 format is used. Since we know this is a new card, setting
+ // the version here won't cause a problem.
+ this.vCardEdit.vCardProperties.addValue("version", "3.0");
+ }
+ }
+ if (!book || book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ // Tell vcard-edit to read the input fields. Setting the _vCard property
+ // MUST happen before accessing `card.vCardProperties` or creating new
+ // cards will fail.
+ this.vCardEdit.saveVCard();
+ card.setProperty("_vCard", this.vCardEdit.vCardString);
+ card.setProperty(
+ "PreferDisplayName",
+ this.vCardEdit.preferDisplayName.checked
+ );
+
+ // Old screen names should by now be on the vCard. Delete them.
+ for (let key of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ card.deleteProperty(key);
+ }
+
+ // No photo or a new photo. Delete the old one.
+ if (this._photoChanged) {
+ let oldLeafName = card.getProperty("PhotoName", "");
+ if (oldLeafName) {
+ let oldPath = PathUtils.join(
+ PathUtils.profileDir,
+ "Photos",
+ oldLeafName
+ );
+ await IOUtils.remove(oldPath);
+
+ card.setProperty("PhotoName", "");
+ card.setProperty("PhotoType", "");
+ card.setProperty("PhotoURI", "");
+ }
+ if (card.supportsVCard) {
+ for (let entry of card.vCardProperties.getAllEntries("photo")) {
+ card.vCardProperties.removeEntry(entry);
+ }
+ }
+ }
+
+ // Save the new photo.
+ if (this._photoChanged && this._photoDetails.blob) {
+ if (book.dirType == Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE) {
+ let reader = new FileReader();
+ await new Promise(resolve => {
+ reader.onloadend = resolve;
+ reader.readAsDataURL(this._photoDetails.blob);
+ });
+ if (card.vCardProperties.getFirstValue("version") == "4.0") {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry("photo", {}, "uri", reader.result)
+ );
+ } else {
+ card.vCardProperties.addEntry(
+ new VCardPropertyEntry(
+ "photo",
+ { encoding: "B" },
+ "binary",
+ reader.result.substring(reader.result.indexOf(",") + 1)
+ )
+ );
+ }
+ } else {
+ let leafName = `${AddrBookUtils.newUID()}.jpg`;
+ let path = PathUtils.join(PathUtils.profileDir, "Photos", leafName);
+ let buffer = await this._photoDetails.blob.arrayBuffer();
+ await IOUtils.write(path, new Uint8Array(buffer));
+ card.setProperty("PhotoName", leafName);
+ }
+ }
+ this._photoChanged = false;
+ this.isEditing = false;
+
+ if (!card.directoryUID) {
+ card = book.addCard(card);
+ cardsPane.cardsList.selectedIndex =
+ cardsPane.cardsList.view.getIndexForUID(card.UID);
+ // The selection change will update the UI.
+ } else {
+ book.modifyCard(card);
+ // The addrbook-contact-updated notification will update the UI.
+ }
+
+ if (this._focusOnCardsList) {
+ cardsPane.cardsList.table.body.focus();
+ } else {
+ this.editButton.focus();
+ }
+ },
+
+ /**
+ * Delete the currently displayed card.
+ */
+ async deleteCurrentContact() {
+ let card = this.currentCard;
+ let book = MailServices.ab.getDirectoryFromUID(card.directoryUID);
+
+ if (!book) {
+ throw new Components.Exception(
+ "Card doesn't have a book to delete from",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (book.readOnly) {
+ throw new Components.Exception(
+ "Address book is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ let name = card.displayName;
+ let [title, message] = await document.l10n.formatValues([
+ {
+ id: "about-addressbook-confirm-delete-contacts-title",
+ args: { count: 1 },
+ },
+ {
+ id: "about-addressbook-confirm-delete-contacts-single",
+ args: { name },
+ },
+ ]);
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ ) === 0
+ ) {
+ // TODO: Setting the index should be unnecessary.
+ let indexAfterDelete = cardsPane.cardsList.currentIndex;
+ book.deleteCards([card]);
+ cardsPane.cardsList.currentIndex = Math.min(
+ indexAfterDelete,
+ cardsPane.cardsList.view.rowCount - 1
+ );
+ // The addrbook-contact-deleted notification will update the details pane UI.
+ }
+ },
+
+ displayList(listCard) {
+ if (this.isEditing) {
+ return;
+ }
+
+ this.clearDisplay();
+ if (!listCard || !listCard.isMailList) {
+ return;
+ }
+ this.currentList = listCard;
+
+ let listDirectory = MailServices.ab.getDirectory(listCard.mailListURI);
+
+ document.querySelector("#viewContact .list-header").hidden = false;
+ document.querySelector(
+ "#viewContact .list-header > h1"
+ ).textContent = `${listDirectory.dirName}`;
+
+ let cards = Array.from(listDirectory.childCards, card => {
+ return {
+ name: card.generateName(ABView.nameFormat),
+ email: card.primaryEmail,
+ photoURL: card.photoURL,
+ };
+ });
+ let { sortColumn, sortDirection } = cardsPane.cardsList.view;
+ let key = sortColumn == "EmailAddresses" ? "email" : "name";
+ cards.sort((a, b) => {
+ if (sortDirection == "descending") {
+ [b, a] = [a, b];
+ }
+ return ABView.prototype.collator.compare(a[key], b[key]);
+ });
+
+ let list = this.selectedCardsSection.querySelector("ul");
+ list.replaceChildren();
+ let template =
+ document.getElementById("selectedCard").content.firstElementChild;
+ for (let card of cards) {
+ let li = list.appendChild(template.cloneNode(true));
+ li._card = card;
+ let avatar = li.querySelector(".recipient-avatar");
+ let name = li.querySelector(".name");
+ let address = li.querySelector(".address");
+ name.textContent = card.name;
+ address.textContent = card.email;
+
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ img.alt = name.textContent;
+ img.src = photoURL;
+ avatar.appendChild(img);
+ } else {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(name.textContent)[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ avatar.appendChild(letter);
+ }
+ }
+ this.selectedCardsSection.hidden = list.childElementCount == 0;
+
+ let book = MailServices.ab.getDirectoryFromUID(listCard.directoryUID);
+ this.writeButton.hidden = list.childElementCount == 0;
+ this.eventButton.hidden = this.writeButton.hidden;
+ this.searchButton.hidden = true;
+ this.newListButton.hidden = true;
+ this.editButton.hidden = book.readOnly;
+
+ this.actions.hidden = this.writeButton.hidden && this.editButton.hidden;
+
+ this.node.hidden = this.splitter.isCollapsed = false;
+ document.getElementById("viewContact").scrollTo(0, 0);
+ },
+
+ _onClick(event) {
+ let selectedContacts = cardsPane.selectedCards.filter(
+ card => !card.isMailList && card.primaryEmail
+ );
+
+ switch (event.target.id) {
+ case "detailsWriteButton":
+ cardsPane.writeToSelected();
+ break;
+ case "detailsEventButton": {
+ let contacts;
+ if (this.currentList) {
+ let directory = MailServices.ab.getDirectory(
+ this.currentList.mailListURI
+ );
+ contacts = directory.childCards;
+ } else {
+ contacts = selectedContacts;
+ }
+ let attendees = contacts.map(card => {
+ let attendee = new CalAttendee();
+ attendee.id = `mailto:${card.primaryEmail}`;
+ attendee.commonName = card.displayName;
+ return attendee;
+ });
+ if (attendees.length) {
+ window.browsingContext.topChromeWindow.createEventWithDialog(
+ null,
+ null,
+ null,
+ null,
+ null,
+ false,
+ attendees
+ );
+ }
+ break;
+ }
+ case "detailsSearchButton":
+ if (this.currentCard.primaryEmail) {
+ let searchString = this.currentCard.emailAddresses.join(" ");
+ window.browsingContext.topChromeWindow.tabmail.openTab("glodaFacet", {
+ searcher: new GlodaMsgSearcher(null, searchString, false),
+ });
+ }
+ break;
+ case "detailsNewListButton":
+ if (selectedContacts.length) {
+ createList(selectedContacts);
+ }
+ break;
+ case "editButton":
+ this.editCurrent();
+ break;
+ case "detailsDeleteButton":
+ this.deleteCurrentContact();
+ break;
+ }
+ },
+};
+
+var photoDialog = {
+ /**
+ * The ratio of pixels in the source image to pixels in the preview.
+ *
+ * @type {number}
+ */
+ _scale: null,
+
+ /**
+ * The square to which the image will be cropped, in preview pixels.
+ *
+ * @type {DOMRect}
+ */
+ _cropRect: null,
+
+ /**
+ * The bounding rectangle of the image in the preview, in preview pixels.
+ * Cached for efficiency.
+ *
+ * @type {DOMRect}
+ */
+ _previewRect: null,
+
+ init() {
+ this._dialog = document.getElementById("photoDialog");
+ this._dialog.saveButton = this._dialog.querySelector(".accept");
+ this._dialog.cancelButton = this._dialog.querySelector(".cancel");
+ this._dialog.discardButton = this._dialog.querySelector(".extra1");
+
+ this._dropTarget = this._dialog.querySelector("#photoDropTarget");
+ this._svg = this._dialog.querySelector("svg");
+ this._preview = this._svg.querySelector("image");
+ this._cropMask = this._svg.querySelector("path");
+ this._dragRect = this._svg.querySelector("rect");
+ this._corners = this._svg.querySelectorAll("rect.corner");
+
+ this._dialog.addEventListener("dragover", this);
+ this._dialog.addEventListener("drop", this);
+ this._dialog.addEventListener("paste", this);
+ this._dropTarget.addEventListener("click", event => {
+ if (event.button != 0) {
+ return;
+ }
+ this._showFilePicker();
+ });
+ this._dropTarget.addEventListener("keydown", event => {
+ if (event.key != " " && event.key != "Enter") {
+ return;
+ }
+ this._showFilePicker();
+ });
+
+ class Mover {
+ constructor(element) {
+ element.addEventListener("mousedown", this);
+ }
+
+ handleEvent(event) {
+ if (event.type == "mousedown") {
+ if (event.buttons != 1) {
+ return;
+ }
+ this.onMouseDown(event);
+ window.addEventListener("mousemove", this);
+ window.addEventListener("mouseup", this);
+ } else if (event.type == "mousemove") {
+ if (event.buttons != 1) {
+ // The button was released and we didn't get a mouseup event, or the
+ // button(s) pressed changed. Either way, stop dragging.
+ this.onMouseUp();
+ return;
+ }
+ this.onMouseMove(event);
+ } else {
+ this.onMouseUp(event);
+ }
+ }
+
+ onMouseUp(event) {
+ delete this._dragPosition;
+ window.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseup", this);
+ }
+ }
+
+ new (class extends Mover {
+ onMouseDown(event) {
+ this._dragPosition = {
+ x: event.clientX - photoDialog._cropRect.x,
+ y: event.clientY - photoDialog._cropRect.y,
+ };
+ }
+
+ onMouseMove(event) {
+ photoDialog._cropRect.x = Math.min(
+ Math.max(0, event.clientX - this._dragPosition.x),
+ photoDialog._previewRect.width - photoDialog._cropRect.width
+ );
+ photoDialog._cropRect.y = Math.min(
+ Math.max(0, event.clientY - this._dragPosition.y),
+ photoDialog._previewRect.height - photoDialog._cropRect.height
+ );
+ photoDialog._redrawCropRect();
+ }
+ })(this._dragRect);
+
+ class CornerMover extends Mover {
+ constructor(element, xEdge, yEdge) {
+ super(element);
+ this.xEdge = xEdge;
+ this.yEdge = yEdge;
+ }
+
+ onMouseDown(event) {
+ this._dragPosition = {
+ x: event.clientX - photoDialog._cropRect[this.xEdge],
+ y: event.clientY - photoDialog._cropRect[this.yEdge],
+ };
+ }
+
+ onMouseMove(event) {
+ let { width, height } = photoDialog._previewRect;
+ let { top, right, bottom, left } = photoDialog._cropRect;
+ let { x, y } = this._dragPosition;
+
+ // New coordinates of the dragged corner, constrained to the image size.
+ x = Math.max(0, Math.min(width, event.clientX - x));
+ y = Math.max(0, Math.min(height, event.clientY - y));
+
+ // New size based on the dragged corner and a minimum size of 80px.
+ let newWidth = this.xEdge == "right" ? x - left : right - x;
+ let newHeight = this.yEdge == "bottom" ? y - top : bottom - y;
+ let newSize = Math.max(80, Math.min(newWidth, newHeight));
+
+ photoDialog._cropRect.width = newSize;
+ if (this.xEdge == "left") {
+ photoDialog._cropRect.x = right - photoDialog._cropRect.width;
+ }
+ photoDialog._cropRect.height = newSize;
+ if (this.yEdge == "top") {
+ photoDialog._cropRect.y = bottom - photoDialog._cropRect.height;
+ }
+ photoDialog._redrawCropRect();
+ }
+ }
+
+ new CornerMover(this._corners[0], "left", "top");
+ new CornerMover(this._corners[1], "right", "top");
+ new CornerMover(this._corners[2], "right", "bottom");
+ new CornerMover(this._corners[3], "left", "bottom");
+
+ this._dialog.saveButton.addEventListener("click", () => this._save());
+ this._dialog.cancelButton.addEventListener("click", () => this._cancel());
+ this._dialog.discardButton.addEventListener("click", () => this._discard());
+ },
+
+ _setState(state) {
+ if (state == "preview") {
+ this._dropTarget.hidden = true;
+ this._svg.toggleAttribute("hidden", false);
+ this._dialog.saveButton.disabled = false;
+ return;
+ }
+
+ this._dropTarget.classList.toggle("drop-target", state == "target");
+ this._dropTarget.classList.toggle("drop-loading", state == "loading");
+ this._dropTarget.classList.toggle("drop-error", state == "error");
+ document.l10n.setAttributes(
+ this._dropTarget.querySelector(".label"),
+ `about-addressbook-photo-drop-${state}`
+ );
+
+ this._dropTarget.hidden = false;
+ this._svg.toggleAttribute("hidden", true);
+ this._dialog.saveButton.disabled = true;
+ },
+
+ /**
+ * Show the photo dialog, with no displayed image.
+ */
+ showEmpty() {
+ this._setState("target");
+
+ if (!this._dialog.open) {
+ this._dialog.discardButton.hidden = true;
+ this._dialog.showModal();
+ }
+ },
+
+ /**
+ * Show the photo dialog, with `file` as the displayed image.
+ *
+ * @param {File} file
+ */
+ showWithFile(file) {
+ this.showWithURL(URL.createObjectURL(file));
+ },
+
+ /**
+ * Show the photo dialog, with `URL` as the displayed image and (optionally)
+ * a pre-set crop rectangle
+ *
+ * @param {string} url - The URL of the image.
+ * @param {?DOMRect} cropRect - The rectangle used to crop the image.
+ * @param {boolean} [showDiscard=false] - Whether to show a discard button
+ * when opening the dialog.
+ */
+ showWithURL(url, cropRect, showDiscard = false) {
+ // Load the image from the URL, to figure out the scale factor.
+ let img = document.createElement("img");
+ img.addEventListener("load", () => {
+ const PREVIEW_SIZE = 500;
+
+ let { naturalWidth, naturalHeight } = img;
+ this._scale = Math.max(
+ 1,
+ img.naturalWidth / PREVIEW_SIZE,
+ img.naturalHeight / PREVIEW_SIZE
+ );
+
+ let previewWidth = naturalWidth / this._scale;
+ let previewHeight = naturalHeight / this._scale;
+ let smallDimension = Math.min(previewWidth, previewHeight);
+
+ this._previewRect = new DOMRect(0, 0, previewWidth, previewHeight);
+ if (cropRect) {
+ this._cropRect = DOMRect.fromRect(cropRect);
+ } else {
+ this._cropRect = new DOMRect(
+ (this._previewRect.width - smallDimension) / 2,
+ (this._previewRect.height - smallDimension) / 2,
+ smallDimension,
+ smallDimension
+ );
+ }
+
+ this._preview.setAttribute("href", url);
+ this._preview.setAttribute("width", previewWidth);
+ this._preview.setAttribute("height", previewHeight);
+
+ this._svg.setAttribute("width", previewWidth + 20);
+ this._svg.setAttribute("height", previewHeight + 20);
+ this._svg.setAttribute(
+ "viewBox",
+ `-10 -10 ${previewWidth + 20} ${previewHeight + 20}`
+ );
+
+ this._redrawCropRect();
+ this._setState("preview");
+ this._dialog.saveButton.focus();
+ });
+ img.addEventListener("error", () => this._setState("error"));
+ img.src = url;
+
+ this._setState("loading");
+
+ if (!this._dialog.open) {
+ this._dialog.discardButton.hidden = !showDiscard;
+ this._dialog.showModal();
+ }
+ },
+
+ /**
+ * Resize the crop controls to match the current _cropRect.
+ */
+ _redrawCropRect() {
+ let { top, right, bottom, left, width, height } = this._cropRect;
+
+ this._cropMask.setAttribute(
+ "d",
+ `M0 0H${this._previewRect.width}V${this._previewRect.height}H0Z M${left} ${top}V${bottom}H${right}V${top}Z`
+ );
+
+ this._dragRect.setAttribute("x", left);
+ this._dragRect.setAttribute("y", top);
+ this._dragRect.setAttribute("width", width);
+ this._dragRect.setAttribute("height", height);
+
+ this._corners[0].setAttribute("x", left - 10);
+ this._corners[0].setAttribute("y", top - 10);
+ this._corners[1].setAttribute("x", right - 30);
+ this._corners[1].setAttribute("y", top - 10);
+ this._corners[2].setAttribute("x", right - 30);
+ this._corners[2].setAttribute("y", bottom - 30);
+ this._corners[3].setAttribute("x", left - 10);
+ this._corners[3].setAttribute("y", bottom - 30);
+ },
+
+ /**
+ * Crop, shrink, convert the image to a JPEG, then assign it to the photo
+ * element and close the dialog. Doesn't save the JPEG to disk, that happens
+ * when (if) the contact is saved.
+ */
+ async _save() {
+ const DOUBLE_SIZE = 600;
+ const FINAL_SIZE = 300;
+
+ let source = this._preview;
+ let { x, y, width, height } = this._cropRect;
+ x *= this._scale;
+ y *= this._scale;
+ width *= this._scale;
+ height *= this._scale;
+
+ // If the image is much larger than our target size, draw an intermediate
+ // version at twice the size first. This produces better-looking results.
+ if (width > DOUBLE_SIZE) {
+ let canvas1 = document.createElement("canvas");
+ canvas1.width = canvas1.height = DOUBLE_SIZE;
+ let context1 = canvas1.getContext("2d");
+ context1.drawImage(
+ source,
+ x,
+ y,
+ width,
+ height,
+ 0,
+ 0,
+ DOUBLE_SIZE,
+ DOUBLE_SIZE
+ );
+
+ source = canvas1;
+ x = y = 0;
+ width = height = DOUBLE_SIZE;
+ }
+
+ let canvas2 = document.createElement("canvas");
+ canvas2.width = canvas2.height = FINAL_SIZE;
+ let context2 = canvas2.getContext("2d");
+ context2.drawImage(
+ source,
+ x,
+ y,
+ width,
+ height,
+ 0,
+ 0,
+ FINAL_SIZE,
+ FINAL_SIZE
+ );
+
+ let blob = await new Promise(resolve =>
+ canvas2.toBlob(resolve, "image/jpeg")
+ );
+
+ detailsPane.setPhoto({
+ blob,
+ sourceURL: this._preview.getAttribute("href"),
+ cropRect: DOMRect.fromRect(this._cropRect),
+ });
+
+ this._dialog.close();
+ },
+
+ /**
+ * Just close the dialog.
+ */
+ _cancel() {
+ this._dialog.close();
+ },
+
+ /**
+ * Throw away the contact's existing photo, and close the dialog. Doesn't
+ * remove the existing photo from disk, that happens when (if) the contact
+ * is saved.
+ */
+ _discard() {
+ this._dialog.close();
+ detailsPane.setPhoto(null);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "paste":
+ this._onPaste(event);
+ break;
+ }
+ },
+
+ /**
+ * Gets the first image file from a DataTransfer object, or null if there
+ * are no image files in the object.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {File|null}
+ */
+ _getUseableFile(dataTransfer) {
+ if (
+ dataTransfer.files.length &&
+ dataTransfer.files[0].type.startsWith("image/")
+ ) {
+ return dataTransfer.files[0];
+ }
+ return null;
+ },
+
+ /**
+ * Gets the first image file from a DataTransfer object, or null if there
+ * are no image files in the object.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {string|null}
+ */
+ _getUseableURL(dataTransfer) {
+ let data = dataTransfer.getData("text/plain");
+
+ return /^https?:\/\//.test(data) ? data : null;
+ },
+
+ _onDragOver(event) {
+ if (
+ this._getUseableFile(event.dataTransfer) ||
+ this._getUseableURL(event.clipboardData)
+ ) {
+ event.dataTransfer.dropEffect = "move";
+ event.preventDefault();
+ }
+ },
+
+ _onDrop(event) {
+ let file = this._getUseableFile(event.dataTransfer);
+ if (file) {
+ this.showWithFile(file);
+ event.preventDefault();
+ } else {
+ let url = this._getUseableURL(event.clipboardData);
+ if (url) {
+ this.showWithURL(url);
+ event.preventDefault();
+ }
+ }
+ },
+
+ _onPaste(event) {
+ let file = this._getUseableFile(event.clipboardData);
+ if (file) {
+ this.showWithFile(file);
+ } else {
+ let url = this._getUseableURL(event.clipboardData);
+ if (url) {
+ this.showWithURL(url);
+ }
+ }
+ event.preventDefault();
+ },
+
+ /**
+ * Show a file picker to choose an image.
+ */
+ async _showFilePicker() {
+ let title = await document.l10n.formatValue(
+ "about-addressbook-photo-filepicker-title"
+ );
+
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ picker.init(
+ window.browsingContext.topChromeWindow,
+ title,
+ Ci.nsIFilePicker.modeOpen
+ );
+ picker.appendFilters(Ci.nsIFilePicker.filterImages);
+ let result = await new Promise(resolve => picker.open(resolve));
+
+ if (result != Ci.nsIFilePicker.returnOK) {
+ return;
+ }
+
+ this.showWithFile(await File.createFromNsIFile(picker.file));
+ },
+};
+
+// Printing
+
+var printHandler = {
+ printDirectory(directory) {
+ let title = directory ? directory.dirName : document.title;
+
+ let cards;
+ if (directory) {
+ cards = directory.childCards;
+ } else {
+ cards = [];
+ for (let directory of MailServices.ab.directories) {
+ cards = cards.concat(directory.childCards);
+ }
+ }
+
+ this._printCards(title, cards);
+ },
+
+ printCards(cards) {
+ this._printCards(document.title, cards);
+ },
+
+ async _printCards(title, cards) {
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ let nameFormat = Services.prefs.getIntPref(
+ "mail.addr_book.lastnamefirst",
+ 0
+ );
+
+ cards.sort((a, b) => {
+ let aName = a.generateName(nameFormat);
+ let bName = b.generateName(nameFormat);
+ return collator.compare(aName, bName);
+ });
+
+ let printDocument = document.implementation.createHTMLDocument();
+ printDocument.title = title;
+ printDocument.head
+ .appendChild(printDocument.createElement("meta"))
+ .setAttribute("charset", "utf-8");
+ let link = printDocument.head.appendChild(
+ printDocument.createElement("link")
+ );
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", "chrome://messagebody/skin/abPrint.css");
+
+ let printTemplate = document.getElementById("printTemplate");
+
+ for (let card of cards) {
+ if (card.isMailList) {
+ continue;
+ }
+
+ let div = printDocument.createElement("div");
+ div.append(printTemplate.content.cloneNode(true));
+ detailsPane.fillContactDetails(div, card);
+ let photo = div.querySelector(".contact-photo");
+ if (photo.src.startsWith("chrome:")) {
+ photo.hidden = true;
+ }
+ await document.l10n.translateFragment(div);
+ printDocument.body.appendChild(div);
+ }
+
+ let html = new XMLSerializer().serializeToString(printDocument);
+ this._printURL(URL.createObjectURL(new File([html], "text/html")));
+ },
+
+ async _printURL(url) {
+ let topWindow = window.browsingContext.topChromeWindow;
+ await topWindow.PrintUtils.loadPrintBrowser(url);
+ topWindow.PrintUtils.startPrintWindow(
+ topWindow.PrintUtils.printBrowser.browsingContext,
+ {}
+ );
+ },
+};
+
+/**
+ * A span that displays the current time in a given time zone.
+ * The time is updated every minute.
+ */
+class ActiveTime extends HTMLSpanElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ this.hasConnected = true;
+ this.setAttribute("is", "active-time");
+
+ try {
+ this.formatter = new Services.intl.DateTimeFormat(undefined, {
+ timeZone: this.getAttribute("tz"),
+ weekday: "long",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ } catch {
+ // DateTimeFormat will throw if the time zone is unknown.
+ // If it does this will just be an empty span.
+ return;
+ }
+ this.update = this.update.bind(this);
+ this.update();
+
+ CalMetronome.on("minute", this.update);
+ window.addEventListener("unload", this, { once: true });
+ }
+
+ disconnectedCallback() {
+ CalMetronome.off("minute", this.update);
+ }
+
+ handleEvent() {
+ CalMetronome.off("minute", this.update);
+ }
+
+ update() {
+ this.textContent = this.formatter.format(new Date());
+ }
+}
+customElements.define("active-time", ActiveTime, { extends: "span" });
diff --git a/comm/mail/components/addrbook/content/aboutAddressBook.xhtml b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml
new file mode 100644
index 0000000000..51a689106a
--- /dev/null
+++ b/comm/mail/components/addrbook/content/aboutAddressBook.xhtml
@@ -0,0 +1,460 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, you can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true">
+<head>
+ <meta charset="utf-8" />
+ <title data-l10n-id="about-addressbook-title"></title>
+ <meta http-equiv="Content-Security-Policy"
+ content="default-src chrome:; script-src chrome: 'unsafe-inline'; img-src blob: chrome: data: http: https:; style-src chrome: 'unsafe-inline'; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/address-book.svg" />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/primaryToolbar.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/avatars.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/aboutAddressBook.css" />
+
+ <link rel="localization" href="messenger/treeView.ftl" />
+ <link rel="localization" href="messenger/addressbook/aboutAddressBook.ftl" />
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+
+ <script src="chrome://messenger/content/globalOverlay.js"></script>
+ <script src="chrome://global/content/editMenuOverlay.js"></script>
+ <script src="chrome://messenger/content/pane-splitter.js"></script>
+ <script src="chrome://messenger/content/tree-listbox.js"></script>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="chrome://messenger/content/jsTreeView.js"></script>
+ <script src="chrome://messenger/content/addressbook/abView-new.js"></script>
+ <script src="chrome://messenger/content/addressbook/aboutAddressBook.js"></script>
+</head>
+<body>
+ <xul:toolbox id="toolbox" class="contentTabToolbox" labelalign="end">
+ <xul:toolbar class="chromeclass-toolbar contentTabToolbar themeable-full" mode="full">
+ <xul:toolbarbutton id="toolbarCreateBook" is="toolbarbutton-menu-button" type="menu-button"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-address-book"
+ tabindex="0">
+ <xul:menupopup>
+ <xul:menuitem data-l10n-id="about-addressbook-toolbar-new-address-book"/>
+ <xul:menuitem value="CARDDAV_DIRECTORY_TYPE"
+ data-l10n-id="about-addressbook-toolbar-add-carddav-address-book"/>
+ <xul:menuitem value="LDAP_DIRECTORY_TYPE"
+ data-l10n-id="about-addressbook-toolbar-add-ldap-address-book"/>
+ </xul:menupopup>
+ </xul:toolbarbutton>
+ <xul:toolbarbutton id="toolbarCreateContact"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-contact"
+ tabindex="0"/>
+ <xul:toolbarbutton id="toolbarCreateList"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-new-list"
+ tabindex="0"/>
+ <xul:toolbarbutton id="toolbarImport"
+ class="toolbarbutton-1"
+ data-l10n-id="about-addressbook-toolbar-import"
+ tabindex="0"/>
+ </xul:toolbar>
+ </xul:toolbox>
+ <div id="booksPane" class="no-overscroll">
+ <ul is="ab-tree-listbox" id="books" role="tree">
+ <li id="allAddressBooks"
+ class="bookRow noDelete readOnly"
+ data-l10n-id="all-address-books-row">
+ <div class="bookRow-container">
+ <div class="twisty"></div>
+ <div class="bookRow-icon"></div>
+ <span class="bookRow-name" tabindex="-1" data-l10n-id="all-address-books"></span>
+ <div class="bookRow-menu"></div>
+ </div>
+ </li>
+ </ul>
+ <div id="cardCount"></div>
+ <template id="bookRow">
+ <li class="bookRow">
+ <div class="bookRow-container">
+ <div class="twisty">
+ <img class="twisty-icon" src="chrome://messenger/skin/icons/new/nav-down-sm.svg" alt="" />
+ </div>
+ <div class="bookRow-icon"></div>
+ <span class="bookRow-name" tabindex="-1"></span>
+ <div class="bookRow-menu"></div>
+ </div>
+ <ul></ul>
+ </li>
+ </template>
+ <template id="listRow">
+ <li class="listRow">
+ <div class="listRow-container">
+ <div class="listRow-icon"></div>
+ <span class="listRow-name" tabindex="-1"></span>
+ <div class="listRow-menu"></div>
+ </div>
+ </li>
+ </template>
+ </div>
+ <hr is="pane-splitter" id="booksSplitter"
+ resize-direction="horizontal"
+ resize-id="booksPane"/>
+ <div id="cardsPane">
+ <div id="cardsPaneHeader">
+ <input is="ab-card-search-input" id="searchInput"
+ type="search"
+ data-l10n-attrs="placeholder" />
+ <button id="displayButton"
+ class="button icon-button icon-only button-flat"
+ data-l10n-id="about-addressbook-sort-button2">
+ </button>
+ </div>
+
+ <tree-view id="cards">
+ <slot name="placeholders">
+ <div id="placeholderEmptyBook"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-empty-book"></div>
+ <button id="placeholderCreateContact"
+ class="icon-button"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-new-contact"></button>
+ <div id="placeholderSearchOnly"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-search-only"></div>
+ <div id="placeholderSearching"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-searching"></div>
+ <div id="placeholderNoSearchResults"
+ hidden="hidden"
+ data-l10n-id="about-addressbook-placeholder-no-search-results"></div>
+ </slot>
+ </tree-view>
+ </div>
+ <!-- We will dynamically switch this splitter to be horizontal or vertical and
+ affect the cardsPane or detailsPane based on the required layout. -->
+ <hr is="pane-splitter" id="sharedSplitter" />
+ <div id="detailsPane" hidden="hidden">
+ <article id="viewContact" class="contact-details-scroll">
+ <!-- If you're changing this, you probably want to change #printTemplate too. -->
+ <header>
+ <div class="contact-header">
+ <img id="viewContactPhoto" class="contact-photo" alt="" />
+ <div class="contact-headings">
+ <h1 id="viewContactName" class="contact-heading-name"></h1>
+ <p id="viewContactNickName" class="contact-heading-nickname"></p>
+ <p id="viewPrimaryEmail" class="contact-heading-email"></p>
+ </div>
+ </div>
+ <div class="list-header">
+ <div class="recipient-avatar is-mail-list">
+ <img alt="" src="chrome://messenger/skin/icons/new/compact/user-list-alt.svg" />
+ </div>
+ <h1 id="viewListName" class="contact-heading-name"></h1>
+ </div>
+ <div class="selection-header">
+ <h1 id="viewSelectionCount" class="contact-heading-name"></h1>
+ </div>
+ </header>
+ <div id="detailsBody">
+ <section id="detailsActions" class="button-block">
+ <div>
+ <button type="button" id="detailsWriteButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-write-action-button"></button>
+ <button type="button" id="detailsEventButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-event-action-button"></button>
+ <button type="button" id="detailsSearchButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-search-action-button"></button>
+ <button type="button" id="detailsNewListButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-new-list-action-button"></button>
+ </div>
+ <div class="edit-block">
+ <button type="button" id="editButton"
+ data-l10n-id="about-addressbook-begin-edit-contact-button"></button>
+ </div>
+ </section>
+ <section id="emailAddresses" class="details-email-addresses">
+ <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="phoneNumbers" class="details-phone-numbers">
+ <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="addresses" class="details-addresses">
+ <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="notes" class="details-notes">
+ <h2 data-l10n-id="about-addressbook-details-notes-header"></h2>
+ <div></div>
+ </section>
+ <section id="websites" class="details-websites">
+ <h2 data-l10n-id="about-addressbook-details-websites-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="instantMessaging" class="details-instant-messaging">
+ <h2 data-l10n-id="about-addressbook-details-impp-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="otherInfo" class="details-other-info">
+ <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section id="selectedCards">
+ <ul></ul>
+ </section>
+ <template id="entryItem">
+ <li class="entry-item">
+ <span class="entry-type"></span>
+ <span class="entry-value"></span>
+ </li>
+ </template>
+ <template id="selectedCard">
+ <li class="selected-card">
+ <div class="recipient-avatar"></div>
+ <div class="ab-card-row-data">
+ <p class="ab-card-first-line">
+ <span class="name"></span>
+ </p>
+ <p class="ab-card-second-line">
+ <span class="address"></span>
+ </p>
+ </div>
+ </li>
+ </template>
+ </div>
+ </article>
+ <form id="editContactForm"
+ autocomplete="off"
+ aria-labelledby="editContactHeadingName">
+ <div class="contact-details-scroll">
+ <div class="contact-header">
+ <div class="contact-headings">
+ <h1 id="editContactHeadingName" class="contact-heading-name"></h1>
+ <p id="editContactHeadingNickName" class="contact-heading-nickname">
+ </p>
+ <p id="editContactHeadingEmail" class="contact-heading-email"></p>
+ </div>
+ <!-- NOTE: We place the photo 'input' after the headings, since it is
+ - functionally a form control. However, we style the photo to
+ - appear at the inline-start of the contact-header. -->
+ <!-- NOTE: We wrap the button with a plain div because the button
+ - itself will not receive the paste event. -->
+ <div id="photoInput">
+ <button type="button" id="photoButton"
+ class="plain-button"
+ data-l10n-id="about-addressbook-details-edit-photo">
+ <img class="contact-photo" alt="" />
+ <div id="photoOverlay"></div>
+ </button>
+ </div>
+ </div>
+ #include vcard-edit/vCardTemplates.inc.xhtml
+ <vcard-edit />
+ </div>
+ <div id="detailsFooter" class="button-block">
+ <div>
+ <button type="button" id="detailsDeleteButton"
+ class="icon-button"
+ data-l10n-id="about-addressbook-delete-edit-contact-button"></button>
+ </div>
+ <div>
+ <xul:label control="addContactBookList"
+ data-l10n-id="about-addressbook-add-contact-to"/>
+ <xul:menulist is="menulist-addrbooks" id="addContactBookList"
+ writable="true"/>
+ <button type="reset" id="cancelEditButton"
+ data-l10n-id="about-addressbook-cancel-edit-contact-button"></button>
+ <button type="submit" id="saveEditButton"
+ class="primary"
+ data-l10n-id="about-addressbook-save-edit-contact-button"></button>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div id="detailsPaneBackdrop"><!--
+ When editing a card, this element covers everything except #detailsPane,
+ preventing change to another card.
+ --></div>
+
+ <dialog id="photoDialog">
+ <div id="photoDialogInner">
+ <!-- FIXME: The dialog is not semantic or accessible.
+ - We use a tabindex and role="alert" as a temporary solution. -->
+ <div id="photoDropTarget" role="alert" tabindex="0">
+ <div class="icon"></div>
+ <div class="label" data-l10n-id="about-addressbook-photo-drop-target"></div>
+ </div>
+ <svg xmlns="http://www.w3.org/2000/svg" width="520" height="520" viewBox="-10 -10 520 520">
+ <image/>
+ <path fill="#000000" fill-opacity="0.5" d="M0 0H500V500H0Z M200 200V300H300V200Z"/>
+ <rect x="0" y="0" width="500" height="500"/>
+ <rect class="corner nw" width="40" height="40"/>
+ <rect class="corner ne" width="40" height="40"/>
+ <rect class="corner se" width="40" height="40"/>
+ <rect class="corner sw" width="40" height="40"/>
+ </svg>
+ </div>
+
+ <menu class="dialog-menu-container">
+ <button class="extra1" data-l10n-id="about-addressbook-photo-discard"></button>
+ <button class="cancel" data-l10n-id="about-addressbook-photo-cancel"></button>
+ <button class="accept primary" data-l10n-id="about-addressbook-photo-save"></button>
+ </menu>
+ </dialog>
+
+ <!-- In-content dialogs. -->
+ <xul:stack id="dialogStack" hidden="true"/>
+ <xul:vbox id="dialogTemplate"
+ class="dialogOverlay"
+ align="center"
+ pack="center"
+ topmost="true"
+ hidden="true">
+ <xul:vbox class="dialogBox"
+ pack="end"
+ role="dialog"
+ aria-labelledby="dialogTitle">
+ <xul:hbox class="dialogTitleBar" align="center">
+ <xul:label class="dialogTitle" flex="1"/>
+ <xul:button class="dialogClose close-icon" data-l10n-id="close-button"/>
+ </xul:hbox>
+ <xul:browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </xul:vbox>
+ </xul:vbox>
+
+ <template id="printTemplate">
+ <!-- If you're changing this, you probably want to change #viewContact too. -->
+ <div class="contact-header">
+ <img class="contact-photo" alt="" />
+ <div class="contact-headings">
+ <h1 class="contact-heading-name"></h1>
+ <p class="contact-heading-nickname"></p>
+ <p class="contact-heading-email"></p>
+ </div>
+ </div>
+ <div class="contact-body">
+ <section class="details-email-addresses">
+ <h2 data-l10n-id="about-addressbook-details-email-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-phone-numbers">
+ <h2 data-l10n-id="about-addressbook-details-phone-numbers-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-addresses">
+ <h2 data-l10n-id="about-addressbook-details-addresses-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-notes">
+ <h2 data-l10n-id="about-addressbook-details-notes-header"></h2>
+ <div></div>
+ </section>
+ <section class="details-websites">
+ <h2 data-l10n-id="about-addressbook-details-websites-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-instant-messaging">
+ <h2 data-l10n-id="about-addressbook-details-impp-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ <section class="details-other-info">
+ <h2 data-l10n-id="about-addressbook-details-other-info-header"></h2>
+ <ul class="entry-list"></ul>
+ </section>
+ </div>
+ </template>
+</body>
+<xul:menupopup id="bookContext">
+ <xul:menuitem id="bookContextProperties"/>
+ <xul:menuitem id="bookContextSynchronize"
+ data-l10n-id="about-addressbook-books-context-synchronize"/>
+ <xul:menuitem id="bookContextPrint"
+ data-l10n-id="about-addressbook-books-context-print"/>
+ <xul:menuitem id="bookContextExport"
+ data-l10n-id="about-addressbook-books-context-export"/>
+ <xul:menuitem id="bookContextDelete"
+ data-l10n-id="about-addressbook-books-context-delete"/>
+ <xul:menuitem id="bookContextRemove"
+ data-l10n-id="about-addressbook-books-context-remove"/>
+ <xul:menuseparator/>
+ <xul:menuitem id="bookContextStartupDefault" type="checkbox"
+ data-l10n-id="about-addressbook-books-context-startup-default"/>
+</xul:menupopup>
+<xul:menupopup id="sortContext"
+ position="bottomleft topleft">
+ <xul:menuitem type="radio"
+ name="format"
+ value="0"
+ checked="true"
+ data-l10n-id="about-addressbook-name-format-display"/>
+ <xul:menuitem type="radio"
+ name="format"
+ value="2"
+ data-l10n-id="about-addressbook-name-format-firstlast"/>
+ <xul:menuitem type="radio"
+ name="format"
+ value="1"
+ data-l10n-id="about-addressbook-name-format-lastfirst"/>
+ <xul:menuseparator/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="GeneratedName ascending"
+ checked="true"
+ data-l10n-id="about-addressbook-sort-name-ascending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="GeneratedName descending"
+ data-l10n-id="about-addressbook-sort-name-descending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="EmailAddresses ascending"
+ data-l10n-id="about-addressbook-sort-email-ascending"/>
+ <xul:menuitem type="radio"
+ name="sort"
+ value="EmailAddresses descending"
+ data-l10n-id="about-addressbook-sort-email-descending"/>
+ <xul:menuseparator/>
+ <xul:menuitem id="sortContextTableLayout"
+ type="checkbox"
+ data-l10n-id="about-addressbook-table-layout"/>
+</xul:menupopup>
+<xul:menupopup id="cardContext">
+ <xul:menuitem id="cardContextWrite"
+ data-l10n-id="about-addressbook-cards-context-write"/>
+ <xul:menu id="cardContextWriteMenu"
+ data-l10n-id="about-addressbook-cards-context-write">
+ <xul:menupopup>
+ <!-- Filled dynamically. -->
+ </xul:menupopup>
+ </xul:menu>
+ <xul:menuseparator id="cardContextWriteSeparator"/>
+ <xul:menuitem id="cardContextEdit"
+ data-l10n-id="about-addressbook-books-context-edit"/>
+ <xul:menuitem id="cardContextPrint"
+ data-l10n-id="about-addressbook-books-context-print"/>
+ <xul:menuitem id="cardContextExport"
+ data-l10n-id="about-addressbook-books-context-export"/>
+ <xul:menuitem id="cardContextDelete"
+ data-l10n-id="about-addressbook-books-context-delete"/>
+ <xul:menuitem id="cardContextRemove"
+ data-l10n-id="about-addressbook-books-context-remove"/>
+</xul:menupopup>
+</html>
diff --git a/comm/mail/components/addrbook/content/addressBookTab.js b/comm/mail/components/addrbook/content/addressBookTab.js
new file mode 100644
index 0000000000..5605612daf
--- /dev/null
+++ b/comm/mail/components/addrbook/content/addressBookTab.js
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// mail/base/content/specialTabs.js
+/* globals contentTabBaseType, DOMLinkHandler */
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+/**
+ * A tab to show the Address Book.
+ */
+var addressBookTabType = {
+ __proto__: contentTabBaseType,
+ name: "addressBookTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ bundle: Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ ),
+ protoSvc: Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService(
+ Ci.nsIExternalProtocolService
+ ),
+
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ addressBookTab: {
+ type: "addressBookTab",
+ },
+ },
+
+ shouldSwitchTo(aArgs) {
+ if (!this.tab) {
+ return -1;
+ }
+
+ if ("onLoad" in aArgs) {
+ if (this.tab.browser.contentDocument.readyState != "complete") {
+ this.tab.browser.addEventListener(
+ "about-addressbook-ready",
+ event => aArgs.onLoad(event, this.tab.browser),
+ {
+ capture: true,
+ once: true,
+ }
+ );
+ } else {
+ aArgs.onLoad(null, this.tab.browser);
+ }
+ }
+ return document.getElementById("tabmail").tabInfo.indexOf(this.tab);
+ },
+
+ closeTab(aTab) {
+ this.tab = null;
+ },
+
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("preferencesTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "addressBookTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ aTab.panel.setAttribute("id", "addressBookTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+
+ // Start setting up the browser.
+ aTab.browser = aTab.panel.querySelector("browser");
+ aTab.browser.setAttribute(
+ "id",
+ "addressBookTabBrowser" + this.lastBrowserId
+ );
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "addressBookTabBrowser" + this.lastBrowserId
+ );
+ aTab.panel.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ aTab.url = "about:addressbook";
+ aTab.paneID = aArgs.paneID;
+ aTab.scrollPaneTo = aArgs.scrollPaneTo;
+ aTab.otherArgs = aArgs.otherArgs;
+
+ // Now set up the listeners.
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ // Wait for full loading of the tab and the automatic selecting of last tab.
+ // Then run the given onload code.
+ aTab.browser.addEventListener(
+ "about-addressbook-ready",
+ function (event) {
+ aTab.pageLoading = false;
+ aTab.pageLoaded = true;
+
+ if ("onLoad" in aArgs) {
+ // Let selection of the initial pane complete before selecting another.
+ // Otherwise we can end up with two panes selected at once.
+ aTab.browser.contentWindow.setTimeout(() => {
+ // By now, the tab could already be closed. Check that it isn't.
+ if (aTab.panel) {
+ aArgs.onLoad(event, aTab.browser);
+ }
+ });
+ }
+ },
+ {
+ capture: true,
+ once: true,
+ }
+ );
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = true;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", aTab.browser);
+ let params = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ postData: aArgs.postData || null,
+ };
+ aTab.browser.loadURI(Services.io.newURI("about:addressbook"), params);
+
+ this.tab = aTab;
+ this.lastBrowserId++;
+ },
+
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ return {};
+ },
+
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("addressBookTab", {});
+ },
+
+ doCommand(aCommand, aTab) {
+ if (aCommand == "cmd_print") {
+ aTab.browser.contentWindow.externalAction({ action: "print" });
+ return;
+ }
+ this.__proto__.doCommand(aCommand, aTab);
+ },
+};
diff --git a/comm/mail/components/addrbook/content/menulist-addrbooks.js b/comm/mail/components/addrbook/content/menulist-addrbooks.js
new file mode 100644
index 0000000000..6d919d98ad
--- /dev/null
+++ b/comm/mail/components/addrbook/content/menulist-addrbooks.js
@@ -0,0 +1,271 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+// The menulist CE is defined lazily. Create one now to get menulist defined,
+// allowing us to inherit from it.
+if (!customElements.get("menulist")) {
+ delete document.createXULElement("menulist");
+}
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ /**
+ * MozMenulistAddrbooks is a menulist widget that is automatically
+ * populated with the complete address book list.
+ *
+ * @augments {MozMenuList}
+ */
+ class MozMenulistAddrbooks extends customElements.get("menulist") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ if (this.menupopup) {
+ return;
+ }
+
+ this._directories = [];
+
+ this._rebuild();
+
+ // Store as a member of `this` so there's a strong reference.
+ this._addressBookListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-reloaded",
+ ],
+
+ init() {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic, true);
+ }
+ window.addEventListener("unload", this);
+ },
+
+ cleanUp() {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ window.removeEventListener("unload", this);
+ },
+
+ handleEvent(event) {
+ this.cleanUp();
+ },
+
+ observe: (subject, topic, data) => {
+ // Test-only reload of the address book manager.
+ if (topic == "addrbook-reloaded") {
+ this._rebuild();
+ return;
+ }
+
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ switch (topic) {
+ case "addrbook-directory-created": {
+ if (this._matches(subject)) {
+ this._rebuild();
+ }
+ break;
+ }
+ case "addrbook-directory-updated": {
+ // Find the item in the list to rename.
+ // We can't use indexOf here because we need loose equality.
+ let len = this._directories.length;
+ for (var oldIndex = len - 1; oldIndex >= 0; oldIndex--) {
+ if (this._directories[oldIndex] == subject) {
+ break;
+ }
+ }
+ if (oldIndex != -1) {
+ this._rebuild();
+ }
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ // Find the item in the list to remove.
+ // We can't use indexOf here because we need loose equality.
+ let len = this._directories.length;
+ for (var index = len - 1; index >= 0; index--) {
+ if (this._directories[index] == subject) {
+ break;
+ }
+ }
+ if (index != -1) {
+ this._directories.splice(index, 1);
+ // Are we removing the selected directory?
+ if (
+ this.selectedItem ==
+ this.menupopup.removeChild(this.menupopup.children[index])
+ ) {
+ // If so, try to select the first directory, if available.
+ if (this.menupopup.hasChildNodes()) {
+ this.menupopup.firstElementChild.doCommand();
+ } else {
+ this.selectedItem = null;
+ }
+ }
+ }
+ break;
+ }
+ }
+ },
+ };
+
+ this._addressBookListener.init();
+ }
+
+ /**
+ * Returns the address book type based on the remoteonly attribute
+ * of the menulist.
+ *
+ * "URI" Local Address Book
+ * "dirPrefId" Remote LDAP Directory
+ */
+ get _type() {
+ return this.getAttribute("remoteonly") ? "dirPrefId" : "URI";
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._addressBookListener.cleanUp();
+ this._teardown();
+ }
+
+ _rebuild() {
+ // Init the address book cache.
+ this._directories.length = 0;
+
+ for (let ab of MailServices.ab.directories) {
+ if (this._matches(ab)) {
+ this._directories.push(ab);
+
+ if (this.getAttribute("mailinglists") == "true") {
+ // Also append contained mailinglists.
+ for (let list of ab.childNodes) {
+ if (this._matches(list)) {
+ this._directories.push(list);
+ }
+ }
+ }
+ }
+ }
+
+ this._teardown();
+
+ if (this.hasAttribute("none")) {
+ // Create a dummy menuitem representing no selection.
+ this._directories.unshift(null);
+ let listItem = this.appendItem(this.getAttribute("none"), "");
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+ }
+
+ if (this.hasAttribute("alladdressbooks")) {
+ // Insert a menuitem representing All Addressbooks.
+ let allABLabel = this.getAttribute("alladdressbooks");
+ if (allABLabel == "true") {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ allABLabel = bundle.GetStringFromName("allAddressBooks");
+ }
+
+ this._directories.unshift(null);
+ let listItem = this.appendItem(allABLabel, "moz-abdirectory://?");
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+ }
+
+ // Now create menuitems for all displayed directories.
+ let type = this._type;
+ for (let ab of this._directories) {
+ if (!ab) {
+ // Skip the empty members added above.
+ continue;
+ }
+
+ let listItem = this.appendItem(ab.dirName, ab[type]);
+ listItem.setAttribute("class", "menuitem-iconic abMenuItem");
+
+ // Style the items by type.
+ if (ab.isMailList) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/user-list.svg"
+ );
+ } else if (ab.isRemote && ab.isSecure) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/globe-secure.svg"
+ );
+ } else if (ab.isRemote) {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/globe.svg"
+ );
+ } else {
+ listItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/icons/new/compact/address-book.svg"
+ );
+ }
+ }
+
+ // Attempt to select the persisted or otherwise first directory.
+ this.selectedIndex = this._directories.findIndex(d => {
+ return d && d[type] == this.value;
+ });
+
+ if (!this.selectedItem && this.menupopup.hasChildNodes()) {
+ this.selectedIndex = 0;
+ }
+ }
+
+ _teardown() {
+ // Empty out anything in the list.
+ while (this.menupopup && this.menupopup.hasChildNodes()) {
+ this.menupopup.lastChild.remove();
+ }
+ }
+
+ _matches(ab) {
+ // This condition is used for instance when creating cards
+ if (this.getAttribute("writable") == "true" && ab.readOnly) {
+ return false;
+ }
+
+ // This condition is used for instance when creating mailing lists
+ if (
+ this.getAttribute("supportsmaillists") == "true" &&
+ !ab.supportsMailingLists
+ ) {
+ return false;
+ }
+
+ return (
+ this.getAttribute(ab.isRemote ? "localonly" : "remoteonly") != "true"
+ );
+ }
+ }
+
+ customElements.define("menulist-addrbooks", MozMenulistAddrbooks, {
+ extends: "menulist",
+ });
+}
diff --git a/comm/mail/components/addrbook/content/vcard-edit/adr.mjs b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs
new file mode 100644
index 0000000000..2f395173f3
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/adr.mjs
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ADR
+ */
+export class VCardAdrComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("adr", {}, "text", [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ]);
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-adr");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.streetEl = this.querySelector('textarea[name="street"]');
+ this.assignIds(this.streetEl, this.querySelector('label[for="street"]'));
+ this.streetEl.addEventListener("input", () => {
+ this.resizeStreetEl();
+ });
+
+ this.localityEl = this.querySelector('input[name="locality"]');
+ this.assignIds(
+ this.localityEl,
+ this.querySelector('label[for="locality"]')
+ );
+
+ this.regionEl = this.querySelector('input[name="region"]');
+ this.assignIds(this.regionEl, this.querySelector('label[for="region"]'));
+
+ this.codeEl = this.querySelector('input[name="code"]');
+ this.assignIds(this.regionEl, this.querySelector('label[for="code"]'));
+
+ this.countryEl = this.querySelector('input[name="country"]');
+ this.assignIds(this.countryEl, this.querySelector('label[for="country"]'));
+
+ // Create the adr type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ });
+
+ this.fromVCardPropertyEntryToUI();
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ }
+
+ fromVCardPropertyEntryToUI() {
+ if (Array.isArray(this.vCardPropertyEntry.value[2])) {
+ this.streetEl.value = this.vCardPropertyEntry.value[2].join("\n");
+ } else {
+ this.streetEl.value = this.vCardPropertyEntry.value[2] || "";
+ }
+ // Per RFC 6350, post office box and extended address SHOULD be empty.
+ let pobox = this.vCardPropertyEntry.value[0] || "";
+ let extendedAddr = this.vCardPropertyEntry.value[1] || "";
+ if (extendedAddr) {
+ this.streetEl.value = this.streetEl.value + "\n" + extendedAddr.trim();
+ delete this.vCardPropertyEntry.value[1];
+ }
+ if (pobox) {
+ this.streetEl.value = pobox.trim() + "\n" + this.streetEl.value;
+ delete this.vCardPropertyEntry.value[0];
+ }
+
+ this.resizeStreetEl();
+ this.localityEl.value = this.vCardPropertyEntry.value[3] || "";
+ this.regionEl.value = this.vCardPropertyEntry.value[4] || "";
+ this.codeEl.value = this.vCardPropertyEntry.value[5] || "";
+ this.countryEl.value = this.vCardPropertyEntry.value[6] || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ let streetValue = this.streetEl.value || "";
+ streetValue = streetValue.trim();
+ if (streetValue.includes("\n")) {
+ streetValue = streetValue.replaceAll("\r", "");
+ streetValue = streetValue.split("\n");
+ }
+
+ this.vCardPropertyEntry.value = [
+ "",
+ "",
+ streetValue,
+ this.localityEl.value || "",
+ this.regionEl.value || "",
+ this.codeEl.value || "",
+ this.countryEl.value || "",
+ ];
+ }
+
+ valueIsEmpty() {
+ return [
+ this.streetEl,
+ this.localityEl,
+ this.regionEl,
+ this.codeEl,
+ this.countryEl,
+ ].every(e => !e.value);
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+
+ resizeStreetEl() {
+ this.streetEl.rows = Math.max(1, this.streetEl.value.split("\n").length);
+ }
+}
+
+customElements.define("vcard-adr", VCardAdrComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/custom.mjs b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs
new file mode 100644
index 0000000000..bcdb1f6531
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/custom.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+export class VCardCustomComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry[]} */
+ vCardPropertyEntries = null;
+ /** @type {HTMLInputElement[]} */
+ inputEls = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-custom");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.inputEls = this.querySelectorAll("input");
+ let labelEls = this.querySelectorAll("label");
+ for (let i = 0; i < 4; i++) {
+ let inputId = vCardIdGen.next().value;
+ document.l10n.setAttributes(
+ labelEls[i],
+ `about-addressbook-entry-name-custom${i + 1}`
+ );
+ labelEls[i].htmlFor = inputId;
+ this.inputEls[i].id = inputId;
+ }
+ this.fromVCardPropertyEntryToUI();
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-custom").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ }
+
+ fromVCardPropertyEntryToUI() {
+ for (let i = 0; i < 4; i++) {
+ this.inputEls[i].value = this.vCardPropertyEntries[i].value;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ for (let i = 0; i < 4; i++) {
+ this.vCardPropertyEntries[i].value = this.inputEls[i].value;
+ }
+ }
+}
+
+customElements.define("vcard-custom", VCardCustomComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/edit.mjs b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs
new file mode 100644
index 0000000000..90463e33bb
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/edit.mjs
@@ -0,0 +1,1094 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+import { VCardAdrComponent } from "./adr.mjs";
+import { VCardCustomComponent } from "./custom.mjs";
+import { VCardEmailComponent } from "./email.mjs";
+import { VCardIMPPComponent } from "./impp.mjs";
+import { VCardNComponent } from "./n.mjs";
+import { VCardFNComponent } from "./fn.mjs";
+import { VCardNickNameComponent } from "./nickname.mjs";
+import { VCardNoteComponent } from "./note.mjs";
+import {
+ VCardOrgComponent,
+ VCardRoleComponent,
+ VCardTitleComponent,
+} from "./org.mjs";
+import { VCardSpecialDateComponent } from "./special-date.mjs";
+import { VCardTelComponent } from "./tel.mjs";
+import { VCardTZComponent } from "./tz.mjs";
+import { VCardURLComponent } from "./url.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardProperties",
+ "resource:///modules/VCardUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+class VCardEdit extends HTMLElement {
+ constructor() {
+ super();
+
+ this.contactNameHeading = document.getElementById("editContactHeadingName");
+ this.contactNickNameHeading = document.getElementById(
+ "editContactHeadingNickName"
+ );
+ this.contactEmailHeading = document.getElementById(
+ "editContactHeadingEmail"
+ );
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.updateView();
+
+ this.addEventListener("vcard-remove-property", e => {
+ if (e.target.vCardPropertyEntries) {
+ for (let entry of e.target.vCardPropertyEntries) {
+ this.vCardProperties.removeEntry(entry);
+ }
+ } else {
+ this.vCardProperties.removeEntry(e.target.vCardPropertyEntry);
+ }
+
+ // Move the focus to the first available valid element of the fieldset.
+ let sibling =
+ e.target.nextElementSibling || e.target.previousElementSibling;
+ // If we got a button, focus it since it's the "add row" button.
+ if (sibling?.type == "button") {
+ sibling.focus();
+ return;
+ }
+
+ // Otherwise we have a row field, so try to find a focusable element.
+ if (sibling && this.moveFocusIntoElement(sibling)) {
+ return;
+ }
+
+ // If we reach this point, the markup was unpredictable and we should
+ // move the focus to a valid element to avoid focus lost.
+ e.target
+ .closest("fieldset")
+ .querySelector(".add-property-button")
+ .focus();
+ });
+ }
+ }
+
+ disconnectedCallback() {
+ this.replaceChildren();
+ }
+
+ get vCardString() {
+ return this._vCardProperties.toVCard();
+ }
+
+ set vCardString(value) {
+ if (value) {
+ try {
+ this.vCardProperties = lazy.VCardProperties.fromVCard(value);
+ return;
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ this.vCardProperties = new lazy.VCardProperties("4.0");
+ }
+
+ get vCardProperties() {
+ return this._vCardProperties;
+ }
+
+ set vCardProperties(value) {
+ this._vCardProperties = value;
+ // If no n property is present set one.
+ if (!this._vCardProperties.getFirstEntry("n")) {
+ this._vCardProperties.addEntry(VCardNComponent.newVCardPropertyEntry());
+ }
+ // If no fn property is present set one.
+ if (!this._vCardProperties.getFirstEntry("fn")) {
+ this._vCardProperties.addEntry(VCardFNComponent.newVCardPropertyEntry());
+ }
+ // If no nickname property is present set one.
+ if (!this._vCardProperties.getFirstEntry("nickname")) {
+ this._vCardProperties.addEntry(
+ VCardNickNameComponent.newVCardPropertyEntry()
+ );
+ }
+ // If no email property is present set one.
+ if (!this._vCardProperties.getFirstEntry("email")) {
+ let emailEntry = VCardEmailComponent.newVCardPropertyEntry();
+ emailEntry.params.pref = "1"; // Set as default email.
+ this._vCardProperties.addEntry(emailEntry);
+ }
+ // If one of the organizational properties is present,
+ // make sure they all are.
+ let title = this._vCardProperties.getFirstEntry("title");
+ let role = this._vCardProperties.getFirstEntry("role");
+ let org = this._vCardProperties.getFirstEntry("org");
+ if (title || role || org) {
+ if (!title) {
+ this._vCardProperties.addEntry(
+ VCardTitleComponent.newVCardPropertyEntry()
+ );
+ }
+ if (!role) {
+ this._vCardProperties.addEntry(
+ VCardRoleComponent.newVCardPropertyEntry()
+ );
+ }
+ if (!org) {
+ this._vCardProperties.addEntry(
+ VCardOrgComponent.newVCardPropertyEntry()
+ );
+ }
+ }
+
+ for (let i = 1; i <= 4; i++) {
+ if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "")
+ );
+ }
+ }
+
+ this.updateView();
+ }
+
+ updateView() {
+ // Create new DOM and replacing other vCardProperties.
+ let template = document.getElementById("template-addr-book-edit");
+ let clonedTemplate = template.content.cloneNode(true);
+ // Making the next two calls in one go causes a console error to be logged.
+ this.replaceChildren();
+ this.append(clonedTemplate);
+
+ if (!this.vCardProperties) {
+ return;
+ }
+
+ this.addFieldsetActions();
+
+ // Insert the vCard property entries.
+ for (let vCardPropertyEntry of this.vCardProperties.entries) {
+ this.insertVCardElement(vCardPropertyEntry, false);
+ }
+
+ let customProperties = ["x-custom1", "x-custom2", "x-custom3", "x-custom4"];
+ if (customProperties.some(key => this.vCardProperties.getFirstValue(key))) {
+ // If one of these properties has a value, display all of them.
+ let customFieldset = this.querySelector("#addr-book-edit-custom");
+ let customEl =
+ customFieldset.querySelector("vcard-custom") ||
+ new VCardCustomComponent();
+ customEl.vCardPropertyEntries = customProperties.map(key =>
+ this._vCardProperties.getFirstEntry(key)
+ );
+ let addCustom = document.getElementById("vcard-add-custom");
+ customFieldset.insertBefore(customEl, addCustom);
+ addCustom.hidden = true;
+ }
+
+ let nameEl = this.querySelector("vcard-n");
+ this.firstName = nameEl.firstNameEl.querySelector("input");
+ this.lastName = nameEl.lastNameEl.querySelector("input");
+ this.prefixName = nameEl.prefixEl.querySelector("input");
+ this.middleName = nameEl.middleNameEl.querySelector("input");
+ this.suffixName = nameEl.suffixEl.querySelector("input");
+ this.displayName = this.querySelector("vcard-fn").displayEl;
+
+ [
+ this.firstName,
+ this.lastName,
+ this.prefixName,
+ this.middleName,
+ this.suffixName,
+ this.displayName,
+ ].forEach(element => {
+ element.addEventListener("input", event =>
+ this.generateContactName(event)
+ );
+ });
+
+ // Only set the strings and define this selector if we're inside the
+ // address book edit panel.
+ if (document.getElementById("detailsPane")) {
+ this.preferDisplayName = this.querySelector("vcard-fn").preferDisplayEl;
+ document.l10n.setAttributes(
+ this.preferDisplayName.closest(".vcard-checkbox").querySelector("span"),
+ "about-addressbook-prefer-display-name"
+ );
+ }
+
+ this.nickName = this.querySelector("vcard-nickname").nickNameEl;
+ this.nickName.addEventListener("input", () => this.updateNickName());
+
+ if (this.vCardProperties) {
+ this.toggleDefaultEmailView();
+ this.checkForBdayOccurrences();
+ }
+
+ this.updateNickName();
+ this.updateEmailHeading();
+ this.generateContactName();
+ }
+
+ /**
+ * Update the contact name to reflect the users' choice.
+ *
+ * @param {?Event} event - The DOM event if we have one.
+ */
+ async generateContactName(event = null) {
+ // Don't generate any preview if the contact name element is not available,
+ // which it might happen since this component is used in other areas outside
+ // the address book UI.
+ if (!this.contactNameHeading) {
+ return;
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ let result = "";
+ let pref = Services.prefs.getIntPref("mail.addr_book.lastnamefirst");
+ switch (pref) {
+ case Ci.nsIAbCard.GENERATE_DISPLAY_NAME:
+ result = this.buildDefaultName();
+ break;
+
+ case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER:
+ if (this.lastName.value) {
+ result = bundle.formatStringFromName("lastFirstFormat", [
+ this.lastName.value,
+ [
+ this.prefixName.value,
+ this.firstName.value,
+ this.middleName.value,
+ this.suffixName.value,
+ ]
+ .filter(Boolean)
+ .join(" "),
+ ]);
+ } else {
+ // Get the generic name if we don't have a last name.
+ result = this.buildDefaultName();
+ }
+ break;
+
+ default:
+ result = bundle.formatStringFromName("firstLastFormat", [
+ [this.prefixName.value, this.firstName.value, this.middleName.value]
+ .filter(Boolean)
+ .join(" "),
+ [this.lastName.value, this.suffixName.value]
+ .filter(Boolean)
+ .join(" "),
+ ]);
+ break;
+ }
+
+ if (result == "" || result == ", ") {
+ // We don't have anything to show as a contact name, so let's find the
+ // default email and show that, if we have it, otherwise pass an empty
+ // string to remove any leftover data.
+ let email = this.getDefaultEmail();
+ result = email ? email.split("@", 1)[0] : "";
+ }
+
+ this.contactNameHeading.textContent = result;
+ this.fillDisplayName(event);
+ }
+
+ /**
+ * Returns the name to show for this contact if the display name is available
+ * or it generates one from the available N data.
+ *
+ * @returns {string} - The name to show for this contact.
+ */
+ buildDefaultName() {
+ return this.displayName.isDirty
+ ? this.displayName.value
+ : [
+ this.prefixName.value,
+ this.firstName.value,
+ this.middleName.value,
+ this.lastName.value,
+ this.suffixName.value,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ }
+
+ /**
+ * Update the nickname value of the contact header.
+ */
+ updateNickName() {
+ // Don't generate any preview if the contact nickname element is not
+ // available, which it might happen since this component is used in other
+ // areas outside the address book UI.
+ if (!this.contactNickNameHeading) {
+ return;
+ }
+
+ let value = this.nickName.value.trim();
+ this.contactNickNameHeading.hidden = !value;
+ this.contactNickNameHeading.textContent = value;
+ }
+
+ /**
+ * Update the email value of the contact header.
+ *
+ * @param {?string} email - The email value the user is currently typing.
+ */
+ updateEmailHeading(email = null) {
+ // Don't generate any preview if the contact nickname email is not
+ // available, which it might happen since this component is used in other
+ // areas outside the address book UI.
+ if (!this.contactEmailHeading) {
+ return;
+ }
+
+ // If no email string was passed, it means this method was called when the
+ // view or edit pane refreshes, therefore we need to fetch the correct
+ // default email address.
+ let value = email ?? this.getDefaultEmail();
+ this.contactEmailHeading.hidden = !value;
+ this.contactEmailHeading.textContent = value;
+ }
+
+ /**
+ * Find the default email used for this contact.
+ *
+ * @returns {VCardEmailComponent}
+ */
+ getDefaultEmail() {
+ let emails = document.getElementById("vcard-email").children;
+ if (emails.length == 1) {
+ return emails[0].emailEl.value;
+ }
+
+ let defaultEmail = [...emails].find(
+ el => el.vCardPropertyEntry.params.pref === "1"
+ );
+
+ // If no email is marked as preferred, use the first one.
+ if (!defaultEmail) {
+ defaultEmail = emails[0];
+ }
+
+ return defaultEmail.emailEl.value;
+ }
+
+ /**
+ * Auto fill the display name only if the pref is set, the user is not
+ * editing the display name field, and the field was never edited.
+ * The intention is to prefill while entering a new contact. Don't fill
+ * if we don't have a proper default name to show, but only a placeholder.
+ *
+ * @param {?Event} event - The DOM event if we have one.
+ */
+ fillDisplayName(event = null) {
+ if (
+ Services.prefs.getBoolPref("mail.addr_book.displayName.autoGeneration") &&
+ event?.originalTarget.id != "vCardDisplayName" &&
+ !this.displayName.isDirty &&
+ this.buildDefaultName()
+ ) {
+ this.displayName.value = this.contactNameHeading.textContent;
+ }
+ }
+
+ /**
+ * Inserts a custom element for a {VCardPropertyEntry}
+ *
+ * - Assigns rich data (not bind to a html attribute) and therefore
+ * the reference.
+ * - Inserts the element in the form at the correct position.
+ *
+ * @param {VCardPropertyEntry} entry
+ * @param {boolean} addEntry Adds the entry to the vCardProperties.
+ * @returns {VCardPropertyEntryView | undefined}
+ */
+ insertVCardElement(entry, addEntry) {
+ // Add the entry to the vCardProperty data.
+ if (addEntry) {
+ this.vCardProperties.addEntry(entry);
+ }
+
+ let fieldset;
+ let addButton;
+ switch (entry.name) {
+ case "n":
+ let n = new VCardNComponent();
+ n.vCardPropertyEntry = entry;
+ fieldset = document.getElementById("addr-book-edit-n");
+ let displayNicknameContainer = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(n, displayNicknameContainer);
+ return n;
+ case "fn":
+ let fn = new VCardFNComponent();
+ fn.vCardPropertyEntry = entry;
+ fieldset = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(fn, fieldset.firstElementChild);
+ return fn;
+ case "nickname":
+ let nickname = new VCardNickNameComponent();
+ nickname.vCardPropertyEntry = entry;
+ fieldset = this.querySelector(
+ "#addr-book-edit-n .addr-book-edit-display-nickname"
+ );
+ fieldset.insertBefore(
+ nickname,
+ fieldset.firstElementChild?.nextElementSibling
+ );
+ return nickname;
+ case "email":
+ let email = document.createElement("tr", { is: "vcard-email" });
+ email.vCardPropertyEntry = entry;
+ document.getElementById("vcard-email").appendChild(email);
+ return email;
+ case "url":
+ let url = new VCardURLComponent();
+ url.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-url");
+ addButton = document.getElementById("vcard-add-url");
+ fieldset.insertBefore(url, addButton);
+ return url;
+ case "tel":
+ let tel = new VCardTelComponent();
+ tel.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-tel");
+ addButton = document.getElementById("vcard-add-tel");
+ fieldset.insertBefore(tel, addButton);
+ return tel;
+ case "tz":
+ let tz = new VCardTZComponent();
+ tz.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-tz");
+ addButton = document.getElementById("vcard-add-tz");
+ fieldset.insertBefore(tz, addButton);
+ addButton.hidden = true;
+ return tz;
+ case "impp":
+ let impp = new VCardIMPPComponent();
+ impp.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-impp");
+ addButton = document.getElementById("vcard-add-impp");
+ fieldset.insertBefore(impp, addButton);
+ return impp;
+ case "anniversary":
+ let anniversary = new VCardSpecialDateComponent();
+ anniversary.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-bday-anniversary");
+ addButton = document.getElementById("vcard-add-bday-anniversary");
+ fieldset.insertBefore(anniversary, addButton);
+ return anniversary;
+ case "bday":
+ let bday = new VCardSpecialDateComponent();
+ bday.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-bday-anniversary");
+ addButton = document.getElementById("vcard-add-bday-anniversary");
+ fieldset.insertBefore(bday, addButton);
+ return bday;
+ case "adr":
+ let address = new VCardAdrComponent();
+ address.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-address");
+ addButton = document.getElementById("vcard-add-adr");
+ fieldset.insertBefore(address, addButton);
+ return address;
+ case "note":
+ let note = new VCardNoteComponent();
+ note.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-note");
+ addButton = document.getElementById("vcard-add-note");
+ fieldset.insertBefore(note, addButton);
+ // Only one note is allowed via UI.
+ addButton.hidden = true;
+ return note;
+ case "title":
+ let title = new VCardTitleComponent();
+ title.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(
+ title,
+ fieldset.querySelector("vcard-role, vcard-org, #vcard-add-org")
+ );
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one title is allowed via UI.
+ addButton.hidden = true;
+ return title;
+ case "role":
+ let role = new VCardRoleComponent();
+ role.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(
+ role,
+ fieldset.querySelector("vcard-org, #vcard-add-org")
+ );
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one role is allowed via UI.
+ addButton.hidden = true;
+ return role;
+ case "org":
+ let org = new VCardOrgComponent();
+ org.vCardPropertyEntry = entry;
+ fieldset = this.querySelector("#addr-book-edit-org");
+ addButton = document.getElementById("vcard-add-org");
+ fieldset.insertBefore(org, addButton);
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).hidden = false;
+ // Only one org is allowed via UI.
+ addButton.hidden = true;
+ return org;
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Creates a VCardPropertyEntry with a matching
+ * name to the vCard spec.
+ *
+ * @param {string} entryName - A name which should be a vCard spec property.
+ * @returns {VCardPropertyEntry | undefined}
+ */
+ static createVCardProperty(entryName) {
+ switch (entryName) {
+ case "n":
+ return VCardNComponent.newVCardPropertyEntry();
+ case "fn":
+ return VCardFNComponent.newVCardPropertyEntry();
+ case "nickname":
+ return VCardNickNameComponent.newVCardPropertyEntry();
+ case "email":
+ return VCardEmailComponent.newVCardPropertyEntry();
+ case "url":
+ return VCardURLComponent.newVCardPropertyEntry();
+ case "tel":
+ return VCardTelComponent.newVCardPropertyEntry();
+ case "tz":
+ return VCardTZComponent.newVCardPropertyEntry();
+ case "impp":
+ return VCardIMPPComponent.newVCardPropertyEntry();
+ case "bday":
+ return VCardSpecialDateComponent.newBdayVCardPropertyEntry();
+ case "anniversary":
+ return VCardSpecialDateComponent.newAnniversaryVCardPropertyEntry();
+ case "adr":
+ return VCardAdrComponent.newVCardPropertyEntry();
+ case "note":
+ return VCardNoteComponent.newVCardPropertyEntry();
+ case "title":
+ return VCardTitleComponent.newVCardPropertyEntry();
+ case "role":
+ return VCardRoleComponent.newVCardPropertyEntry();
+ case "org":
+ return VCardOrgComponent.newVCardPropertyEntry();
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Mutates the referenced vCardPropertyEntry(s).
+ * If the value of a VCardPropertyEntry is empty, the entry gets
+ * removed from the vCardProperty.
+ */
+ saveVCard() {
+ for (let node of [
+ ...this.querySelectorAll("vcard-adr"),
+ ...this.querySelectorAll("vcard-custom"),
+ ...document.getElementById("vcard-email").children,
+ ...this.querySelectorAll("vcard-fn"),
+ ...this.querySelectorAll("vcard-impp"),
+ ...this.querySelectorAll("vcard-n"),
+ ...this.querySelectorAll("vcard-nickname"),
+ ...this.querySelectorAll("vcard-note"),
+ ...this.querySelectorAll("vcard-org"),
+ ...this.querySelectorAll("vcard-role"),
+ ...this.querySelectorAll("vcard-title"),
+ ...this.querySelectorAll("vcard-special-date"),
+ ...this.querySelectorAll("vcard-tel"),
+ ...this.querySelectorAll("vcard-tz"),
+ ...this.querySelectorAll("vcard-url"),
+ ]) {
+ if (typeof node.fromUIToVCardPropertyEntry === "function") {
+ node.fromUIToVCardPropertyEntry();
+ }
+
+ // Filter out empty fields.
+ if (typeof node.valueIsEmpty === "function" && node.valueIsEmpty()) {
+ this.vCardProperties.removeEntry(node.vCardPropertyEntry);
+ }
+ }
+
+ // If no email has a pref value of 1, set it to the first email.
+ let emailEntries = this.vCardProperties.getAllEntries("email");
+ if (
+ emailEntries.length >= 1 &&
+ emailEntries.every(entry => entry.params.pref !== "1")
+ ) {
+ emailEntries[0].params.pref = "1";
+ }
+
+ for (let i = 1; i <= 4; i++) {
+ let entry = this._vCardProperties.getFirstEntry(`x-custom${i}`);
+ if (entry && !entry.value) {
+ this._vCardProperties.removeEntry(entry);
+ }
+ }
+ }
+
+ /**
+ * Move focus into the form.
+ */
+ setFocus() {
+ this.querySelector("vcard-n input:not([hidden])").focus();
+ }
+
+ /**
+ * Move focus to the first visible form element below the given element.
+ *
+ * @param {Element} element - The element to move focus into.
+ * @returns {boolean} - If the focus was moved into the element.
+ */
+ moveFocusIntoElement(element) {
+ for (let child of element.querySelectorAll(
+ "select,input,textarea,button"
+ )) {
+ // Make sure it is visible.
+ if (child.clientWidth != 0 && child.clientHeight != 0) {
+ child.focus();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Add buttons and further actions of the groupings for vCard property
+ * entries.
+ */
+ addFieldsetActions() {
+ // Add email button.
+ let addEmail = document.getElementById("vcard-add-email");
+ this.registerAddButton(addEmail, "email", () => {
+ this.toggleDefaultEmailView();
+ });
+
+ // Add listener to update the email written in the contact header.
+ this.addEventListener("vcard-email-default-changed", event => {
+ this.updateEmailHeading(
+ event.target.querySelector('input[type="email"]').value
+ );
+ });
+
+ // Add listener to be sure that only one checkbox from the emails is ticked.
+ this.addEventListener("vcard-email-default-checkbox", event => {
+ // Show the newly selected default email in the contact header.
+ this.updateEmailHeading(
+ event.target.querySelector('input[type="email"]').value
+ );
+ for (let vCardEmailComponent of document.getElementById("vcard-email")
+ .children) {
+ if (event.target !== vCardEmailComponent) {
+ vCardEmailComponent.checkboxEl.checked = false;
+ }
+ }
+ });
+
+ // Handling the VCardPropertyEntry change with the select.
+ let specialDatesFieldset = document.getElementById(
+ "addr-book-edit-bday-anniversary"
+ );
+ specialDatesFieldset.addEventListener(
+ "vcard-bday-anniversary-change",
+ event => {
+ let newVCardPropertyEntry = new lazy.VCardPropertyEntry(
+ event.detail.name,
+ event.target.vCardPropertyEntry.params,
+ event.target.vCardPropertyEntry.type,
+ event.target.vCardPropertyEntry.value
+ );
+ this.vCardProperties.removeEntry(event.target.vCardPropertyEntry);
+ event.target.vCardPropertyEntry = newVCardPropertyEntry;
+ this.vCardProperties.addEntry(newVCardPropertyEntry);
+ this.checkForBdayOccurrences();
+ }
+ );
+
+ // Add special date button.
+ let addSpecialDate = document.getElementById("vcard-add-bday-anniversary");
+ addSpecialDate.addEventListener("click", e => {
+ let newVCardProperty;
+ if (!this.vCardProperties.getFirstEntry("bday")) {
+ newVCardProperty = VCardEdit.createVCardProperty("bday");
+ } else {
+ newVCardProperty = VCardEdit.createVCardProperty("anniversary");
+ }
+ let el = this.insertVCardElement(newVCardProperty, true);
+ this.checkForBdayOccurrences();
+ this.moveFocusIntoElement(el);
+ });
+
+ // Organizational Properties.
+ let addOrg = document.getElementById("vcard-add-org");
+ addOrg.addEventListener("click", event => {
+ let title = VCardEdit.createVCardProperty("title");
+ let role = VCardEdit.createVCardProperty("role");
+ let org = VCardEdit.createVCardProperty("org");
+
+ let titleEl = this.insertVCardElement(title, true);
+ this.insertVCardElement(role, true);
+ this.insertVCardElement(org, true);
+
+ this.moveFocusIntoElement(titleEl);
+ addOrg.hidden = true;
+ });
+
+ let addAddress = document.getElementById("vcard-add-adr");
+ this.registerAddButton(addAddress, "adr");
+
+ let addURL = document.getElementById("vcard-add-url");
+ this.registerAddButton(addURL, "url");
+
+ let addTel = document.getElementById("vcard-add-tel");
+ this.registerAddButton(addTel, "tel");
+
+ let addTZ = document.getElementById("vcard-add-tz");
+ this.registerAddButton(addTZ, "tz", () => {
+ addTZ.hidden = true;
+ });
+
+ let addIMPP = document.getElementById("vcard-add-impp");
+ this.registerAddButton(addIMPP, "impp");
+
+ let addNote = document.getElementById("vcard-add-note");
+ this.registerAddButton(addNote, "note", () => {
+ addNote.hidden = true;
+ });
+
+ let addCustom = document.getElementById("vcard-add-custom");
+ addCustom.addEventListener("click", event => {
+ let el = new VCardCustomComponent();
+
+ // When the custom properties are deleted and added again ensure that
+ // the properties are set.
+ for (let i = 1; i <= 4; i++) {
+ if (!this._vCardProperties.getFirstEntry(`x-custom${i}`)) {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(`x-custom${i}`, {}, "text", "")
+ );
+ }
+ }
+
+ el.vCardPropertyEntries = [
+ this._vCardProperties.getFirstEntry("x-custom1"),
+ this._vCardProperties.getFirstEntry("x-custom2"),
+ this._vCardProperties.getFirstEntry("x-custom3"),
+ this._vCardProperties.getFirstEntry("x-custom4"),
+ ];
+ addCustom.parentNode.insertBefore(el, addCustom);
+
+ this.moveFocusIntoElement(el);
+ addCustom.hidden = true;
+ });
+
+ // Delete button for Organization Properties. This property has multiple
+ // fields, so we should dispatch the remove event only once after everything
+ // has been removed.
+ this.querySelector(
+ "#addr-book-edit-org .remove-property-button"
+ ).addEventListener("click", event => {
+ this.querySelector("vcard-title").remove();
+ this.querySelector("vcard-role").remove();
+ let org = this.querySelector("vcard-org");
+ // Reveal the "Add" button so we can focus it.
+ document.getElementById("vcard-add-org").hidden = false;
+ // Dispatch the event before removing the element so we can handle focus.
+ org.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ org.remove();
+ event.target.hidden = true;
+ });
+ }
+
+ /**
+ * Registers a click event for addButton which creates a new vCardProperty
+ * and inserts it.
+ *
+ * @param {HTMLButtonElement} addButton
+ * @param {string} VCardPropertyName RFC6350 vCard property name.
+ * @param {(vCardElement) => {}} callback For further refinement.
+ * Like different focus instead of an input field.
+ */
+ registerAddButton(addButton, VCardPropertyName, callback) {
+ addButton.addEventListener("click", event => {
+ let newVCardProperty = VCardEdit.createVCardProperty(VCardPropertyName);
+ let el = this.insertVCardElement(newVCardProperty, true);
+
+ this.moveFocusIntoElement(el);
+ if (callback) {
+ callback(el);
+ }
+ });
+ }
+
+ /**
+ * If one BDAY vCardPropertyEntry is present disable
+ * the option to change an Anniversary to a BDAY.
+ *
+ * @see VCardSpecialDateComponent
+ */
+ checkForBdayOccurrences() {
+ let bdayOccurrence = this.vCardProperties.getFirstEntry("bday");
+ this.querySelectorAll("vcard-special-date").forEach(specialDate => {
+ specialDate.birthdayAvailability({ hasBday: !!bdayOccurrence });
+ });
+ }
+
+ /**
+ * Hide the default checkbox if we only have one email field.
+ */
+ toggleDefaultEmailView() {
+ let hideDefault =
+ document.getElementById("vcard-email").children.length <= 1;
+ let defaultColumn = this.querySelector(".default-column");
+ if (defaultColumn) {
+ defaultColumn.hidden = hideDefault;
+ }
+ document.getElementById("addr-book-edit-email-default").hidden =
+ hideDefault;
+
+ // Add class to position legend absolute.
+ document
+ .getElementById("addr-book-edit-email")
+ .classList.toggle("default-table-header", !hideDefault);
+ }
+
+ /**
+ * Validate the form with the minimum required data to save or update a
+ * contact. We can't use the built-in checkValidity() since our fields
+ * are not handled properly by the form element.
+ *
+ * @returns {boolean} - If the form is valid or not.
+ */
+ checkMinimumRequirements() {
+ let hasEmail = [...document.getElementById("vcard-email").children].find(
+ s => {
+ let field = s.querySelector(`input[type="email"]`);
+ return field.value.trim() && field.checkValidity();
+ }
+ );
+ let hasOrg = [...this.querySelectorAll("vcard-org")].find(n =>
+ n.orgEl.value.trim()
+ );
+
+ return (
+ this.firstName.value.trim() ||
+ this.lastName.value.trim() ||
+ this.displayName.value.trim() ||
+ hasEmail ||
+ hasOrg
+ );
+ }
+
+ /**
+ * Validate the special date fields making sure that we have a valid
+ * DATE-AND-OR-TIME. See date, date-noreduc.
+ * That is, valid if any of the fields are valid, but the combination of
+ * only year and day is not valid.
+ *
+ * @returns {boolean} - True all created special date fields are valid.
+ * @see https://datatracker.ietf.org/doc/html/rfc6350#section-4.3.4
+ */
+ validateDates() {
+ for (let field of document.querySelectorAll("vcard-special-date")) {
+ let y = field.querySelector(`input[type="number"][name="year"]`);
+ let m = field.querySelector(`select[name="month"]`);
+ let d = field.querySelector(`select[name="day"]`);
+ if (!y.checkValidity()) {
+ y.focus();
+ return false;
+ }
+ if (y.value && d.value && !m.value) {
+ m.required = true;
+ m.focus();
+ return false;
+ }
+ }
+ return true;
+ }
+}
+customElements.define("vcard-edit", VCardEdit);
+
+/**
+ * Responsible for the type selection of a vCard property.
+ *
+ * Couples the given vCardPropertyEntry with a <select> element.
+ * This is safe because contact editing always creates a new contact, even
+ * when an existing contact is selected for editing.
+ *
+ * @see RFC6350 TYPE
+ */
+class VCardTypeSelectionComponent extends HTMLElement {
+ /**
+ * The select element created by this custom element.
+ *
+ * @type {HTMLSelectElement}
+ */
+ selectEl;
+
+ /**
+ * Initializes the type selector elements to control the given
+ * vCardPropertyEntry.
+ *
+ * @param {VCardPropertyEntry} vCardPropertyEntry - The VCardPropertyEntry
+ * this element should control.
+ * @param {boolean} [options.createLabel] - Whether a Type label should be
+ * created for the selectEl element. If this is not `true`, then the label
+ * for the selectEl should be provided through some other means, such as the
+ * labelledBy property.
+ * @param {string} [options.labelledBy] - Optional `id` of the element that
+ * should label the selectEl element (through aria-labelledby).
+ * @param {string} [options.propertyType] - Specifies the set of types that
+ * should be available and shown for the corresponding property. Set as
+ * "tel" to use the set of telephone types. Otherwise defaults to only using
+ * the `home`, `work` and `(None)` types.
+ */
+ createTypeSelection(vCardPropertyEntry, options) {
+ let template;
+ let types;
+ switch (options.propertyType) {
+ case "tel":
+ types = ["work", "home", "cell", "fax", "pager"];
+ template = document.getElementById("template-vcard-edit-type-tel");
+ break;
+ default:
+ types = ["work", "home"];
+ template = document.getElementById("template-vcard-edit-type");
+ break;
+ }
+
+ let clonedTemplate = template.content.cloneNode(true);
+ this.replaceChildren(clonedTemplate);
+
+ this.selectEl = this.querySelector("select");
+ let selectId = vCardIdGen.next().value;
+ this.selectEl.id = selectId;
+
+ // Just abandon any values we don't have UI for. We don't have any way to
+ // know whether to keep them or not, and they're very rarely used.
+ let paramsType = vCardPropertyEntry.params.type;
+ // toLowerCase is called because other vCard sources are saving the type
+ // in upper case. E.g. from Google.
+ if (Array.isArray(paramsType)) {
+ let lowerCaseTypes = paramsType.map(type => type.toLowerCase());
+ this.selectEl.value = lowerCaseTypes.find(t => types.includes(t)) || "";
+ } else if (paramsType && types.includes(paramsType.toLowerCase())) {
+ this.selectEl.value = paramsType.toLowerCase();
+ }
+
+ // Change the value on the vCardPropertyEntry.
+ this.selectEl.addEventListener("change", e => {
+ if (this.selectEl.value) {
+ vCardPropertyEntry.params.type = this.selectEl.value;
+ } else {
+ delete vCardPropertyEntry.params.type;
+ }
+ });
+
+ // Set an aria-labelledyby on the select.
+ if (options.labelledBy) {
+ if (!document.getElementById(options.labelledBy)) {
+ throw new Error(`No such label element with id ${options.labelledBy}`);
+ }
+ this.querySelector("select").setAttribute(
+ "aria-labelledby",
+ options.labelledBy
+ );
+ }
+
+ // Create a label element for the select.
+ if (options.createLabel) {
+ let labelEl = document.createElement("label");
+ labelEl.htmlFor = selectId;
+ labelEl.setAttribute("data-l10n-id", "vcard-entry-type-label");
+ labelEl.classList.add("screen-reader-only");
+ this.insertBefore(labelEl, this.selectEl);
+ }
+ }
+}
+
+customElements.define("vcard-type", VCardTypeSelectionComponent);
+
+/**
+ * Interface for vCard Fields in the edit view.
+ *
+ * @interface VCardPropertyEntryView
+ */
+
+/**
+ * Getter/Setter for rich data do not use HTMLAttributes for this.
+ * Keep the reference intact through vCardProperties for proper saving.
+ *
+ * @property
+ * @name VCardPropertyEntryView#vCardPropertyEntry
+ */
+
+/**
+ * fromUIToVCardPropertyEntry should directly change data with the reference
+ * through vCardPropertyEntry.
+ * It's there for an action to read the user input values into the
+ * vCardPropertyEntry.
+ *
+ * @function
+ * @name VCardPropertyEntryView#fromUIToVCardPropertyEntry
+ * @returns {void}
+ */
+
+/**
+ * Updates the UI accordingly to the vCardPropertyEntry.
+ *
+ * @function
+ * @name VCardPropertyEntryView#fromVCardPropertyEntryToUI
+ * @returns {void}
+ */
+
+/**
+ * Checks if the value of VCardPropertyEntry is empty.
+ *
+ * @function
+ * @name VCardPropertyEntryView#valueIsEmpty
+ * @returns {boolean}
+ */
+
+/**
+ * Creates a new VCardPropertyEntry for usage in the a new Field.
+ *
+ * @function
+ * @name VCardPropertyEntryView#newVCardPropertyEntry
+ * @static
+ * @returns {VCardPropertyEntry}
+ */
diff --git a/comm/mail/components/addrbook/content/vcard-edit/email.mjs b/comm/mail/components/addrbook/content/vcard-edit/email.mjs
new file mode 100644
index 0000000000..751399ac6c
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/email.mjs
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 EMAIL
+ */
+export class VCardEmailComponent extends HTMLTableRowElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ emailEl;
+ /** @type {HTMLInputElement} */
+ checkboxEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("email", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-email");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.emailEl = this.querySelector('input[type="email"]');
+ this.checkboxEl = this.querySelector('input[type="checkbox"]');
+
+ this.emailEl.addEventListener("input", () => {
+ // Dispatch the event only if this field is the currently selected
+ // default/preferred email address.
+ if (this.checkboxEl.checked) {
+ this.dispatchEvent(VCardEmailComponent.EmailEvent());
+ }
+ });
+
+ // Uncheck the checkbox of other VCardEmailComponents if this one is
+ // checked.
+ this.checkboxEl.addEventListener("change", event => {
+ if (event.target.checked === true) {
+ this.dispatchEvent(VCardEmailComponent.CheckboxEvent());
+ }
+ });
+
+ // Create the email type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ labelledBy: "addr-book-edit-email-type",
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ document.querySelector("vcard-edit").toggleDefaultEmailView();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.emailEl.value = this.vCardPropertyEntry.value;
+
+ let pref = this.vCardPropertyEntry.params.pref;
+ if (pref === "1") {
+ this.checkboxEl.checked = true;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.emailEl.value;
+
+ if (this.checkboxEl.checked) {
+ this.vCardPropertyEntry.params.pref = "1";
+ } else if (
+ this.vCardPropertyEntry.params.pref &&
+ this.vCardPropertyEntry.params.pref === "1"
+ ) {
+ // Only delete the pref if a pref of 1 is set and the checkbox is not
+ // checked. The pref mechanic is not fully supported yet. Leave all other
+ // prefs untouched.
+ delete this.vCardPropertyEntry.params.pref;
+ }
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ /**
+ * This event is fired when the checkbox is checked and we need to uncheck the
+ * other checkboxes from each VCardEmailComponent.
+ * FIXME: This should be a radio button part of radiogroup.
+ *
+ * @returns {CustomEvent}
+ */
+ static CheckboxEvent() {
+ return new CustomEvent("vcard-email-default-checkbox", {
+ detail: {},
+ bubbles: true,
+ });
+ }
+
+ /**
+ * This event is fired when the value of an email input field is changed. The
+ * event is fired only if the current email si set as default/preferred.
+ *
+ * @returns {CustomEvent}
+ */
+ static EmailEvent() {
+ return new CustomEvent("vcard-email-default-changed", {
+ detail: {},
+ bubbles: true,
+ });
+ }
+}
+
+customElements.define("vcard-email", VCardEmailComponent, { extends: "tr" });
diff --git a/comm/mail/components/addrbook/content/vcard-edit/fn.mjs b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs
new file mode 100644
index 0000000000..446a262f28
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/fn.mjs
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 FN
+ */
+export class VCardFNComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLElement} */
+ displayEl;
+ /** @type {HTMLElement} */
+ preferDisplayEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("fn", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-fn");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.displayEl = this.querySelector("#vCardDisplayName");
+ this.displayEl.addEventListener(
+ "input",
+ () => {
+ this.displayEl.isDirty = true;
+ },
+ { once: true }
+ );
+ this.preferDisplayEl = this.querySelector("#vCardPreferDisplayName");
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.displayEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.displayEl.value = this.vCardPropertyEntry.value;
+ this.displayEl.isDirty = !!this.displayEl.value.trim();
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.displayEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+customElements.define("vcard-fn", VCardFNComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs
new file mode 100644
index 0000000000..b4ce37bfda
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/id-gen.mjs
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function* vCardHtmlIdGen() {
+ let internalId = 0;
+ while (true) {
+ yield `vcard-id-${internalId++}`;
+ }
+}
+
+export let vCardIdGen = vCardHtmlIdGen();
diff --git a/comm/mail/components/addrbook/content/vcard-edit/impp.mjs b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs
new file mode 100644
index 0000000000..232925942e
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/impp.mjs
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 IMPP
+ */
+export class VCardIMPPComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ imppEl;
+ /** @type {HTMLSelectElement} */
+ protocolEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("impp", {}, "uri", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-impp");
+ this.appendChild(template.content.cloneNode(true));
+
+ this.imppEl = this.querySelector('input[name="impp"]');
+ document.l10n
+ .formatValue("vcard-impp-input-title")
+ .then(t => (this.imppEl.title = t));
+
+ this.protocolEl = this.querySelector('select[name="protocol"]');
+ this.protocolEl.id = vCardIdGen.next().value;
+
+ let protocolLabel = this.querySelector('label[for="protocol"]');
+ protocolLabel.htmlFor = this.protocolEl.id;
+
+ this.protocolEl.addEventListener("change", event => {
+ let entered = this.imppEl.value.split(":", 1)[0]?.toLowerCase();
+ if (entered) {
+ this.protocolEl.value =
+ [...this.protocolEl.options].find(o => o.value.startsWith(entered))
+ ?.value || "";
+ }
+ this.imppEl.placeholder = this.protocolEl.value;
+ this.imppEl.pattern = this.protocolEl.selectedOptions[0].dataset.pattern;
+ });
+
+ this.imppEl.id = vCardIdGen.next().value;
+ let imppLabel = this.querySelector('label[for="impp"]');
+ imppLabel.htmlFor = this.imppEl.id;
+ document.l10n.setAttributes(imppLabel, "vcard-impp-label");
+ this.imppEl.addEventListener("change", event => {
+ this.protocolEl.dispatchEvent(new CustomEvent("change"));
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ this.imppEl.dispatchEvent(new CustomEvent("change"));
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.imppEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.imppEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-impp", VCardIMPPComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/n.mjs b/comm/mail/components/addrbook/content/vcard-edit/n.mjs
new file mode 100644
index 0000000000..ae5d386d93
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/n.mjs
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 N
+ */
+export class VCardNComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLElement} */
+ prefixEl;
+ /** @type {HTMLElement} */
+ firstNameEl;
+ /** @type {HTMLElement} */
+ middleNameEl;
+ /** @type {HTMLElement} */
+ lastNameEl;
+ /** @type {HTMLElement} */
+ suffixEl;
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-n");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.registerListComponents();
+ this.fromVCardPropertyEntryToUI();
+ this.sortAsOrder();
+ }
+ }
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("n", {}, "text", ["", "", "", "", ""]);
+ }
+
+ /**
+ * Assigns the vCardPropertyEntry values to the individual
+ * NListComponentText elements.
+ *
+ * @TODO sort-as param should be used for the order.
+ * The use-case is that not every language has the order of
+ * prefix, firstName, middleName, lastName, suffix.
+ * Aswell that the user is able to change the sorting as he like
+ * on a per contact base.
+ */
+ sortAsOrder() {
+ if (!this.vCardPropertyEntry.params["sort-as"]) {
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ /**
+ * @TODO
+ * The sort-as DOM Mutation
+ */
+ }
+
+ fromVCardPropertyEntryToUI() {
+ let prefixVal = this.vCardPropertyEntry.value[3] || "";
+ let prefixInput = this.prefixEl.querySelector("input");
+ prefixInput.value = prefixVal;
+ if (prefixVal) {
+ this.prefixEl.querySelector("button").hidden = true;
+ } else {
+ this.prefixEl.classList.add("hasButton");
+ this.prefixEl.querySelector("label").hidden = true;
+ prefixInput.hidden = true;
+ }
+
+ // First Name is always shown.
+ this.firstNameEl.querySelector("input").value =
+ this.vCardPropertyEntry.value[1] || "";
+
+ let middleNameVal = this.vCardPropertyEntry.value[2] || "";
+ let middleNameInput = this.middleNameEl.querySelector("input");
+ middleNameInput.value = middleNameVal;
+ if (middleNameVal) {
+ this.middleNameEl.querySelector("button").hidden = true;
+ } else {
+ this.middleNameEl.classList.add("hasButton");
+ this.middleNameEl.querySelector("label").hidden = true;
+ middleNameInput.hidden = true;
+ }
+
+ // Last Name is always shown.
+ this.lastNameEl.querySelector("input").value =
+ this.vCardPropertyEntry.value[0] || "";
+
+ let suffixVal = this.vCardPropertyEntry.value[4] || "";
+ let suffixInput = this.suffixEl.querySelector("input");
+ suffixInput.value = suffixVal;
+ if (suffixVal) {
+ this.suffixEl.querySelector("button").hidden = true;
+ } else {
+ this.suffixEl.classList.add("hasButton");
+ this.suffixEl.querySelector("label").hidden = true;
+ suffixInput.hidden = true;
+ }
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = [
+ this.lastNameEl.querySelector("input").value,
+ this.firstNameEl.querySelector("input").value,
+ this.middleNameEl.querySelector("input").value,
+ this.prefixEl.querySelector("input").value,
+ this.suffixEl.querySelector("input").value,
+ ];
+ }
+
+ valueIsEmpty() {
+ let noEmptyStrings = [
+ this.prefixEl,
+ this.firstNameEl,
+ this.middleNameEl,
+ this.lastNameEl,
+ this.suffixEl,
+ ].filter(node => {
+ return node.querySelector("input").value !== "";
+ });
+ return noEmptyStrings.length === 0;
+ }
+
+ registerListComponents() {
+ this.prefixEl = this.querySelector("#n-list-component-prefix");
+ let prefixInput = this.prefixEl.querySelector("input");
+ let prefixButton = this.prefixEl.querySelector("button");
+ prefixButton.addEventListener("click", e => {
+ this.prefixEl.querySelector("label").hidden = false;
+ prefixInput.hidden = false;
+ prefixButton.hidden = true;
+ this.prefixEl.classList.remove("hasButton");
+ prefixInput.focus();
+ });
+
+ this.firstNameEl = this.querySelector("#n-list-component-firstname");
+
+ this.middleNameEl = this.querySelector("#n-list-component-middlename");
+ let middleNameInput = this.middleNameEl.querySelector("input");
+ let middleNameButton = this.middleNameEl.querySelector("button");
+ middleNameButton.addEventListener("click", e => {
+ this.middleNameEl.querySelector("label").hidden = false;
+ middleNameInput.hidden = false;
+ middleNameButton.hidden = true;
+ this.middleNameEl.classList.remove("hasButton");
+ middleNameInput.focus();
+ });
+
+ this.lastNameEl = this.querySelector("#n-list-component-lastname");
+
+ this.suffixEl = this.querySelector("#n-list-component-suffix");
+ let suffixInput = this.suffixEl.querySelector("input");
+ let suffixButton = this.suffixEl.querySelector("button");
+ suffixButton.addEventListener("click", e => {
+ this.suffixEl.querySelector("label").hidden = false;
+ suffixInput.hidden = false;
+ suffixButton.hidden = true;
+ this.suffixEl.classList.remove("hasButton");
+ suffixInput.focus();
+ });
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.prefixEl = null;
+ this.firstNameEl = null;
+ this.middleNameEl = null;
+ this.lastNameEl = null;
+ this.suffixEl = null;
+ }
+ }
+}
+customElements.define("vcard-n", VCardNComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs
new file mode 100644
index 0000000000..3622b28997
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/nickname.mjs
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 NICKNAME
+ */
+export class VCardNickNameComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+ /** @type {HTMLElement} */
+ nickNameEl;
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-nickname");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("nickname", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.nickNameEl = this.querySelector("#vCardNickName");
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.nickNameEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.nickNameEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.nickNameEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+customElements.define("vcard-nickname", VCardNickNameComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/note.mjs b/comm/mail/components/addrbook/content/vcard-edit/note.mjs
new file mode 100644
index 0000000000..f78f4a16d8
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/note.mjs
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 Note
+ */
+export class VCardNoteComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLTextAreaElement} */
+ textAreaEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("note", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-note");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.textAreaEl = this.querySelector("textarea");
+ this.textAreaEl.addEventListener("input", () => {
+ this.resizeTextAreaEl();
+ });
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-note").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.textAreaEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.textAreaEl.value = this.vCardPropertyEntry.value;
+ this.resizeTextAreaEl();
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.textAreaEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ resizeTextAreaEl() {
+ this.textAreaEl.rows = Math.min(
+ 15,
+ Math.max(5, this.textAreaEl.value.split("\n").length)
+ );
+ }
+}
+
+customElements.define("vcard-note", VCardNoteComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/org.mjs b/comm/mail/components/addrbook/content/vcard-edit/org.mjs
new file mode 100644
index 0000000000..fb788c3043
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/org.mjs
@@ -0,0 +1,197 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 TITLE
+ */
+export class VCardTitleComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ titleEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("title", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-title");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.titleEl = this.querySelector('input[name="title"]');
+ this.assignIds(this.titleEl, this.querySelector('label[for="title"]'));
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.vCardPropertyEntry = null;
+ this.titleEl = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.titleEl.value = this.vCardPropertyEntry.value || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.titleEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+}
+customElements.define("vcard-title", VCardTitleComponent);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ROLE
+ */
+export class VCardRoleComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ roleEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("role", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-role");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.roleEl = this.querySelector('input[name="role"]');
+ this.assignIds(this.roleEl, this.querySelector('label[for="role"]'));
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.vCardPropertyEntry = null;
+ this.roleEl = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.roleEl.value = this.vCardPropertyEntry.value || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.roleEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+
+ assignIds(inputEl, labelEl) {
+ let labelInputId = vCardIdGen.next().value;
+ inputEl.id = labelInputId;
+ labelEl.htmlFor = labelInputId;
+ }
+}
+customElements.define("vcard-role", VCardRoleComponent);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ORG
+ */
+export class VCardOrgComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+ /** @type {HTMLInputElement} */
+ orgEl;
+ /** @type {HTMLInputElement} */
+ unitEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("org", {}, "text", ["", ""]);
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-org");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.orgEl = this.querySelector('input[name="org"]');
+ this.orgEl.id = vCardIdGen.next().value;
+ this.querySelector('label[for="org"]').htmlFor = this.orgEl.id;
+
+ this.unitEl = this.querySelector('input[name="orgUnit"]');
+ this.unitEl.id = vCardIdGen.next().value;
+ this.querySelector('label[for="orgUnit"]').htmlFor = this.unitEl.id;
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ let values = this.vCardPropertyEntry.value;
+ if (!values) {
+ this.orgEl.value = "";
+ this.unitEl.value = "";
+ return;
+ }
+ if (!Array.isArray(values)) {
+ values = [values];
+ }
+ this.orgEl.value = values.shift() || "";
+ // In case data had more levels of units, just pull them together.
+ this.unitEl.value = values.join(", ");
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = [this.orgEl.value.trim()];
+ if (this.unitEl.value.trim()) {
+ this.vCardPropertyEntry.value.push(this.unitEl.value.trim());
+ }
+ }
+
+ valueIsEmpty() {
+ return (
+ !this.vCardPropertyEntry.value ||
+ (Array.isArray(this.vCardPropertyEntry.value) &&
+ this.vCardPropertyEntry.value.every(v => v === ""))
+ );
+ }
+}
+customElements.define("vcard-org", VCardOrgComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs
new file mode 100644
index 0000000000..17c7df493b
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/special-date.mjs
@@ -0,0 +1,269 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+/**
+ * ANNIVERSARY and BDAY both have a cardinality of
+ * 1 ("Exactly one instance per vCard MAY be present.").
+ *
+ * For Anniversary we changed the cardinality to
+ * ("One or more instances per vCard MAY be present.")".
+ *
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 ANNIVERSARY and BDAY
+ */
+export class VCardSpecialDateComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLSelectElement} */
+ selectEl;
+ /** @type {HTMLInputElement} */
+ year;
+ /** @type {HTMLSelectElement} */
+ month;
+ /** @type {HTMLSelectElement} */
+ day;
+
+ /**
+ * Object containing the available days for each month.
+ *
+ * @type {object}
+ */
+ monthDays = {
+ 1: 31,
+ 2: 28,
+ 3: 31,
+ 4: 30,
+ 5: 31,
+ 6: 30,
+ 7: 31,
+ 8: 31,
+ 9: 30,
+ 10: 31,
+ 11: 30,
+ 12: 31,
+ };
+
+ static newAnniversaryVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("anniversary", {}, "date", "");
+ }
+
+ static newBdayVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("bday", {}, "date", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById(
+ "template-vcard-edit-bday-anniversary"
+ );
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.selectEl = this.querySelector(".vcard-type-selection");
+ let selectId = vCardIdGen.next().value;
+ this.selectEl.id = selectId;
+ this.querySelector(".vcard-type-label").htmlFor = selectId;
+
+ this.selectEl.addEventListener("change", event => {
+ this.dispatchEvent(
+ VCardSpecialDateComponent.ChangeVCardPropertyEntryEvent(
+ event.target.value
+ )
+ );
+ });
+
+ this.month = this.querySelector("#month");
+ let monthId = vCardIdGen.next().value;
+ this.month.id = monthId;
+ this.querySelector('label[for="month"]').htmlFor = monthId;
+ this.month.addEventListener("change", () => {
+ this.fillDayOptions();
+ });
+
+ this.day = this.querySelector("#day");
+ let dayId = vCardIdGen.next().value;
+ this.day.id = dayId;
+ this.querySelector('label[for="day"]').htmlFor = dayId;
+
+ this.year = this.querySelector("#year");
+ let yearId = vCardIdGen.next().value;
+ this.year.id = yearId;
+ this.querySelector('label[for="year"]').htmlFor = yearId;
+ this.year.addEventListener("input", () => {
+ this.fillDayOptions();
+ });
+
+ document.l10n.formatValues([{ id: "vcard-date-year" }]).then(yearLabel => {
+ this.year.placeholder = yearLabel;
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fillMonthOptions();
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.selectEl.value = this.vCardPropertyEntry.name;
+ if (this.vCardPropertyEntry.type === "text") {
+ // TODO: support of text type for special-date
+ this.hidden = true;
+ return;
+ }
+ // Default value is date-and-or-time.
+ let dateValue;
+ try {
+ dateValue = ICAL.VCardTime.fromDateAndOrTimeString(
+ this.vCardPropertyEntry.value || "",
+ "date-and-or-time"
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ // Always set the month first since that controls the available days.
+ this.month.value = dateValue?.month || "";
+ this.fillDayOptions();
+ this.day.value = dateValue?.day || "";
+ this.year.value = dateValue?.year || "";
+ }
+
+ fromUIToVCardPropertyEntry() {
+ if (this.vCardPropertyEntry.type === "text") {
+ // TODO: support of text type for special-date
+ return;
+ }
+ // Default value is date-and-or-time.
+ let dateValue = new ICAL.VCardTime({}, null, "date");
+ // Set the properties directly instead of using the VCardTime
+ // constructor argument, which causes null values to become 0.
+ dateValue.year = this.year.value ? Number(this.year.value) : null;
+ dateValue.month = this.month.value ? Number(this.month.value) : null;
+ dateValue.day = this.day.value ? Number(this.day.value) : null;
+ this.vCardPropertyEntry.value = dateValue.toString();
+ }
+
+ valueIsEmpty() {
+ return !this.year.value && !this.month.value && !this.day.value;
+ }
+
+ /**
+ * @param {"bday" | "anniversary"} entryName
+ * @returns {CustomEvent}
+ */
+ static ChangeVCardPropertyEntryEvent(entryName) {
+ return new CustomEvent("vcard-bday-anniversary-change", {
+ detail: {
+ name: entryName,
+ },
+ bubbles: true,
+ });
+ }
+
+ /**
+ * Check if the specified year is a leap year in order to add or remove the
+ * extra day to February.
+ *
+ * @returns {boolean} True if the currently specified year is a leap year,
+ * or if no valid year value is available.
+ */
+ isLeapYear() {
+ // If the year is empty, we can't know if it's a leap year so must assume
+ // it is. Otherwise year-less dates can't show Feb 29.
+ if (!this.year.checkValidity() || this.year.value === "") {
+ return true;
+ }
+
+ let year = parseInt(this.year.value);
+ return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
+ }
+
+ fillMonthOptions() {
+ let formatter = Intl.DateTimeFormat(undefined, { month: "long" });
+ for (let m = 1; m <= 12; m++) {
+ let option = document.createElement("option");
+ option.setAttribute("value", m);
+ option.setAttribute("label", formatter.format(new Date(2000, m - 1, 2)));
+ this.month.appendChild(option);
+ }
+ }
+
+ /**
+ * Update the Day select element to reflect the available days of the selected
+ * month.
+ */
+ fillDayOptions() {
+ let prevDay = 0;
+ // Save the previously selected day if we have one.
+ if (this.day.childNodes.length > 1) {
+ prevDay = this.day.value;
+ }
+
+ // Always clear old options.
+ let defaultOption = document.createElement("option");
+ defaultOption.value = "";
+ document.l10n
+ .formatValues([{ id: "vcard-date-day" }])
+ .then(([dayLabel]) => {
+ defaultOption.textContent = dayLabel;
+ });
+ this.day.replaceChildren(defaultOption);
+
+ let monthValue = this.month.value || 1;
+ // Add a day to February if this is a leap year and we're in February.
+ if (monthValue === "2") {
+ this.monthDays["2"] = this.isLeapYear() ? 29 : 28;
+ }
+
+ let formatter = Intl.DateTimeFormat(undefined, { day: "numeric" });
+ for (let d = 1; d <= this.monthDays[monthValue]; d++) {
+ let option = document.createElement("option");
+ option.setAttribute("value", d);
+ option.setAttribute("label", formatter.format(new Date(2000, 0, d)));
+ this.day.appendChild(option);
+ }
+ // Reset the previously selected day, if it's available in the currently
+ // selected month.
+ this.day.value = prevDay <= this.monthDays[monthValue] ? prevDay : "";
+ }
+
+ /**
+ * @param {boolean} options.hasBday
+ */
+ birthdayAvailability(options) {
+ if (this.vCardPropertyEntry.name === "bday") {
+ return;
+ }
+ Array.from(this.selectEl.options).forEach(option => {
+ if (option.value === "bday") {
+ option.disabled = options.hasBday;
+ }
+ });
+ }
+}
+
+customElements.define("vcard-special-date", VCardSpecialDateComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/tel.mjs b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs
new file mode 100644
index 0000000000..a5eb30c6d5
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/tel.mjs
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 TEL
+ *
+ * @TODO missing type-param-tel support.
+ * "text, voice, video, textphone"
+ */
+export class VCardTelComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ inputElement;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("tel", {}, "text", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-tel");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.inputElement = this.querySelector('input[type="text"]');
+ let urlId = vCardIdGen.next().value;
+ this.inputElement.id = urlId;
+ let urlLabel = this.querySelector('label[for="text"]');
+ urlLabel.htmlFor = urlId;
+ document.l10n.setAttributes(urlLabel, "vcard-tel-label");
+ this.inputElement.type = "tel";
+
+ // Create the tel type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ propertyType: "tel",
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.inputElement.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.inputElement.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-tel", VCardTelComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/tz.mjs b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs
new file mode 100644
index 0000000000..cf77114db6
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/tz.mjs
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "cal",
+ "resource:///modules/calendar/calUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 URL
+ */
+export class VCardTZComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLSelectElement} */
+ selectEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("tz", {}, "text", "");
+ }
+
+ constructor() {
+ super();
+ let template = document.getElementById("template-vcard-edit-tz");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.selectEl = this.querySelector("select");
+ for (let tzid of lazy.cal.timezoneService.timezoneIds) {
+ let option = this.selectEl.appendChild(
+ document.createElement("option")
+ );
+ option.value = tzid;
+ option.textContent =
+ lazy.cal.timezoneService.getTimezone(tzid).displayName;
+ }
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ document.getElementById("vcard-add-tz").hidden = false;
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+ }
+
+ disconnectedCallback() {
+ if (!this.isConnected) {
+ this.selectEl = null;
+ this.vCardPropertyEntry = null;
+ }
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.selectEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.selectEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-tz", VCardTZComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/url.mjs b/comm/mail/components/addrbook/content/vcard-edit/url.mjs
new file mode 100644
index 0000000000..98a1b42951
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/url.mjs
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { vCardIdGen } from "./id-gen.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "VCardPropertyEntry",
+ "resource:///modules/VCardUtils.jsm"
+);
+
+/**
+ * @implements {VCardPropertyEntryView}
+ * @see RFC6350 URL
+ */
+export class VCardURLComponent extends HTMLElement {
+ /** @type {VCardPropertyEntry} */
+ vCardPropertyEntry;
+
+ /** @type {HTMLInputElement} */
+ urlEl;
+
+ static newVCardPropertyEntry() {
+ return new lazy.VCardPropertyEntry("url", {}, "uri", "");
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ let template = document.getElementById("template-vcard-edit-type-text");
+ let clonedTemplate = template.content.cloneNode(true);
+ this.appendChild(clonedTemplate);
+
+ this.urlEl = this.querySelector('input[type="text"]');
+ let urlId = vCardIdGen.next().value;
+ this.urlEl.id = urlId;
+ let urlLabel = this.querySelector('label[for="text"]');
+ urlLabel.htmlFor = urlId;
+ this.urlEl.type = "url";
+ document.l10n.setAttributes(urlLabel, "vcard-url-label");
+
+ this.urlEl.addEventListener("input", () => {
+ // Auto add https:// if the url is missing scheme.
+ if (
+ this.urlEl.value.length > "https://".length &&
+ !/^https?:\/\//.test(this.urlEl.value)
+ ) {
+ this.urlEl.value = "https://" + this.urlEl.value;
+ }
+ });
+
+ // Create the url type selection.
+ this.vCardType = this.querySelector("vcard-type");
+ this.vCardType.createTypeSelection(this.vCardPropertyEntry, {
+ createLabel: true,
+ });
+
+ this.querySelector(".remove-property-button").addEventListener(
+ "click",
+ () => {
+ this.dispatchEvent(
+ new CustomEvent("vcard-remove-property", { bubbles: true })
+ );
+ this.remove();
+ }
+ );
+
+ this.fromVCardPropertyEntryToUI();
+ }
+
+ fromVCardPropertyEntryToUI() {
+ this.urlEl.value = this.vCardPropertyEntry.value;
+ }
+
+ fromUIToVCardPropertyEntry() {
+ this.vCardPropertyEntry.value = this.urlEl.value;
+ }
+
+ valueIsEmpty() {
+ return this.vCardPropertyEntry.value === "";
+ }
+}
+
+customElements.define("vcard-url", VCardURLComponent);
diff --git a/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml
new file mode 100644
index 0000000000..56d53f57f1
--- /dev/null
+++ b/comm/mail/components/addrbook/content/vcard-edit/vCardTemplates.inc.xhtml
@@ -0,0 +1,398 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!-- Styles -->
+<link rel="stylesheet" href="chrome://messenger/skin/vcard.css" />
+
+<!-- Scripts -->
+<script type="module" src="chrome://messenger/content/addressbook/edit/edit.mjs"></script>
+
+<!-- Localization -->
+<link rel="localization" href="messenger/addressbook/vcard.ftl" />
+
+<!-- Edit View -->
+<template id="template-addr-book-edit">
+ <!-- Name -->
+ <fieldset id="addr-book-edit-n" class="addr-book-edit-fieldset fieldset-reset">
+ <legend class="screen-reader-only" data-l10n-id="vcard-name-header"/>
+ <div class="addr-book-edit-display-nickname">
+ </div>
+ </fieldset>
+ <fieldset id="addr-book-edit-email" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-email-header"/>
+ <table>
+ <thead>
+ <tr>
+ <th id="addr-book-edit-email-type" scope="col">
+ <!-- NOTE: We use the <span> so we can apply the screen-reader-only
+ - class to the <span> rather than the <th> element. If we apply
+ - the class to the <th> element directly it causes problems with
+ - Orca's "browse mode" table navigation. See bug 1776644. -->
+ <span class="screen-reader-only"
+ data-l10n-id="vcard-entry-type-label">
+ </span>
+ </th>
+ <th id="addr-book-edit-email-label" scope="col">
+ <span class="screen-reader-only"
+ data-l10n-id="vcard-email-label">
+ </span>
+ </th>
+ <th id="addr-book-edit-email-default" scope="col">
+ <span data-l10n-id="vcard-primary-email-label"></span>
+ </th>
+ </tr>
+ </thead>
+ <tbody id="vcard-email"></tbody>
+ </table>
+ <button id="vcard-add-email"
+ data-l10n-id="vcard-email-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- URL -->
+ <fieldset id="addr-book-edit-url" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-url-header"/>
+ <button id="vcard-add-url"
+ data-l10n-id="vcard-url-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Address -->
+ <fieldset id="addr-book-edit-address" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-adr-header"/>
+ <button id="vcard-add-adr"
+ data-l10n-id="vcard-adr-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Tel -->
+ <fieldset id="addr-book-edit-tel" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-tel-header"/>
+ <button id="vcard-add-tel"
+ data-l10n-id="vcard-tel-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Time Zone -->
+ <fieldset id="addr-book-edit-tz" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-tz-header"/>
+ <button id="vcard-add-tz"
+ data-l10n-id="vcard-tz-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- IMPP (Chat) -->
+ <fieldset id="addr-book-edit-impp" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-impp2-header"/>
+ <button id="vcard-add-impp"
+ data-l10n-id="vcard-impp-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Birthday and Anniversary (Special dates) -->
+ <fieldset id="addr-book-edit-bday-anniversary" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-bday-anniversary-header"/>
+ <button id="vcard-add-bday-anniversary"
+ data-l10n-id="vcard-bday-anniversary-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Notes -->
+ <fieldset id="addr-book-edit-note" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-note-header"/>
+ <button id="vcard-add-note"
+ data-l10n-id="vcard-note-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+ <!-- Organization Info -->
+ <fieldset id="addr-book-edit-org" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-org-header"/>
+ <button id="vcard-add-org"
+ data-l10n-id="vcard-org-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"
+ hidden="hidden"></button>
+ </fieldset>
+ <!-- Custom -->
+ <fieldset id="addr-book-edit-custom" class="addr-book-edit-fieldset fieldset-reset">
+ <legend data-l10n-id="vcard-custom-header"/>
+ <button id="vcard-add-custom"
+ data-l10n-id="vcard-custom-add"
+ class="addr-book-edit-fieldset-button add-property-button icon-button"
+ type="button"></button>
+ </fieldset>
+</template>
+
+<!-- Individual fields -->
+
+<!-- N field -->
+<template id="template-vcard-edit-n">
+ <div id="n-list-component-prefix" class="n-list-component">
+ <label for="vcard-n-prefix" data-l10n-id="vcard-n-prefix" />
+ <input id="vcard-n-prefix"
+ type="text"
+ autocomplete="off" />
+ <button class="primary" data-l10n-id="vcard-n-add-prefix"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ </div>
+ <div id="n-list-component-firstname" class="n-list-component">
+ <label for="vcard-n-firstname" data-l10n-id="vcard-n-firstname" />
+ <input id="vcard-n-firstname"
+ type="text"
+ autocomplete="off" />
+ </div>
+ <div id="n-list-component-middlename" class="n-list-component">
+ <label for="vcard-n-middlename" data-l10n-id="vcard-n-middlename" />
+ <input id="vcard-n-middlename"
+ type="text"
+ autocomplete="off" />
+ <button class="primary" data-l10n-id="vcard-n-add-middlename"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ </div>
+ <div id="n-list-component-lastname" class="n-list-component">
+ <label for="vcard-n-lastname" data-l10n-id="vcard-n-lastname" />
+ <input id="vcard-n-lastname"
+ type="text"
+ autocomplete="off" />
+ </div>
+ <div id="n-list-component-suffix" class="n-list-component">
+ <label for="vcard-n-suffix" data-l10n-id="vcard-n-suffix" />
+ <button class="primary" data-l10n-id="vcard-n-add-suffix"
+ type="button">
+ <img src="chrome://global/skin/icons/add.svg" alt="" />
+ </button>
+ <input id="vcard-n-suffix"
+ type="text"
+ autocomplete="off" />
+ </div>
+</template>
+
+<!-- FN field. -->
+<template id="template-vcard-edit-fn">
+ <label for="vCardDisplayName" data-l10n-id="vcard-displayname"></label>
+ <input id="vCardDisplayName" type="text"/>
+ <label id="vCardDisplayNameCheckbox" class="vcard-checkbox">
+ <!-- There is no l10n ID on this element because the vCard edit form is
+ also used in other sections that don't use this checkbox and don't have
+ access to the fluent string. The string is added when needed by the
+ address book edit.js file. -->
+ <input type="checkbox" id="vCardPreferDisplayName" checked="checked" />
+ <!-- SPAN element needed for fluent string. -->
+ <span></span>
+ </label>
+</template>
+
+<!-- NICKNAME field. -->
+<template id="template-vcard-edit-nickname">
+ <label for="vCardNickName" data-l10n-id="vcard-nickname"></label>
+ <input id="vCardNickName" type="text"/>
+</template>
+
+<!-- Email -->
+<template id="template-vcard-edit-email">
+ <td>
+ <vcard-type></vcard-type>
+ </td>
+ <td class="email-column">
+ <input type="email"
+ aria-labelledby="addr-book-edit-email-label" />
+ </td>
+ <td class="default-column">
+ <input type="checkbox"
+ aria-labelledby="addr-book-edit-email-default" />
+ </td>
+ <td>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+ </td>
+</template>
+
+<!-- Phone -->
+<template id="template-vcard-edit-tel">
+ <vcard-type></vcard-type>
+ <label class="screen-reader-only" for="text"/>
+ <input type="text"/>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Field with type and text -->
+<template id="template-vcard-edit-type-text">
+ <vcard-type></vcard-type>
+ <label class="screen-reader-only" for="text"/>
+ <input type="text"/>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Time Zone -->
+<template id="template-vcard-edit-tz">
+ <select>
+ <option value=""></option>
+ </select>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- IMPP -->
+<template id="template-vcard-edit-impp">
+ <label class="screen-reader-only" for="protocol" data-l10n-id="vcard-impp-select"></label>
+ <select name="protocol" class="vcard-type-selection">
+ <option value="matrix:u/john:example.org" data-pattern="matrix:.+/.+:.+">Matrix</option>
+ <option value="xmpp:john@example.org" data-pattern="xmpp:.+@.+">XMPP</option>
+ <option value="ircs://irc.example.org/john,isuser" data-pattern="ircs?://.+/.+,.+">IRC</option>
+ <option value="sip:1-555-123-4567@voip.example.org" data-pattern="sip:.+@.+">SIP</option>
+ <option value="skype:johndoe" data-pattern="skype:[A-Za-z\d\-\._]{6,32}">Skype</option>
+ <option value="" data-l10n-id="vcard-impp-option-other" data-pattern="..+:..+"></option>
+ </select>
+ <label class="screen-reader-only" for="impp" data-l10n-id="vcard-impp-input-label"></label>
+ <input type="text" name="impp" pattern="..+:..+" />
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+</template>
+
+<!-- Birthday and Anniversary -->
+<template id="template-vcard-edit-bday-anniversary">
+ <label class="vcard-type-label screen-reader-only"
+ data-l10n-id="vcard-entry-type-label"></label>
+ <select class="vcard-type-selection">
+ <option value="bday" data-l10n-id="vcard-bday-label" selected="selected"/>
+ <option value="anniversary" data-l10n-id="vcard-anniversary-label"/>
+ </select>
+
+ <div class="vcard-year-month-day-container">
+ <label class="screen-reader-only" for="year" data-l10n-id="vcard-date-year"></label>
+ <input id="year" name="year" type="number" min="1000" max="9999" pattern="[0-9]{4}" class="size5" />
+
+ <label class="screen-reader-only" for="month" data-l10n-id="vcard-date-month"></label>
+ <select id="month" name="month" class="vcard-month-select">
+ <option value="" data-l10n-id="vcard-date-month" selected="selected"></option>
+ </select>
+
+ <label class="screen-reader-only" for="day" data-l10n-id="vcard-date-day"></label>
+ <select id="day" name="day" class="vcard-day-select">
+ <option value="" data-l10n-id="vcard-date-day" selected="selected"></option>
+ </select>
+
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button-title"></button>
+ </div>
+</template>
+
+<!-- Address -->
+<template id="template-vcard-edit-adr">
+ <fieldset class="fieldset-grid fieldset-reset">
+ <legend class="screen-reader-only" data-l10n-id="vcard-adr-label"/>
+ <vcard-type></vcard-type>
+ <div class="vcard-adr-inputs">
+ <label for="street" data-l10n-id="vcard-adr-street"/>
+ <textarea name="street"></textarea>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="locality" data-l10n-id="vcard-adr-locality"/>
+ <input type="text" name="locality"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="region" data-l10n-id="vcard-adr-region"/>
+ <input type="text" name="region"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="code" data-l10n-id="vcard-adr-code"/>
+ <input type="text" name="code"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="country" data-l10n-id="vcard-adr-country"/>
+ <input type="text" name="country"/>
+ </div>
+ </fieldset>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- Notes -->
+<template id="template-vcard-edit-note">
+ <textarea></textarea>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<!-- Organization Info -->
+<template id="template-vcard-edit-title">
+ <div class="vcard-adr-inputs">
+ <label for="title" data-l10n-id="vcard-org-title"/>
+ <input type="text" data-l10n-id="vcard-org-title-input" name="title" />
+ </div>
+</template>
+<template id="template-vcard-edit-role">
+ <div class="vcard-adr-inputs">
+ <label for="role" data-l10n-id="vcard-org-role"/>
+ <input type="text" data-l10n-id="vcard-org-role-input" name="role" />
+ </div>
+</template>
+<template id="template-vcard-edit-org">
+ <div class="vcard-adr-inputs">
+ <label for="org" data-l10n-id="vcard-org-org" />
+ <input type="text" name="org" data-l10n-id="vcard-org-org-input" />
+ <label for="orgUnit" data-l10n-id="vcard-org-org-unit" class="screen-reader-only"/>
+ <input type="text" name="orgUnit" data-l10n-id="vcard-org-org-unit-input" />
+ </div>
+</template>
+
+<!-- Custom -->
+<template id="template-vcard-edit-custom">
+ <div class="vcard-adr-inputs">
+ <label for="custom1"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom2"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom3"/>
+ <input type="text"/>
+ </div>
+ <div class="vcard-adr-inputs">
+ <label for="custom4"/>
+ <input type="text"/>
+ </div>
+ <button type="button"
+ class="addr-book-edit-fieldset-button remove-property-button icon-button"
+ data-l10n-id="vcard-remove-button"></button>
+</template>
+
+<template id="template-vcard-edit-type">
+ <select class="vcard-type-selection">
+ <option value="work" data-l10n-id="vcard-entry-type-work"/>
+ <option value="home" data-l10n-id="vcard-entry-type-home"/>
+ <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/>
+ </select>
+</template>
+
+<template id="template-vcard-edit-type-tel">
+ <select class="vcard-type-selection">
+ <option value="work" data-l10n-id="vcard-entry-type-work"/>
+ <option value="home" data-l10n-id="vcard-entry-type-home"/>
+ <option value="cell" data-l10n-id="vcard-entry-type-cell"/>
+ <option value="fax" data-l10n-id="vcard-entry-type-fax"/>
+ <option value="pager" data-l10n-id="vcard-entry-type-pager"/>
+ <option value="" data-l10n-id="vcard-entry-type-none" selected="selected"/>
+ </select>
+</template>
diff --git a/comm/mail/components/addrbook/jar.mn b/comm/mail/components/addrbook/jar.mn
new file mode 100644
index 0000000000..48d6cc9b2f
--- /dev/null
+++ b/comm/mail/components/addrbook/jar.mn
@@ -0,0 +1,35 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/addressbook/abCommon.js (content/abCommon.js)
+ content/messenger/addressbook/abEditListDialog.xhtml (content/abEditListDialog.xhtml)
+ content/messenger/addressbook/abMailListDialog.xhtml (content/abMailListDialog.xhtml)
+ content/messenger/addressbook/abContactsPanel.xhtml (content/abContactsPanel.xhtml)
+ content/messenger/addressbook/abContactsPanel.js (content/abContactsPanel.js)
+* content/messenger/addressbook/abSearchDialog.xhtml (content/abSearchDialog.xhtml)
+ content/messenger/addressbook/abSearchDialog.js (content/abSearchDialog.js)
+ content/messenger/addressbook/menulist-addrbooks.js (content/menulist-addrbooks.js)
+
+ content/messenger/addressbook/aboutAddressBook.js (content/aboutAddressBook.js)
+* content/messenger/addressbook/aboutAddressBook.xhtml (content/aboutAddressBook.xhtml)
+ content/messenger/addressbook/addressBookTab.js (content/addressBookTab.js)
+# TODO: Rename this after removal of mailnews/addrbook/content/abView.js.
+ content/messenger/addressbook/abView-new.js (content/abView-new.js)
+# Edit view
+ content/messenger/addressbook/edit/adr.mjs (content/vcard-edit/adr.mjs)
+ content/messenger/addressbook/edit/custom.mjs (content/vcard-edit/custom.mjs)
+ content/messenger/addressbook/edit/edit.mjs (content/vcard-edit/edit.mjs)
+ content/messenger/addressbook/edit/email.mjs (content/vcard-edit/email.mjs)
+ content/messenger/addressbook/edit/fn.mjs (content/vcard-edit/fn.mjs)
+ content/messenger/addressbook/edit/impp.mjs (content/vcard-edit/impp.mjs)
+ content/messenger/addressbook/edit/n.mjs (content/vcard-edit/n.mjs)
+ content/messenger/addressbook/edit/nickname.mjs (content/vcard-edit/nickname.mjs)
+ content/messenger/addressbook/edit/note.mjs (content/vcard-edit/note.mjs)
+ content/messenger/addressbook/edit/org.mjs (content/vcard-edit/org.mjs)
+ content/messenger/addressbook/edit/special-date.mjs (content/vcard-edit/special-date.mjs)
+ content/messenger/addressbook/edit/tel.mjs (content/vcard-edit/tel.mjs)
+ content/messenger/addressbook/edit/tz.mjs (content/vcard-edit/tz.mjs)
+ content/messenger/addressbook/edit/url.mjs (content/vcard-edit/url.mjs)
+ content/messenger/addressbook/edit/id-gen.mjs (content/vcard-edit/id-gen.mjs)
diff --git a/comm/mail/components/addrbook/moz.build b/comm/mail/components/addrbook/moz.build
new file mode 100644
index 0000000000..7ca81b6ae6
--- /dev/null
+++ b/comm/mail/components/addrbook/moz.build
@@ -0,0 +1,10 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/addrbook/test/browser/browser.ini b/comm/mail/components/addrbook/test/browser/browser.ini
new file mode 100644
index 0000000000..99d7d9190d
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+head = head.js
+prefs =
+ carddav.setup.loglevel=Debug
+ carddav.sync.loglevel=Debug
+ ldap_2.servers.osx.dirType=-1
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.oauth.loglevel=Debug
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ signon.rememberSignons=true
+subsuite = thunderbird
+support-files = data/**
+tags = addrbook
+
+[browser_cardDAV_init.js]
+[browser_cardDAV_oAuth.js]
+tags = oauth
+[browser_cardDAV_properties.js]
+[browser_cardDAV_sync.js]
+[browser_contact_sidebar.js]
+[browser_contact_tree.js]
+[browser_directory_tree.js]
+[browser_display_card.js]
+[browser_display_multiple.js]
+[browser_drag_drop.js]
+[browser_edit_async.js]
+[browser_edit_card.js]
+[browser_edit_photo.js]
+[browser_ldap_search.js]
+support-files = ../../../../../mailnews/addrbook/test/unit/data/ldap_contacts.json
+[browser_mailing_lists.js]
+[browser_open_actions.js]
+[browser_search.js]
+[browser_telemetry.js]
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js
new file mode 100644
index 0000000000..36e44a84c7
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_init.js
@@ -0,0 +1,664 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+// A list of books returned by CardDAVServer unless changed.
+const DEFAULT_BOOKS = [
+ {
+ label: "Not This One",
+ url: "/addressbooks/me/default/",
+ },
+ {
+ label: "CardDAV Test",
+ url: "/addressbooks/me/test/",
+ },
+];
+
+async function wrappedTest(testInitCallback, ...attemptArgs) {
+ Services.logins.removeAllLogins();
+
+ CardDAVServer.open("alice", "alice");
+ if (testInitCallback) {
+ await testInitCallback();
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ for (let args of attemptArgs) {
+ if (args.url?.startsWith("/")) {
+ args.url = CardDAVServer.origin + args.url;
+ }
+ await attemptInit(dialogWindow, args);
+ }
+ dialogWindow.document.querySelector("dialog").getButton("cancel").click();
+ });
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+ CardDAVServer.resetHandlers();
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "no faulty logins were saved");
+}
+
+async function attemptInit(
+ dialogWindow,
+ {
+ username,
+ url,
+ certError,
+ password,
+ savePassword,
+ expectedStatus = "carddav-connection-error",
+ expectedBooks = [],
+ }
+) {
+ let dialogDocument = dialogWindow.document;
+ let acceptButton = dialogDocument.querySelector("dialog").getButton("accept");
+
+ let usernameInput = dialogDocument.getElementById("carddav-username");
+ let urlInput = dialogDocument.getElementById("carddav-location");
+ let statusMessage = dialogDocument.getElementById("carddav-statusMessage");
+ let availableBooks = dialogDocument.getElementById("carddav-availableBooks");
+
+ if (username) {
+ usernameInput.select();
+ EventUtils.sendString(username, dialogWindow);
+ }
+ if (url) {
+ urlInput.select();
+ EventUtils.sendString(url, dialogWindow);
+ }
+
+ let certPromise =
+ certError === undefined ? Promise.resolve() : handleCertError();
+ let promptPromise =
+ password === undefined
+ ? Promise.resolve()
+ : handlePasswordPrompt(username, password, savePassword);
+
+ acceptButton.click();
+
+ Assert.equal(
+ statusMessage.getAttribute("data-l10n-id"),
+ "carddav-loading",
+ "Correct status message"
+ );
+
+ await certPromise;
+ await promptPromise;
+ await BrowserTestUtils.waitForEvent(dialogWindow, "status-changed");
+
+ Assert.equal(
+ statusMessage.getAttribute("data-l10n-id"),
+ expectedStatus,
+ "Correct status message"
+ );
+
+ Assert.equal(
+ availableBooks.childElementCount,
+ expectedBooks.length,
+ "Expected number of address books found"
+ );
+ for (let i = 0; i < expectedBooks.length; i++) {
+ Assert.equal(availableBooks.children[i].label, expectedBooks[i].label);
+ if (expectedBooks[i].url.startsWith("/")) {
+ Assert.equal(
+ availableBooks.children[i].value,
+ `${CardDAVServer.origin}${expectedBooks[i].url}`
+ );
+ } else {
+ Assert.equal(availableBooks.children[i].value, expectedBooks[i].url);
+ }
+ Assert.ok(availableBooks.children[i].checked);
+ }
+}
+
+function handleCertError() {
+ return BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://pippki/content/exceptionDialog.xhtml"
+ );
+}
+
+function handlePasswordPrompt(expectedUsername, password, savePassword = true) {
+ return BrowserTestUtils.promiseAlertDialog(null, undefined, {
+ async callback(prompt) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == prompt,
+ "waiting for prompt to become active"
+ );
+
+ if (!password) {
+ prompt.document.querySelector("dialog").getButton("cancel").click();
+ return;
+ }
+
+ if (expectedUsername) {
+ Assert.equal(
+ prompt.document.getElementById("loginTextbox").value,
+ expectedUsername
+ );
+ } else {
+ prompt.document.getElementById("loginTextbox").value = "alice";
+ }
+ prompt.document.getElementById("password1Textbox").value = password;
+
+ let checkbox = prompt.document.getElementById("checkbox");
+ Assert.greater(checkbox.getBoundingClientRect().width, 0);
+ Assert.ok(checkbox.checked);
+
+ if (!savePassword) {
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prompt);
+ Assert.ok(!checkbox.checked);
+ }
+
+ prompt.document.querySelector("dialog").getButton("accept").click();
+ },
+ });
+}
+
+/** Test URLs that don't respond. */
+add_task(function testBadURLs() {
+ return wrappedTest(
+ null,
+ { url: "mochi.test:8888" },
+ { url: "http://mochi.test:8888" },
+ { url: "https://mochi.test:8888" }
+ );
+});
+
+/** Test a server with a certificate problem. */
+add_task(function testBadSSL() {
+ return wrappedTest(null, {
+ url: "https://expired.example.com/",
+ certError: true,
+ });
+});
+
+/** Test an ordinary HTTP server that doesn't support CardDAV. */
+add_task(function testNotACardDAVServer() {
+ return wrappedTest(
+ () => {
+ CardDAVServer.server.registerPathHandler("/", null);
+ CardDAVServer.server.registerPathHandler("/.well-known/carddav", null);
+ },
+ {
+ url: "/",
+ }
+ );
+});
+
+/** Test a CardDAV server without the /.well-known/carddav response. */
+add_task(function testNoWellKnown() {
+ return wrappedTest(
+ () =>
+ CardDAVServer.server.registerPathHandler("/.well-known/carddav", null),
+ {
+ url: "/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test cancelling the password prompt when it appears. */
+add_task(function testPasswordCancelled() {
+ return wrappedTest(null, {
+ url: "/",
+ password: null,
+ });
+});
+
+/** Test entering the wrong password, then retrying with the right one. */
+add_task(function testBadPassword() {
+ return wrappedTest(
+ null,
+ {
+ url: "/",
+ password: "bob",
+ },
+ {
+ url: "/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test that entering the full URL of a book links to (only) that book. */
+add_task(function testDirectLink() {
+ return wrappedTest(null, {
+ url: "/addressbooks/me/test/",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [DEFAULT_BOOKS[1]],
+ });
+});
+
+/** Test that entering only a username finds the right URL. */
+add_task(function testEmailGoodPreset() {
+ return wrappedTest(
+ async () => {
+ // The server is open but we need it on a specific port.
+ await CardDAVServer.close();
+ CardDAVServer.open("alice@test.invalid", "alice", 9999);
+ },
+ {
+ username: "alice@test.invalid",
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ }
+ );
+});
+
+/** Test that entering only a bad username fails appropriately. */
+add_task(function testEmailBadPreset() {
+ return wrappedTest(null, {
+ username: "alice@bad.invalid",
+ expectedStatus: "carddav-known-incompatible",
+ });
+});
+
+/**
+ * Test that we correctly use DNS discovery. This uses the mochitest server
+ * (files in the data directory) instead of CardDAVServer because the latter
+ * can't speak HTTPS, and we only do DNS discovery for HTTPS.
+ */
+add_task(async function testDNS() {
+ let _srv = DNS.srv;
+ let _txt = DNS.txt;
+
+ DNS.srv = function (name) {
+ Assert.equal(name, "_carddavs._tcp.dnstest.invalid");
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ };
+ DNS.txt = function (name) {
+ Assert.equal(name, "_carddavs._tcp.dnstest.invalid");
+ return [
+ {
+ data: "path=/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs",
+ },
+ ];
+ };
+
+ let abWindow = await openAddressBookWindow();
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ username: "carol@dnstest.invalid",
+ password: "carol",
+ expectedStatus: null,
+ expectedBooks: [
+ {
+ label: "You found me!",
+ url: "https://example.org/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs",
+ },
+ ],
+ });
+ dialogWindow.document.querySelector("dialog").getButton("cancel").click();
+ });
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+
+ DNS.srv = _srv;
+ DNS.txt = _txt;
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test doing everything correctly, including creating the directory and
+ * doing the initial sync.
+ */
+add_task(async function testEveryThingOK() {
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ });
+
+ let availableBooks = dialogWindow.document.getElementById(
+ "carddav-availableBooks"
+ );
+ availableBooks.children[0].checked = false;
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = new Promise(resolve => {
+ let observer = {
+ observe(directory) {
+ Services.obs.removeObserver(this, "addrbook-directory-synced");
+ resolve(directory);
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-synced");
+ });
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let directory = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.url
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 2, "new book got selected");
+
+ await closeAddressBookWindow();
+
+ // Don't close the server or delete the directory, they're needed below.
+});
+
+/**
+ * Tests adding a second directory on the same server. The auth prompt should
+ * show again, even though we've saved the credentials in the previous test.
+ */
+add_task(async function testEveryThingOKAgain() {
+ // Ensure at least a second has passed since the previous test, since we use
+ // context identifiers based on the current time in seconds.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [DEFAULT_BOOKS[0]],
+ });
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let [directory] = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.altURL
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 5);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(3).querySelector(".bookRow-name")
+ .textContent,
+ "Not This One"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 3, "new book got selected");
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+
+ let otherDirectory = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.CardDAVTest"
+ );
+ await promiseDirectoryRemoved(directory.URI);
+ await promiseDirectoryRemoved(otherDirectory.URI);
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Test setting up a directory but not saving the password. The username
+ * should be saved and no further password prompt should appear. We can't test
+ * restarting Thunderbird but if we could the password prompt would appear
+ * next time the directory makes a request.
+ */
+add_task(async function testNoSavePassword() {
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ savePassword: false,
+ expectedStatus: null,
+ expectedBooks: DEFAULT_BOOKS,
+ });
+
+ let availableBooks = dialogWindow.document.getElementById(
+ "carddav-availableBooks"
+ );
+ availableBooks.children[0].checked = false;
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+ await dialogPromise;
+ let [directory] = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ CardDAVServer.url
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 0, "login was NOT saved");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "CardDAV Test"
+ );
+ Assert.equal(abWindow.booksList.selectedIndex, 2, "new book got selected");
+
+ await closeAddressBookWindow();
+
+ // Disable sync as we're going to start the address book manager again.
+ directory.setIntValue("carddav.syncinterval", 0);
+
+ // Don't close the server or delete the directory, they're needed below.
+});
+
+/**
+ * Tests saving a previously unsaved password. This uses the directory from
+ * the previous test and simulates a restart of the address book manager.
+ */
+add_task(async function testSavePasswordLater() {
+ let reloadPromise = TestUtils.topicObserved("addrbook-reloaded");
+ Services.obs.notifyObservers(null, "addrbook-reload");
+ await reloadPromise;
+
+ Assert.equal(MailServices.ab.directories.length, 3);
+ let directory = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.CardDAVTest"
+ );
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ let promptPromise = handlePasswordPrompt("alice", "alice");
+ let syncPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ davDirectory.fetchAllFromServer();
+ await promptPromise;
+ await syncPromise;
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice",
+ "username was saved"
+ );
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ await CardDAVServer.close();
+
+ await promiseDirectoryRemoved(directory.URI);
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Tests that an address book can still be created if the server returns no
+ * name. The hostname of the server is used instead.
+ */
+add_task(async function testNoName() {
+ CardDAVServer._books = CardDAVServer.books;
+ CardDAVServer.books = { "/addressbooks/me/noname/": undefined };
+ CardDAVServer.open("alice", "alice");
+
+ let abWindow = await openAddressBookWindow();
+
+ Assert.equal(abWindow.booksList.rowCount, 3);
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ await attemptInit(dialogWindow, {
+ url: CardDAVServer.origin,
+ password: "alice",
+ expectedStatus: null,
+ expectedBooks: [{ label: "noname", url: "/addressbooks/me/noname/" }],
+ });
+
+ dialogWindow.document.querySelector("dialog").getButton("accept").click();
+ });
+ let syncPromise = new Promise(resolve => {
+ let observer = {
+ observe(directory) {
+ Services.obs.removeObserver(this, "addrbook-directory-synced");
+ resolve(directory);
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-synced");
+ });
+
+ abWindow.createBook(Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ await dialogPromise;
+ let directory = await syncPromise;
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.url`, ""),
+ `${CardDAVServer.origin}/addressbooks/me/noname/`
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.token`, ""),
+ "http://mochi.test/sync/0"
+ );
+ Assert.equal(
+ Services.prefs.getStringPref(`${directory.dirPrefId}.carddav.username`, ""),
+ "alice"
+ );
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+
+ let logins = Services.logins.findLogins(CardDAVServer.origin, null, "");
+ Assert.equal(logins.length, 1, "login was saved");
+ Assert.equal(logins[0].username, "alice");
+ Assert.equal(logins[0].password, "alice");
+
+ Assert.equal(abWindow.booksList.rowCount, 4);
+ Assert.equal(
+ abWindow.booksList.getRowAtIndex(2).querySelector(".bookRow-name")
+ .textContent,
+ "noname"
+ );
+
+ await closeAddressBookWindow();
+ await CardDAVServer.close();
+ CardDAVServer.books = CardDAVServer._books;
+
+ await promiseDirectoryRemoved(directory.URI);
+
+ Services.logins.removeAllLogins();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js
new file mode 100644
index 0000000000..137a13e221
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_oAuth.js
@@ -0,0 +1,143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Creates address books in various configurations (current and legacy) and
+// performs requests in each of them to prove that OAuth2 authentication is
+// working as expected.
+
+var { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+
+var LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+const PATH = "comm/mail/components/addrbook/test/browser/data/";
+const URL = `http://mochi.test:8888/browser/${PATH}`;
+
+/**
+ * Set a string pref for the given directory.
+ *
+ * @param {string} dirPrefId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(dirPrefId, key, value) {
+ Services.prefs.setStringPref(`ldap_2.servers.${dirPrefId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm,
+ * username, and password.
+ */
+function setLogins(...logins) {
+ Services.logins.removeAllLogins();
+ for (let [origin, realm, username, password] of logins) {
+ Services.logins.addLogin(
+ new LoginInfo(origin, null, realm, username, password, "", "")
+ );
+ }
+}
+
+/**
+ * Create a directory with the given id, perform a request, and check that the
+ * correct authorisation header was used. If the user is required to
+ * re-authenticate with the provider, check that the new token is stored in the
+ * right place.
+ *
+ * @param {string} dirPrefId - Pref ID of the new directory.
+ * @param {string} uid - UID of the new directory.
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen
+ * and the new token stored with this user name.
+ */
+async function subtest(dirPrefId, uid, newTokenUsername) {
+ let directory = new CardDAVDirectory();
+ directory._dirPrefId = dirPrefId;
+ directory._uid = uid;
+ directory.__prefBranch = Services.prefs.getBranch(
+ `ldap_2.servers.${dirPrefId}.`
+ );
+ directory.__prefBranch.setStringPref("carddav.url", URL);
+
+ let response = await directory._makeRequest("auth_headers.sjs");
+ Assert.equal(response.status, 200);
+ let headers = JSON.parse(response.text);
+
+ if (newTokenUsername) {
+ Assert.equal(headers.authorization, "Bearer new_access_token");
+
+ let logins = Services.logins
+ .findLogins(ORIGIN, null, SCOPE)
+ .filter(l => l.username == newTokenUsername);
+ Assert.equal(logins.length, 1);
+ Assert.equal(logins[0].username, newTokenUsername);
+ Assert.equal(logins[0].password, "new_refresh_token");
+ } else {
+ Assert.equal(headers.authorization, "Bearer bobs_access_token");
+ }
+
+ Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username set. */
+add_task(function testAddressBookOAuth_uid_none() {
+ let dirPrefId = "uid_none";
+ let uid = "testAddressBookOAuth_uid_none";
+ return subtest(dirPrefId, uid, uid);
+});
+
+// Test making a request when there IS a matching token, but the server rejects
+// it. Currently a new token is not requested on failure.
+
+/** Expired token stored with UID. */
+add_task(function testAddressBookOAuth_uid_expired() {
+ let dirPrefId = "uid_expired";
+ let uid = "testAddressBookOAuth_uid_expired";
+ setLogins([ORIGIN, SCOPE, uid, "expired_token"]);
+ return subtest(dirPrefId, uid, uid);
+}).skip(); // Broken.
+
+// Test making a request with a valid token.
+
+/** Valid token stored with UID. This is the old way of storing the token. */
+add_task(function testAddressBookOAuth_uid_valid() {
+ let dirPrefId = "uid_valid";
+ let uid = "testAddressBookOAuth_uid_valid";
+ setLogins([ORIGIN, SCOPE, uid, VALID_TOKEN]);
+ return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testAddressBookOAuth_username_validSingle() {
+ let dirPrefId = "username_validSingle";
+ let uid = "testAddressBookOAuth_username_validSingle";
+ setPref(dirPrefId, "carddav.username", USERNAME);
+ setLogins(
+ [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+ [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+ );
+ return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, many scopes. */
+add_task(function testAddressBookOAuth_username_validMultiple() {
+ let dirPrefId = "username_validMultiple";
+ let uid = "testAddressBookOAuth_username_validMultiple";
+ setPref(dirPrefId, "carddav.username", USERNAME);
+ setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+ return subtest(dirPrefId, uid);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js
new file mode 100644
index 0000000000..0acd0b3540
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_properties.js
@@ -0,0 +1,245 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests CardDAV properties dialog.
+ */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+add_task(async () => {
+ const INTERVAL_PREF = "ldap_2.servers.props.carddav.syncinterval";
+ const TOKEN_PREF = "ldap_2.servers.props.carddav.token";
+ const TOKEN_VALUE = "http://mochi.test/sync/0";
+ const URL_PREF = "ldap_2.servers.props.carddav.url";
+ const URL_VALUE = "https://mochi.test/carddav/test";
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "props",
+ undefined,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ Assert.equal(dirPrefId, "ldap_2.servers.props");
+ Assert.equal([...MailServices.ab.directories].length, 3);
+
+ let directory = MailServices.ab.getDirectoryFromId(dirPrefId);
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+ registerCleanupFunction(async () => {
+ Assert.equal(davDirectory._syncTimer, null, "sync timer cleaned up");
+ });
+ Assert.equal(directory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ Services.prefs.setIntPref(INTERVAL_PREF, 0);
+ Services.prefs.setStringPref(TOKEN_PREF, TOKEN_VALUE);
+ Services.prefs.setStringPref(URL_PREF, URL_VALUE);
+
+ Assert.ok(davDirectory);
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory._syncToken, TOKEN_VALUE);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+
+ openDirectory(directory);
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(directory.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+
+ let menu = abDocument.getElementById("bookContext");
+ let menuItem = abDocument.getElementById("bookContextProperties");
+
+ let subtest = async function (expectedValues, newValues, buttonAction) {
+ Assert.equal(booksList.selectedIndex, 2);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ booksList.getRowAtIndex(2),
+ { type: "contextmenu" },
+ abWindow
+ );
+ await shownPromise;
+
+ Assert.ok(BrowserTestUtils.is_visible(menuItem));
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("carddav-name");
+ Assert.equal(nameInput.value, expectedValues.name);
+ if ("name" in newValues) {
+ nameInput.value = newValues.name;
+ }
+
+ let urlInput = dialogDocument.getElementById("carddav-url");
+ Assert.equal(urlInput.value, expectedValues.url);
+ if ("url" in newValues) {
+ urlInput.value = newValues.url;
+ }
+
+ let refreshActiveInput = dialogDocument.getElementById(
+ "carddav-refreshActive"
+ );
+ let refreshIntervalInput = dialogDocument.getElementById(
+ "carddav-refreshInterval"
+ );
+
+ Assert.equal(refreshActiveInput.checked, expectedValues.refreshActive);
+ Assert.equal(
+ refreshIntervalInput.disabled,
+ !expectedValues.refreshActive
+ );
+ if (
+ "refreshActive" in newValues &&
+ newValues.refreshActive != expectedValues.refreshActive
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ refreshActiveInput,
+ {},
+ dialogWindow
+ );
+ Assert.equal(refreshIntervalInput.disabled, !newValues.refreshActive);
+ }
+
+ Assert.equal(refreshIntervalInput.value, expectedValues.refreshInterval);
+ if ("refreshInterval" in newValues) {
+ refreshIntervalInput.value = newValues.refreshInterval;
+ }
+
+ let readOnlyInput = dialogDocument.getElementById("carddav-readOnly");
+
+ Assert.equal(readOnlyInput.checked, expectedValues.readOnly);
+ if ("readOnly" in newValues) {
+ readOnlyInput.checked = newValues.readOnly;
+ }
+
+ dialogDocument.querySelector("dialog").getButton(buttonAction).click();
+ });
+ menu.activateItem(menuItem);
+ await dialogPromise;
+
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ };
+
+ info("Open the dialog and cancel it. Nothing should change.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {},
+ "cancel"
+ );
+
+ Assert.equal(davDirectory.dirName, "props");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 0);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ info("Open the dialog and accept it. Nothing should change.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {},
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "props");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 0);
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+ Assert.equal(davDirectory.readOnly, false);
+
+ info("Open the dialog and change the values.");
+ await subtest(
+ {
+ name: "props",
+ url: URL_VALUE,
+ refreshActive: false,
+ refreshInterval: 30,
+ readOnly: false,
+ },
+ {
+ name: "CardDAV Properties Test",
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 30);
+ Assert.notEqual(davDirectory._syncTimer, null, "sync scheduled");
+ let currentSyncTimer = davDirectory._syncTimer;
+ Assert.equal(davDirectory.readOnly, true);
+
+ info("Open the dialog and accept it. Nothing should change.");
+ await subtest(
+ {
+ name: "CardDAV Properties Test",
+ url: URL_VALUE,
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ {},
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 30);
+ Assert.equal(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "same sync scheduled"
+ );
+ Assert.equal(davDirectory.readOnly, true);
+
+ info("Open the dialog and change the interval.");
+ await subtest(
+ {
+ name: "CardDAV Properties Test",
+ url: URL_VALUE,
+ refreshActive: true,
+ refreshInterval: 30,
+ readOnly: true,
+ },
+ { refreshInterval: 60 },
+ "accept"
+ );
+
+ Assert.equal(davDirectory.dirName, "CardDAV Properties Test");
+ Assert.equal(davDirectory._serverURL, URL_VALUE);
+ Assert.equal(davDirectory.getIntValue("carddav.syncinterval", -1), 60);
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "new sync scheduled"
+ );
+ Assert.equal(davDirectory.readOnly, true);
+
+ await promiseDirectoryRemoved(directory.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js b/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js
new file mode 100644
index 0000000000..1c4e4fb07a
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_cardDAV_sync.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests CardDAV synchronization.
+ */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+add_task(async () => {
+ CardDAVServer.open();
+ registerCleanupFunction(async () => {
+ await CardDAVServer.close();
+ });
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "sync",
+ undefined,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ Assert.equal(dirPrefId, "ldap_2.servers.sync");
+ Assert.equal([...MailServices.ab.directories].length, 3);
+
+ let directory = MailServices.ab.getDirectoryFromId(dirPrefId);
+ let davDirectory = CardDAVDirectory.forFile(directory.fileName);
+ Assert.equal(directory.dirType, Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ Services.prefs.setStringPref(
+ "ldap_2.servers.sync.carddav.token",
+ "http://mochi.test/sync/0"
+ );
+ Services.prefs.setStringPref(
+ "ldap_2.servers.sync.carddav.url",
+ CardDAVServer.url
+ );
+
+ Assert.ok(davDirectory);
+ Assert.equal(davDirectory._serverURL, CardDAVServer.url);
+ Assert.equal(davDirectory._syncToken, "http://mochi.test/sync/0");
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ // This test becomes unreliable if we don't pause for a moment.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 500));
+
+ openDirectory(directory);
+ checkNamesListed();
+
+ let menu = abDocument.getElementById("bookContext");
+ let menuItem = abDocument.getElementById("bookContextSynchronize");
+ let openContext = async (index, itemHidden) => {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.booksList.getRowAtIndex(index),
+ { type: "contextmenu" },
+ abWindow
+ );
+ await shownPromise;
+ Assert.equal(menuItem.hidden, itemHidden);
+ };
+
+ for (let index of [1, 3]) {
+ await openContext(index, true);
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ CardDAVServer.putCardInternal(
+ "first.vcf",
+ "BEGIN:VCARD\r\nUID:first\r\nFN:First\r\nEND:VCARD\r\n"
+ );
+
+ Assert.equal(davDirectory._syncTimer, null, "no sync scheduled");
+
+ let syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.notEqual(davDirectory._syncTimer, null, "first sync scheduled");
+ let currentSyncTimer = davDirectory._syncTimer;
+
+ checkNamesListed("First");
+
+ CardDAVServer.putCardInternal(
+ "second.vcf",
+ "BEGIN:VCARD\r\nUID:second\r\nFN:Second\r\nEND:VCARD\r\n"
+ );
+
+ syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "second sync not the same as the first"
+ );
+ currentSyncTimer = davDirectory._syncTimer;
+
+ checkNamesListed("First", "Second");
+
+ CardDAVServer.deleteCardInternal("second.vcf");
+ CardDAVServer.putCardInternal(
+ "third.vcf",
+ "BEGIN:VCARD\r\nUID:third\r\nFN:Third\r\nEND:VCARD\r\n"
+ );
+
+ syncedPromise = TestUtils.topicObserved("addrbook-directory-synced");
+ await openContext(2, false);
+ menu.activateItem(menuItem);
+ await syncedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.greater(
+ davDirectory._syncTimer,
+ currentSyncTimer,
+ "third sync not the same as the second"
+ );
+
+ checkNamesListed("First", "Third");
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(directory.URI);
+ Assert.equal(davDirectory._syncTimer, null, "sync timer cleaned up");
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js b/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js
new file mode 100644
index 0000000000..3fb0f70b25
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_contact_sidebar.js
@@ -0,0 +1,470 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+add_task(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let book1 = createAddressBook("Book 1");
+ book1.addCard(createContact("daniel", "test"));
+ book1.addCard(createContact("jonathan", "test"));
+ book1.addCard(createContact("năthån", "test"));
+
+ let book2 = createAddressBook("Book 2");
+ book2.addCard(createContact("danielle", "test"));
+ book2.addCard(createContact("katherine", "test"));
+ book2.addCard(createContact("natalie", "test"));
+ book2.addCard(createContact("sūsãnáh", "test"));
+
+ let list = createMailingList("pèóplë named tēst");
+ book2.addMailList(list);
+
+ registerCleanupFunction(async function () {
+ MailServices.accounts.removeAccount(account, true);
+ await promiseDirectoryRemoved(book1.URI);
+ await promiseDirectoryRemoved(book2.URI);
+ });
+
+ // Open a compose window.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ let composeDocument = composeWindow.document;
+ let toAddrInput = composeDocument.getElementById("toAddrInput");
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+ let ccAddrInput = composeDocument.getElementById("ccAddrInput");
+ let ccAddrRow = composeDocument.getElementById("addressRowCc");
+ let bccAddrInput = composeDocument.getElementById("bccAddrInput");
+ let bccAddrRow = composeDocument.getElementById("addressRowBcc");
+
+ // The compose window waits before deciding whether to open the sidebar.
+ // We must wait longer.
+ await new Promise(resolve => composeWindow.setTimeout(resolve, 100));
+
+ // Make sure the contacts sidebar is open.
+
+ let sidebar = composeDocument.getElementById("contactsSidebar");
+ if (BrowserTestUtils.is_hidden(sidebar)) {
+ EventUtils.synthesizeKey("KEY_F9", {}, composeWindow);
+ }
+ let sidebarBrowser = composeDocument.getElementById("contactsBrowser");
+ await TestUtils.waitForCondition(
+ () =>
+ sidebarBrowser.currentURI.spec.includes("abContactsPanel.xhtml") &&
+ sidebarBrowser.contentDocument.readyState == "complete"
+ );
+ let sidebarWindow = sidebarBrowser.contentWindow;
+ let sidebarDocument = sidebarBrowser.contentDocument;
+
+ let abList = sidebarDocument.getElementById("addressbookList");
+ let searchBox = sidebarDocument.getElementById("peopleSearchInput");
+ let cardsList = sidebarDocument.getElementById("abResultsTree");
+ let cardsContext = sidebarDocument.getElementById("cardProperties");
+ let toButton = sidebarDocument.getElementById("toButton");
+ let ccButton = sidebarDocument.getElementById("ccButton");
+ let bccButton = sidebarDocument.getElementById("bccButton");
+
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 0);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ Assert.equal(cardsList.view.selection.count, 0, "no contact selected");
+ Assert.ok(toButton.disabled, "to button disabled with no contact selected");
+ Assert.ok(ccButton.disabled, "cc button disabled with no contact selected");
+ Assert.ok(bccButton.disabled, "bcc button disabled with no contact selected");
+
+ function clickOnRow(row, event) {
+ mailTestUtils.treeClick(
+ EventUtils,
+ sidebarWindow,
+ cardsList,
+ row,
+ 0,
+ event
+ );
+ }
+
+ async function doMenulist(value) {
+ let shownPromise = BrowserTestUtils.waitForEvent(abList, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(abList, {}, sidebarWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(abList, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(
+ abList.querySelector(`[value="${value}"]`),
+ {},
+ sidebarWindow
+ );
+ await hiddenPromise;
+ }
+
+ async function doContextMenu(row, command) {
+ clickOnRow(row, {});
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popupshown"
+ );
+ clickOnRow(row, { type: "contextmenu" });
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popuphidden"
+ );
+ cardsContext.activateItem(
+ cardsContext.querySelector(`[command="${command}"]`)
+ );
+ await hiddenPromise;
+ }
+
+ function checkListNames(expectedNames, message) {
+ let actualNames = [];
+ for (let row = 0; row < cardsList.view.rowCount; row++) {
+ actualNames.push(
+ cardsList.view.getCellText(row, cardsList.columns.GeneratedName)
+ );
+ }
+
+ Assert.deepEqual(actualNames, expectedNames, message);
+ }
+
+ function checkPills(row, expectedPills) {
+ let actualPills = Array.from(
+ row.querySelectorAll("mail-address-pill"),
+ p => p.label
+ );
+ Assert.deepEqual(
+ actualPills,
+ expectedPills,
+ "message recipients match expected"
+ );
+ }
+
+ function clearPills() {
+ for (let input of [toAddrInput, ccAddrInput, bccAddrInput]) {
+ EventUtils.synthesizeMouseAtCenter(input, {}, composeWindow);
+ EventUtils.synthesizeKey(
+ "a",
+ {
+ accelKey: AppConstants.platform == "macosx",
+ ctrlKey: AppConstants.platform != "macosx",
+ },
+ composeWindow
+ );
+ EventUtils.synthesizeKey("KEY_Delete", {}, composeWindow);
+ }
+ checkPills(toAddrRow, []);
+ checkPills(ccAddrRow, []);
+ checkPills(bccAddrRow, []);
+ }
+
+ async function inABEditingMode() {
+ let topWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ let abWindow = await topWindow.toAddressBook();
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+ let tabmail = topWindow.document.getElementById("tabmail");
+ let tab = tabmail.tabInfo.find(
+ t => t.browser?.currentURI.spec == "about:addressbook"
+ );
+ tabmail.closeTab(tab);
+ }
+
+ /**
+ * Make sure the "edit contact" menuitem only shows up for the correct
+ * contacts, and it properly opens the address book tab.
+ *
+ * @param {int} row - The row index to activate.
+ * @param {boolean} isEditable - If the selected contact should be editable.
+ */
+ async function checkEditContact(row, isEditable) {
+ clickOnRow(row, {});
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popupshown"
+ );
+ clickOnRow(row, { type: "contextmenu" });
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ cardsContext,
+ "popuphidden"
+ );
+
+ Assert.equal(
+ cardsContext.querySelector("#abContextBeforeEditContact").hidden,
+ !isEditable
+ );
+ Assert.equal(
+ cardsContext.querySelector("#abContextEditContact").hidden,
+ !isEditable
+ );
+
+ // If it's an editable row, we should see the edit contact menu items.
+ if (isEditable) {
+ cardsContext.activateItem(
+ cardsContext.querySelector("#abContextEditContact")
+ );
+ await hiddenPromise;
+ await inABEditingMode();
+ composeWindow.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ } else {
+ cardsContext.activateItem(
+ cardsContext.querySelector(`[command="cmd_addrBcc"]`)
+ );
+ await hiddenPromise;
+ }
+ }
+
+ // Click on a contact and make sure is editable.
+ await checkEditContact(2, true);
+ // Click on a mailing list and make sure is NOT editable.
+ await checkEditContact(6, false);
+
+ // Check that the address book picker works.
+
+ await doMenulist(book1.URI);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 0);
+ checkListNames(
+ ["daniel test", "jonathan test", "năthån test"],
+ "book1 contacts are shown"
+ );
+
+ await doMenulist(book2.URI);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 3);
+ checkListNames(
+ [
+ "danielle test",
+ "katherine test",
+ "natalie test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "book2 contacts are shown"
+ );
+
+ await doMenulist("moz-abdirectory://?");
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 5);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that the search works.
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, sidebarWindow);
+
+ EventUtils.synthesizeKey("a", { accelKey: true }, sidebarWindow);
+ EventUtils.sendString("dan", sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 8);
+ checkListNames(
+ ["daniel test", "danielle test"],
+ "matching contacts are shown"
+ );
+
+ EventUtils.synthesizeKey("a", { accelKey: true }, sidebarWindow);
+ EventUtils.sendString("kat", sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 2);
+ checkListNames(["katherine test"], "matching contacts are shown");
+
+ EventUtils.synthesizeKey("KEY_Escape", { accelKey: true }, sidebarWindow);
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 1);
+ checkListNames(
+ [
+ "daniel test",
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that double-clicking works.
+
+ clickOnRow(1, { clickCount: 2 });
+ checkPills(toAddrRow, ["danielle test <danielle.test@invalid>"]);
+
+ clickOnRow(3, { clickCount: 2 });
+ checkPills(toAddrRow, [
+ "danielle test <danielle.test@invalid>",
+ "katherine test <katherine.test@invalid>",
+ ]);
+
+ clickOnRow(6, { clickCount: 2 });
+ checkPills(toAddrRow, [
+ "danielle test <danielle.test@invalid>",
+ "katherine test <katherine.test@invalid>",
+ "pèóplë named tēst <pèóplë named tēst>",
+ ]);
+
+ clearPills();
+
+ // Check that drag and drop to the recipients section works.
+
+ clickOnRow(5, {});
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList,
+ toAddrInput,
+ null,
+ null,
+ sidebarWindow,
+ composeWindow
+ );
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ toAddrInput,
+ composeWindow
+ );
+
+ dragService.endDragSession(true);
+ checkPills(toAddrRow, ["năthån test <năthån.test@invalid>"]);
+
+ clearPills();
+
+ // Check that the "Add to" buttons work.
+
+ clickOnRow(7, {});
+
+ Assert.ok(!toButton.disabled, "to button enabled with a contact selected");
+ Assert.ok(!ccButton.disabled, "cc button enabled with a contact selected");
+ Assert.ok(!bccButton.disabled, "bcc button enabled with a contact selected");
+
+ EventUtils.synthesizeMouseAtCenter(toButton, {}, sidebarWindow);
+ checkPills(toAddrRow, ["sūsãnáh test <sūsãnáh.test@invalid>"]);
+
+ clickOnRow(0, {});
+ EventUtils.synthesizeMouseAtCenter(ccButton, {}, sidebarWindow);
+ Assert.ok(BrowserTestUtils.is_visible(ccAddrRow), "cc row visible");
+ checkPills(ccAddrRow, ["daniel test <daniel.test@invalid>"]);
+
+ clickOnRow(2, {});
+ EventUtils.synthesizeMouseAtCenter(bccButton, {}, sidebarWindow);
+ Assert.ok(BrowserTestUtils.is_visible(bccAddrRow), "bcc row visible");
+ checkPills(bccAddrRow, ["jonathan test <jonathan.test@invalid>"]);
+
+ clearPills();
+
+ // Check that the context menu works.
+
+ await doContextMenu(7, "cmd_addrTo");
+ checkPills(toAddrRow, ["sūsãnáh test <sūsãnáh.test@invalid>"]);
+
+ await doContextMenu(4, "cmd_addrCc");
+ checkPills(ccAddrRow, ["natalie test <natalie.test@invalid>"]);
+
+ await doContextMenu(2, "cmd_addrBcc");
+ checkPills(bccAddrRow, ["jonathan test <jonathan.test@invalid>"]);
+
+ clearPills();
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let deletedPromise = TestUtils.topicObserved(
+ "addrbook-contact-deleted",
+ c => c.displayName == "daniel test"
+ );
+ doContextMenu(0, "cmd_delete");
+ await promptPromise;
+ await deletedPromise;
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 8);
+ checkListNames(
+ [
+ "danielle test",
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // Check that the keyboard commands work.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ deletedPromise = TestUtils.topicObserved(
+ "addrbook-contact-deleted",
+ c => c.displayName == "danielle test"
+ );
+ clickOnRow(0, {});
+ EventUtils.synthesizeKey("KEY_Delete", {}, sidebarWindow);
+ await promptPromise;
+ await deletedPromise;
+ await TestUtils.waitForCondition(() => cardsList.view.rowCount != 7);
+ checkListNames(
+ [
+ "jonathan test",
+ "katherine test",
+ "natalie test",
+ "năthån test",
+ "pèóplë named tēst",
+ "sūsãnáh test",
+ ],
+ "all contacts are shown"
+ );
+
+ // TODO sidebar context menu
+
+ // Close the compose window and clean up.
+
+ EventUtils.synthesizeKey("KEY_F9", {}, composeWindow);
+ await TestUtils.waitForCondition(() => BrowserTestUtils.is_hidden(sidebar));
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ let closePromise = BrowserTestUtils.windowClosed(composeWindow);
+ composeWindow.goDoCommand("cmd_close");
+ await promptPromise;
+ await closePromise;
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_contact_tree.js b/comm/mail/components/addrbook/test/browser/browser_contact_tree.js
new file mode 100644
index 0000000000..f502fe855a
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_contact_tree.js
@@ -0,0 +1,1261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function rightClickOnIndex(index) {
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let menu = abWindow.document.getElementById("cardContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { type: "contextmenu" },
+ abWindow
+ );
+ return shownPromise;
+}
+
+/**
+ * Tests that additions and removals are accurately displayed, or not
+ * displayed if they happen outside the current address book.
+ */
+add_task(async function test_additions_and_removals() {
+ async function deleteRowWithPrompt(index) {
+ let promptPromise = BrowserTestUtils.promiseAlertDialogOpen("accept");
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
+ await promptPromise;
+ await new Promise(r => abWindow.setTimeout(r));
+ await new Promise(r => abWindow.setTimeout(r));
+ }
+
+ let bookA = createAddressBook("book A");
+ let contactA1 = bookA.addCard(createContact("contact", "A1"));
+ let bookB = createAddressBook("book B");
+ let contactB1 = bookB.addCard(createContact("contact", "B1"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ await openAllAddressBooks();
+ info("Performing check #1");
+ checkCardsListed(contactA1, contactB1);
+
+ // While in bookA, add a contact and list. Check that they show up.
+ openDirectory(bookA);
+ checkCardsListed(contactA1);
+ let contactA2 = bookA.addCard(createContact("contact", "A2")); // Add A2.
+ checkCardsListed(contactA1, contactA2);
+ let listC = bookA.addMailList(createMailingList("list C")); // Add C.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA1, contactA2, listC);
+ listC.addCard(contactA1);
+ checkCardsListed(contactA1, contactA2, listC);
+
+ await openAllAddressBooks();
+ info("Performing check #2");
+ checkCardsListed(contactA1, contactA2, contactB1, listC);
+
+ // While in listC, add a member and remove a member. Check that they show up
+ // or disappear as appropriate.
+ openDirectory(listC);
+ checkCardsListed(contactA1);
+ listC.addCard(contactA2);
+ checkCardsListed(contactA1, contactA2);
+ await deleteRowWithPrompt(0);
+ checkCardsListed(contactA2);
+ Assert.equal(cardsList.currentIndex, 0);
+
+ await openAllAddressBooks();
+ info("Performing check #3");
+ checkCardsListed(contactA1, contactA2, contactB1, listC);
+
+ // While in bookA, delete a contact. Check it disappears.
+ openDirectory(bookA);
+ checkCardsListed(contactA1, contactA2, listC);
+ await deleteRowWithPrompt(0); // Delete A1.
+ checkCardsListed(contactA2, listC);
+ Assert.equal(cardsList.currentIndex, 0);
+ // Now do some things in an unrelated book. Check nothing changes here.
+ let contactB2 = bookB.addCard(createContact("contact", "B2")); // Add B2.
+ checkCardsListed(contactA2, listC);
+ let listD = bookB.addMailList(createMailingList("list D")); // Add D.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA2, listC);
+ listD.addCard(contactB1);
+ checkCardsListed(contactA2, listC);
+
+ await openAllAddressBooks();
+ info("Performing check #4");
+ checkCardsListed(contactA2, contactB1, contactB2, listC, listD);
+
+ // While in listC, do some things in an unrelated list. Check nothing
+ // changes here.
+ openDirectory(listC);
+ checkCardsListed(contactA2);
+ listD.addCard(contactB2);
+ checkCardsListed(contactA2);
+ listD.deleteCards([contactB1]);
+ checkCardsListed(contactA2);
+ bookB.deleteCards([contactB1]);
+ checkCardsListed(contactA2);
+
+ await openAllAddressBooks();
+ info("Performing check #5");
+ checkCardsListed(contactA2, contactB2, listC, listD);
+
+ // While in bookA, do some things in an unrelated book. Check nothing
+ // changes here.
+ openDirectory(bookA);
+ checkCardsListed(contactA2, listC);
+ bookB.deleteDirectory(listD); // Delete D.
+ checkDirectoryDisplayed(bookA);
+ checkCardsListed(contactA2, listC);
+ await deleteRowWithPrompt(1); // Delete C.
+ checkCardsListed(contactA2);
+
+ // While in "All Address Books", make some changes and check that things
+ // appear or disappear as appropriate.
+ await openAllAddressBooks();
+ info("Performing check #6");
+ checkCardsListed(contactA2, contactB2);
+ let listE = bookB.addMailList(createMailingList("list E")); // Add E.
+ checkDirectoryDisplayed(null);
+ checkCardsListed(contactA2, contactB2, listE);
+ listE.addCard(contactB2);
+ checkCardsListed(contactA2, contactB2, listE);
+ listE.deleteCards([contactB2]);
+ checkCardsListed(contactA2, contactB2, listE);
+ bookB.deleteDirectory(listE); // Delete E.
+ checkDirectoryDisplayed(null);
+ checkCardsListed(contactA2, contactB2);
+ await deleteRowWithPrompt(1);
+ checkCardsListed(contactA2);
+ Assert.equal(cardsList.currentIndex, 0);
+ bookA.deleteCards([contactA2]);
+ checkCardsListed();
+ Assert.equal(cardsList.currentIndex, -1);
+
+ // While in "All Address Books", delete a directory that has contacts and
+ // mailing lists. They should disappear.
+ let contactA3 = bookA.addCard(createContact("contact", "A3")); // Add A3.
+ checkCardsListed(contactA3);
+ let listF = bookA.addMailList(createMailingList("list F")); // Add F.
+ checkCardsListed(contactA3, listF);
+ await promiseDirectoryRemoved(bookA.URI);
+ checkCardsListed();
+
+ abWindow.close();
+
+ await promiseDirectoryRemoved(bookB.URI);
+});
+
+/**
+ * Tests that added contacts are inserted in the right place in the list.
+ */
+add_task(async function test_insertion_order() {
+ await openAddressBookWindow();
+
+ let bookA = createAddressBook("book A");
+ openDirectory(bookA);
+ checkCardsListed();
+ let contactA2 = bookA.addCard(createContact("contact", "A2"));
+ checkCardsListed(contactA2);
+ let contactA1 = bookA.addCard(createContact("contact", "A1")); // Add first.
+ checkCardsListed(contactA1, contactA2);
+ let contactA5 = bookA.addCard(createContact("contact", "A5")); // Add last.
+ checkCardsListed(contactA1, contactA2, contactA5);
+ let contactA3 = bookA.addCard(createContact("contact", "A3")); // Add in the middle.
+ checkCardsListed(contactA1, contactA2, contactA3, contactA5);
+
+ // Flip sort direction.
+ await showSortMenu("sort", "GeneratedName descending");
+
+ checkCardsListed(contactA5, contactA3, contactA2, contactA1);
+ let contactA4 = bookA.addCard(createContact("contact", "A4")); // Add in the middle.
+ checkCardsListed(contactA5, contactA4, contactA3, contactA2, contactA1);
+ let contactA7 = bookA.addCard(createContact("contact", "A7")); // Add first.
+ checkCardsListed(
+ contactA7,
+ contactA5,
+ contactA4,
+ contactA3,
+ contactA2,
+ contactA1
+ );
+ let contactA0 = bookA.addCard(createContact("contact", "A0")); // Add last.
+ checkCardsListed(
+ contactA7,
+ contactA5,
+ contactA4,
+ contactA3,
+ contactA2,
+ contactA1,
+ contactA0
+ );
+
+ contactA3.displayName = "contact A6";
+ contactA3.lastName = "contact A3";
+ contactA3.primaryEmail = "contact.A6@invalid";
+ bookA.modifyCard(contactA3); // Rename, should change position.
+ checkCardsListed(
+ contactA7,
+ contactA3, // Actually A6.
+ contactA5,
+ contactA4,
+ contactA2,
+ contactA1,
+ contactA0
+ );
+
+ // Restore original sort direction.
+ await showSortMenu("sort", "GeneratedName ascending");
+
+ checkCardsListed(
+ contactA0,
+ contactA1,
+ contactA2,
+ contactA4,
+ contactA5,
+ contactA3, // Actually A6.
+ contactA7
+ );
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(bookA.URI);
+});
+
+/**
+ * Tests the name column is updated when the format changes.
+ */
+add_task(async function test_name_column() {
+ const {
+ GENERATE_DISPLAY_NAME,
+ GENERATE_LAST_FIRST_ORDER,
+ GENERATE_FIRST_LAST_ORDER,
+ } = Ci.nsIAbCard;
+
+ let book = createAddressBook("book");
+ book.addCard(createContact("alpha", "tango", "kilo"));
+ book.addCard(createContact("bravo", "zulu", "quebec"));
+ book.addCard(createContact("charlie", "mike", "whiskey"));
+ book.addCard(createContact("delta", "foxtrot", "sierra"));
+ book.addCard(createContact("echo", "november", "uniform"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ // Check the format is display name, ascending.
+ Assert.equal(
+ Services.prefs.getIntPref("mail.addr_book.lastnamefirst"),
+ GENERATE_DISPLAY_NAME
+ );
+
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+
+ // Select the "delta foxtrot" contact. This should remain selected throughout.
+ cardsList.selectedIndex = 2;
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "foxtrot, delta",
+ "mike, charlie",
+ "november, echo",
+ "tango, alpha",
+ "zulu, bravo"
+ );
+ Assert.equal(cardsList.selectedIndex, 0);
+ Assert.deepEqual(cardsList.selectedIndices, [0]);
+
+ // Change the format to first last.
+ await showSortMenu("format", GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Flip the order to descending.
+ await showSortMenu("sort", "GeneratedName descending");
+
+ checkNamesListed(
+ "echo november",
+ "delta foxtrot",
+ "charlie mike",
+ "bravo zulu",
+ "alpha tango"
+ );
+ Assert.equal(cardsList.selectedIndex, 1);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "zulu, bravo",
+ "tango, alpha",
+ "november, echo",
+ "mike, charlie",
+ "foxtrot, delta"
+ );
+ Assert.equal(cardsList.selectedIndex, 4);
+
+ // Change the format to display name.
+ await showSortMenu("format", GENERATE_DISPLAY_NAME);
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ // Sort by email address, ascending.
+ await showSortMenu("sort", "EmailAddresses ascending");
+
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to last, first.
+ await showSortMenu("format", GENERATE_LAST_FIRST_ORDER);
+ checkNamesListed(
+ "tango, alpha",
+ "zulu, bravo",
+ "mike, charlie",
+ "foxtrot, delta",
+ "november, echo"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to first last.
+ await showSortMenu("format", GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Change the format to display name.
+ await showSortMenu("format", GENERATE_DISPLAY_NAME);
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+ Assert.equal(cardsList.selectedIndex, 3);
+
+ // Restore original sort column and direction.
+ await showSortMenu("sort", "GeneratedName ascending");
+
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+ Assert.equal(cardsList.selectedIndex, 2);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests that sort order and name format survive closing and reopening.
+ */
+add_task(async function test_persistence() {
+ let book = createAddressBook("book");
+ book.addCard(createContact("alpha", "tango", "kilo"));
+ book.addCard(createContact("bravo", "zulu", "quebec"));
+ book.addCard(createContact("charlie", "mike", "whiskey"));
+ book.addCard(createContact("delta", "foxtrot", "sierra"));
+ book.addCard(createContact("echo", "november", "uniform"));
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.addr_book.lastnamefirst");
+
+ await openAddressBookWindow();
+ checkNamesListed("kilo", "quebec", "sierra", "uniform", "whiskey");
+
+ info("sorting by GeneratedName, descending");
+ await showSortMenu("sort", "GeneratedName descending");
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed("whiskey", "uniform", "sierra", "quebec", "kilo");
+
+ info("sorting by EmailAddresses, ascending");
+ await showSortMenu("sort", "EmailAddresses ascending");
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed("kilo", "quebec", "whiskey", "sierra", "uniform");
+
+ info("setting name format to first last");
+ await showSortMenu("format", Ci.nsIAbCard.GENERATE_FIRST_LAST_ORDER);
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+
+ await closeAddressBookWindow();
+ info("address book closed, reopening");
+ await openAddressBookWindow();
+ checkNamesListed(
+ "alpha tango",
+ "bravo zulu",
+ "charlie mike",
+ "delta foxtrot",
+ "echo november"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.addr_book.lastnamefirst");
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the context menu compose items.
+ */
+add_task(async function test_context_menu_compose() {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let book = createAddressBook("Book");
+ let contactA = book.addCard(createContact("Contact", "A"));
+ let contactB = createContact("Contact", "B");
+ contactB.setProperty("SecondEmail", "b.contact@invalid");
+ contactB = book.addCard(contactB);
+ let contactC = createContact("Contact", "C");
+ contactC.primaryEmail = null;
+ contactC.setProperty("SecondEmail", "c.contact@invalid");
+ contactC = book.addCard(contactC);
+ let contactD = createContact("Contact", "D");
+ contactD.primaryEmail = null;
+ contactD = book.addCard(contactD);
+ let list = book.addMailList(createMailingList("List"));
+ list.addCard(contactA);
+ list.addCard(contactB);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let writeMenuItem = abDocument.getElementById("cardContextWrite");
+ let writeMenu = abDocument.getElementById("cardContextWriteMenu");
+ let writeMenuSeparator = abDocument.getElementById(
+ "cardContextWriteSeparator"
+ );
+
+ openDirectory(book);
+
+ // Contact A, first and only email address.
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(0);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact A <contact.a@invalid>"
+ );
+
+ // Contact B, first email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(1);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(!writeMenu.hidden, "write menu shown");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ let shownPromise = BrowserTestUtils.waitForEvent(writeMenu, "popupshown");
+ writeMenu.openMenu(true);
+ await shownPromise;
+ let subMenuItems = writeMenu.querySelectorAll("menuitem");
+ Assert.equal(subMenuItems.length, 2);
+ Assert.equal(subMenuItems[0].label, "Contact B <contact.b@invalid>");
+ Assert.equal(subMenuItems[1].label, "Contact B <b.contact@invalid>");
+
+ writeMenu.menupopup.activateItem(subMenuItems[0]);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>"
+ );
+
+ // Contact B, second email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(1);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(!writeMenu.hidden, "write menu shown");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ shownPromise = BrowserTestUtils.waitForEvent(writeMenu, "popupshown");
+ writeMenu.openMenu(true);
+ await shownPromise;
+ subMenuItems = writeMenu.querySelectorAll("menuitem");
+ Assert.equal(subMenuItems.length, 2);
+ Assert.equal(subMenuItems[0].label, "Contact B <contact.b@invalid>");
+ Assert.equal(subMenuItems[1].label, "Contact B <b.contact@invalid>");
+
+ writeMenu.menupopup.activateItem(subMenuItems[1]);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <b.contact@invalid>"
+ );
+
+ // Contact C, second and only email address.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(2);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact C <c.contact@invalid>"
+ );
+
+ // Contact D, no email address.
+
+ await rightClickOnIndex(3);
+ Assert.ok(writeMenuItem.hidden, "write menu item hidden");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(writeMenuSeparator.hidden, "write menu separator hidden");
+ menu.hidePopup();
+
+ // List.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ await rightClickOnIndex(4);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(await composeWindowPromise, "List <List>");
+
+ // Contact A and Contact D.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [0, 3];
+ await rightClickOnIndex(3);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact A <contact.a@invalid>"
+ );
+
+ // Contact B and Contact C.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [1, 2];
+ await rightClickOnIndex(2);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>",
+ "Contact C <c.contact@invalid>"
+ );
+
+ // Contact B and List.
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+
+ cardsList.selectedIndices = [1, 4];
+ await rightClickOnIndex(4);
+ Assert.ok(!writeMenuItem.hidden, "write menu item shown");
+ Assert.ok(writeMenu.hidden, "write menu hidden");
+ Assert.ok(!writeMenuSeparator.hidden, "write menu separator shown");
+ menu.activateItem(writeMenuItem);
+
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "Contact B <contact.b@invalid>",
+ "List <List>"
+ );
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the context menu edit items.
+ */
+add_task(async function test_context_menu_edit() {
+ let normalBook = createAddressBook("Normal Book");
+ let normalList = normalBook.addMailList(createMailingList("Normal List"));
+ let normalContact = normalBook.addCard(createContact("Normal", "Contact"));
+ normalList.addCard(normalContact);
+
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ let readOnlyContact = readOnlyBook.addCard(
+ createContact("Read-Only", "Contact")
+ );
+ readOnlyList.addCard(readOnlyContact);
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let editMenuItem = abDocument.getElementById("cardContextEdit");
+ let exportMenuItem = abDocument.getElementById("cardContextExport");
+
+ async function checkEditItems(index, hidden, isMailList = false) {
+ await rightClickOnIndex(index);
+
+ Assert.equal(
+ editMenuItem.hidden,
+ hidden,
+ `editMenuItem should be hidden=${hidden} on index ${index}`
+ );
+ Assert.equal(
+ exportMenuItem.hidden,
+ !isMailList,
+ `exportMenuItem should be hidden=${!isMailList} on index ${index}`
+ );
+
+ Assert.deepEqual(document.l10n.getAttributes(editMenuItem), {
+ id: isMailList
+ ? "about-addressbook-books-context-edit-list"
+ : "about-addressbook-books-context-edit",
+ args: null,
+ });
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ info("Testing Normal Book");
+ openDirectory(normalBook);
+ await checkEditItems(0, false); // normal contact
+ await checkEditItems(1, false, true); // normal list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkEditItems(0, true); // normal contact + normal list
+ await checkEditItems(1, true); // normal contact + normal list
+
+ info("Testing Normal List");
+ openDirectory(normalList);
+ await checkEditItems(0, false); // normal contact
+
+ info("Testing Read-Only Book");
+ openDirectory(readOnlyBook);
+ await checkEditItems(0, true); // read-only contact
+ await checkEditItems(1, true, true); // read-only list
+
+ info("Testing Read-Only List");
+ openDirectory(readOnlyList);
+ await checkEditItems(0, true); // read-only contact
+
+ info("Testing All Address Books");
+ openAllAddressBooks();
+ await checkEditItems(0, false); // normal contact
+ await checkEditItems(1, false, true); // normal list
+ await checkEditItems(2, true); // read-only contact
+ await checkEditItems(3, true, true); // read-only list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkEditItems(1, true); // normal contact + normal list
+
+ cardsList.selectedIndices = [0, 2];
+ await checkEditItems(2, true); // normal contact + read-only contact
+
+ cardsList.selectedIndices = [1, 3];
+ await checkEditItems(3, true); // normal list + read-only list
+
+ cardsList.selectedIndices = [0, 1, 2, 3];
+ await checkEditItems(3, true); // everything
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(normalBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Tests the context menu delete items.
+ */
+add_task(async function test_context_menu_delete() {
+ let normalBook = createAddressBook("Normal Book");
+ let normalList = normalBook.addMailList(createMailingList("Normal List"));
+ let normalContact = normalBook.addCard(createContact("Normal", "Contact"));
+ normalList.addCard(normalContact);
+
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ let readOnlyContact = readOnlyBook.addCard(
+ createContact("Read-Only", "Contact")
+ );
+ readOnlyList.addCard(readOnlyContact);
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ let menu = abDocument.getElementById("cardContext");
+ let deleteMenuItem = abDocument.getElementById("cardContextDelete");
+ let removeMenuItem = abDocument.getElementById("cardContextRemove");
+
+ async function checkDeleteItems(index, deleteHidden, removeHidden, disabled) {
+ await rightClickOnIndex(index);
+
+ Assert.equal(
+ deleteMenuItem.hidden,
+ deleteHidden,
+ `deleteMenuItem.hidden on index ${index}`
+ );
+ Assert.equal(
+ deleteMenuItem.disabled,
+ disabled,
+ `deleteMenuItem.disabled on index ${index}`
+ );
+ Assert.equal(
+ removeMenuItem.hidden,
+ removeHidden,
+ `removeMenuItem.hidden on index ${index}`
+ );
+ Assert.equal(
+ removeMenuItem.disabled,
+ disabled,
+ `removeMenuItem.disabled on index ${index}`
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ info("Testing Normal Book");
+ openDirectory(normalBook);
+ await checkDeleteItems(0, false, true, false); // normal contact
+ await checkDeleteItems(1, false, true, false); // normal list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkDeleteItems(0, false, true, false); // normal contact + normal list
+ await checkDeleteItems(1, false, true, false); // normal contact + normal list
+
+ info("Testing Normal List");
+ openDirectory(normalList);
+ await checkDeleteItems(0, true, false, false); // normal contact
+
+ info("Testing Read-Only Book");
+ openDirectory(readOnlyBook);
+ await checkDeleteItems(0, false, true, true); // read-only contact
+ await checkDeleteItems(1, false, true, true); // read-only list
+
+ info("Testing Read-Only List");
+ openDirectory(readOnlyList);
+ await checkDeleteItems(0, true, false, true); // read-only contact
+
+ info("Testing All Address Books");
+ openAllAddressBooks();
+ await checkDeleteItems(0, false, true, false); // normal contact
+ await checkDeleteItems(1, false, true, false); // normal list
+ await checkDeleteItems(2, false, true, true); // read-only contact
+ await checkDeleteItems(3, false, true, true); // read-only list
+
+ cardsList.selectedIndices = [0, 1];
+ await checkDeleteItems(1, false, true, false); // normal contact + normal list
+
+ cardsList.selectedIndices = [0, 2];
+ await checkDeleteItems(2, false, true, true); // normal contact + read-only contact
+
+ cardsList.selectedIndices = [1, 3];
+ await checkDeleteItems(3, false, true, true); // normal list + read-only list
+
+ cardsList.selectedIndices = [0, 1, 2, 3];
+ await checkDeleteItems(3, false, true, true); // everything
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(normalBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+add_task(async function test_layout() {
+ function checkColumns(visibleColumns, sortColumn, sortDirection) {
+ let visibleHeaders = cardsHeader.querySelectorAll(
+ `th[is="tree-view-table-header-cell"]:not([hidden])`
+ );
+ Assert.deepEqual(
+ Array.from(visibleHeaders, h => h.id),
+ visibleColumns,
+ "visible columns are correct"
+ );
+
+ for (let header of visibleHeaders) {
+ let button = header.querySelector("button");
+ Assert.equal(
+ button.classList.contains("ascending"),
+ header.id == sortColumn && sortDirection == "ascending",
+ `${header.id} header is ascending`
+ );
+ Assert.equal(
+ button.classList.contains("descending"),
+ header.id == sortColumn && sortDirection == "descending",
+ `${header.id} header is descending`
+ );
+ }
+ }
+
+ function checkRowHeight(height) {
+ Assert.equal(cardsList.getRowAtIndex(0).clientHeight, height);
+ }
+
+ Services.prefs.setIntPref("mail.uidensity", 0);
+ personalBook.addCard(
+ createContact("contact", "one", undefined, "first@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "two", undefined, "second@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "three", undefined, "third@invalid")
+ );
+ personalBook.addCard(
+ createContact("contact", "four", undefined, "fourth@invalid")
+ );
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+ let sharedSplitter = abDocument.getElementById("sharedSplitter");
+
+ // Sanity check.
+
+ Assert.ok(
+ !abDocument.body.classList.contains("layout-table"),
+ "not table layout on opening"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "horizontal",
+ "splitter direction is horizontal"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "cardsPane",
+ "splitter is affecting the cards pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-card-row",
+ "list row implementation used"
+ );
+
+ // Switch layout to table.
+
+ await toggleLayout();
+
+ Assert.ok(
+ abDocument.body.classList.contains("layout-table"),
+ "layout changed"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "vertical",
+ "splitter direction is vertical"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "detailsPane",
+ "splitter is affecting the details pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-table-card-row",
+ "table row implementation used"
+ );
+
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "GeneratedName",
+ "ascending"
+ );
+ checkNamesListed(
+ "contact four",
+ "contact one",
+ "contact three",
+ "contact two"
+ );
+ checkRowHeight(18);
+
+ // Click the email addresses header to sort.
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsHeader.querySelector(`[id="EmailAddressesButton"]`),
+ {},
+ abWindow
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "EmailAddresses",
+ "ascending"
+ );
+ checkNamesListed(
+ "contact one",
+ "contact four",
+ "contact two",
+ "contact three"
+ );
+
+ // Click the email addresses header again to flip the sort.
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsHeader.querySelector(`[id="EmailAddressesButton"]`),
+ {},
+ abWindow
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses"],
+ "EmailAddresses",
+ "descending"
+ );
+ checkNamesListed(
+ "contact three",
+ "contact two",
+ "contact four",
+ "contact one"
+ );
+
+ // Add a column.
+
+ await showPickerMenu("toggle", "Title");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="Title"]`).hidden
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Addresses", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+
+ // Remove a column.
+
+ await showPickerMenu("toggle", "Addresses");
+ await TestUtils.waitForCondition(
+ () => cardsHeader.querySelector(`[id="Addresses"]`).hidden
+ );
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+
+ // Change the density.
+
+ Services.prefs.setIntPref("mail.uidensity", 1);
+ checkRowHeight(22);
+
+ Services.prefs.setIntPref("mail.uidensity", 2);
+ checkRowHeight(32);
+
+ // Close and reopen the Address Book and check that settings were remembered.
+
+ await closeAddressBookWindow();
+
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+ cardsList = abWindow.cardsPane.cardsList;
+ cardsHeader = abWindow.cardsPane.table.header;
+ sharedSplitter = abDocument.getElementById("sharedSplitter");
+
+ Assert.ok(
+ abDocument.body.classList.contains("layout-table"),
+ "table layout preserved on reopening"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "vertical",
+ "splitter direction preserved as vertical"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "detailsPane",
+ "splitter preserved affecting the details pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-table-card-row",
+ "table row implementation used"
+ );
+
+ checkColumns(
+ ["GeneratedName", "EmailAddresses", "PhoneNumbers", "Title"],
+ "EmailAddresses",
+ "descending"
+ );
+ checkNamesListed(
+ "contact three",
+ "contact two",
+ "contact four",
+ "contact one"
+ );
+ checkRowHeight(32);
+
+ // Reset layout to list.
+
+ await toggleLayout();
+
+ Assert.ok(
+ !abDocument.body.classList.contains("layout-table"),
+ "layout changed"
+ );
+ Assert.equal(
+ sharedSplitter.resizeDirection,
+ "horizontal",
+ "splitter direction is horizontal"
+ );
+ Assert.equal(
+ sharedSplitter.resizeElement.id,
+ "cardsPane",
+ "splitter is affecting the cards pane"
+ );
+ Assert.equal(
+ cardsList.getAttribute("rows"),
+ "ab-card-row",
+ "list row implementation used"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.xulStore.removeDocument("about:addressbook");
+ Services.prefs.clearUserPref("mail.uidensity");
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+add_task(async function test_placeholders() {
+ let writableBook = createAddressBook("Writable Book");
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+ let placeholderCreateContact = abWindow.document.getElementById(
+ "placeholderCreateContact"
+ );
+
+ info("checking all address books");
+ await openAllAddressBooks();
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ info("checking writable book");
+ await openDirectory(writableBook);
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ let writableList = writableBook.addMailList(
+ createMailingList("Writable List")
+ );
+ checkPlaceholders();
+
+ info("checking writable list");
+ await openDirectory(writableList);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking writable book");
+ await openDirectory(writableBook);
+ writableBook.deleteDirectory(writableList);
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+
+ info("checking read-only book");
+ await openDirectory(readOnlyBook);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ // This wouldn't happen but we need to check the state in a read-only list.
+ readOnlyBook.setBoolValue("readOnly", false);
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ readOnlyBook.setBoolValue("readOnly", true);
+ checkPlaceholders();
+
+ info("checking read-only list");
+ await openDirectory(readOnlyList);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking read-only book");
+ await openDirectory(readOnlyBook);
+ readOnlyBook.setBoolValue("readOnly", false);
+ readOnlyBook.deleteDirectory(readOnlyList);
+ readOnlyBook.setBoolValue("readOnly", true);
+ checkPlaceholders(["placeholderEmptyBook"]);
+
+ info("checking button opens a new contact to edit");
+ await openAllAddressBooks();
+ checkPlaceholders(["placeholderEmptyBook", "placeholderCreateContact"]);
+ EventUtils.synthesizeMouseAtCenter(placeholderCreateContact, {}, abWindow);
+
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(writableBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Checks that mailling lists address books are shown in the table layout.
+ */
+add_task(async function test_list_table_layout() {
+ let book = createAddressBook("Book");
+ book.addCard(createContact("contact", "one"));
+ let list = createMailingList("list one");
+ book.addMailList(list);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+
+ // Switch layout to table.
+
+ await toggleLayout();
+
+ await showPickerMenu("toggle", "addrbook");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="addrbook"]`).hidden
+ );
+
+ // Check for the contact that the column is shown.
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".addrbook-column").hidden,
+ "Address book column is shown."
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".addrbook-column")
+ .textContent.includes("Book"),
+ "Address book column has the correct name for a contact."
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".addrbook-column")
+ .textContent.includes("Book"),
+ "Address book column has the correct name for a list."
+ );
+
+ Services.xulStore.removeDocument("about:addressbook");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests the option of showing the address book for All Address Book for the
+ * list view (vertical layout).
+ */
+add_task(async function test_list_all_address_book() {
+ let firstBook = createAddressBook("First Book");
+ let secondBook = createAddressBook("Second Book");
+ firstBook.addCard(createContact("contact", "one"));
+ secondBook.addCard(createContact("contact", "two"));
+ let list = createMailingList("list two");
+ secondBook.addMailList(list);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.cardsPane.cardsList;
+ let cardsHeader = abWindow.cardsPane.table.header;
+
+ info("Check that no address book suffix is present.");
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+ Assert.ok(
+ !cardsList.getRowAtIndex(1).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+ Assert.ok(
+ !cardsList.getRowAtIndex(2).querySelector(".address-book-name"),
+ "No address book suffix is present."
+ );
+
+ info("Toggle the option to show address books.");
+ await showSortMenu("toggle", "addrbook");
+ await TestUtils.waitForCondition(
+ () => !cardsHeader.querySelector(`[id="addrbook"]`).hidden
+ );
+
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(0)
+ .querySelector(".address-book-name")
+ .textContent.includes("First Book"),
+ "Address book suffix is present."
+ );
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(1)
+ .querySelector(".address-book-name")
+ .textContent.includes("Second Book"),
+ "Address book suffix is present."
+ );
+ Assert.ok(
+ cardsList
+ .getRowAtIndex(2)
+ .querySelector(".address-book-name")
+ .textContent.includes("Second Book"),
+ "Address book suffix is present for a list."
+ );
+
+ info(`Select another address book and check that no address book suffix is
+ present for another book besides All Address Book`);
+ await openDirectory(secondBook);
+ Assert.ok(
+ !cardsList.getRowAtIndex(0).querySelector(".address-book-name"),
+ "Address book suffix is only present in All Address Book."
+ );
+
+ Services.xulStore.removeDocument("about:addressbook");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(firstBook.URI);
+ await promiseDirectoryRemoved(secondBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_directory_tree.js b/comm/mail/components/addrbook/test/browser/browser_directory_tree.js
new file mode 100644
index 0000000000..ee4b31ab7c
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_directory_tree.js
@@ -0,0 +1,982 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function rightClickOnIndex(index) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.booksList;
+ let menu = abWindow.document.getElementById("bookContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ booksList
+ .getRowAtIndex(index)
+ .querySelector(".bookRow-name, .listRow-name"),
+ { type: "contextmenu" },
+ abWindow
+ );
+ return shownPromise;
+}
+
+/**
+ * Tests that additions and removals are accurately displayed.
+ */
+add_task(async function test_additions_and_removals() {
+ function checkBooksOrder(...expected) {
+ function checkRow(index, { level, open, isList, text, uid }) {
+ info(`Row ${index}`);
+ let row = rows[index];
+
+ let containingList = row.closest("ul");
+ if (level == 1) {
+ Assert.equal(containingList.getAttribute("is"), "ab-tree-listbox");
+ } else if (level == 2) {
+ Assert.equal(containingList.parentNode.localName, "li");
+ containingList = containingList.parentNode.closest("ul");
+ Assert.equal(containingList.getAttribute("is"), "ab-tree-listbox");
+ }
+
+ let childList = row.querySelector("ul");
+ // NOTE: We're not explicitly handling open === false because no test
+ // needed it.
+ if (open) {
+ // Ancestor shouldn't have the collapsed class and the UL child list
+ // should be expanded and visible.
+ Assert.ok(!row.classList.contains("collapsed"));
+ Assert.greater(childList.clientHeight, 0);
+ } else if (childList) {
+ if (row.classList.contains("collapsed")) {
+ // If we have a UL child list and the ancestor element has a collapsed
+ // class, the child list shouldn't be visible.
+ Assert.equal(childList.clientHeight, 0);
+ } else if (childList.childNodes.length) {
+ // If the ancestor doesn't have the collapsed class, and the UL child
+ // list has at least one child node, the child list should be visible.
+ Assert.greater(childList.clientHeight, 0);
+ }
+ }
+
+ Assert.equal(row.classList.contains("listRow"), isList);
+ Assert.equal(row.querySelector("span").textContent, text);
+ Assert.equal(row.getAttribute("aria-label"), text);
+ Assert.equal(row.dataset.uid, uid);
+ }
+
+ let rows = abWindow.booksList.rows;
+ Assert.equal(rows.length, expected.length + 1);
+ for (let i = 0; i < expected.length; i++) {
+ let dir = expected[i].directory;
+ checkRow(i + 1, {
+ ...expected[i],
+ isList: dir.isMailList,
+ text: dir.dirName,
+ uid: dir.UID,
+ });
+ }
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ // Check the initial order.
+
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add one book, *not* using the UI, and check that we don't move to it.
+
+ let newBook1 = createAddressBook("New Book 1");
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add another book, using the UI, and check that we move to the new book.
+
+ let newBook2 = await createAddressBookWithUI("New Book 2");
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add some lists, *not* using the UI, and check that we don't move to them.
+
+ let list1 = newBook1.addMailList(createMailingList("New Book 1 - List 1"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list1 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list3 = newBook1.addMailList(createMailingList("New Book 1 - List 3"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list0 = newBook1.addMailList(createMailingList("New Book 1 - List 0"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list2 = newBook1.addMailList(createMailingList("New Book 1 - List 2"));
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Close the window and open it again. The tree should be as it was before.
+
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+
+ checkDirectoryDisplayed(null);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2 },
+ { level: 1, directory: historyBook }
+ );
+
+ openDirectory(newBook2);
+
+ let list4 = newBook2.addMailList(createMailingList("New Book 2 - List 4"));
+ checkDirectoryDisplayed(newBook2);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Add a new list, using the UI, and check that we move to it.
+
+ let list5 = await createMailingListWithUI(newBook2, "New Book 2 - List 5");
+ checkDirectoryDisplayed(list5);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 1, directory: historyBook }
+ );
+
+ let list6 = await createMailingListWithUI(newBook2, "New Book 2 - List 6");
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 2, directory: list3 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+ // Delete a list that isn't displayed, and check that we don't move.
+
+ newBook1.deleteDirectory(list3);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list5 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Select list5
+ let list5Row = abWindow.booksList.getRowForUID(list5.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ list5Row.querySelector("span"),
+ {},
+ abWindow
+ );
+ checkDirectoryDisplayed(list5);
+
+ // Delete the displayed list, and check that we move to the next list under
+ // the same book.
+
+ newBook2.deleteDirectory(list5);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list6);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 2, directory: list6 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Delete the last list, and check we move to the previous list under the same
+ // book.
+ newBook2.deleteDirectory(list6);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(list4);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: newBook2, open: true },
+ { level: 2, directory: list4 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Delete the displayed book, and check that we move to the next book.
+
+ await promiseDirectoryRemoved(newBook2.URI);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(historyBook);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: newBook1, open: true },
+ { level: 2, directory: list0 },
+ { level: 2, directory: list1 },
+ { level: 2, directory: list2 },
+ { level: 1, directory: historyBook }
+ );
+
+ // Select a list in the first book, then delete the book. Check that we
+ // move to the next book.
+
+ openDirectory(list1);
+ await promiseDirectoryRemoved(newBook1.URI);
+ await new Promise(r => abWindow.setTimeout(r));
+ checkDirectoryDisplayed(historyBook);
+ checkBooksOrder(
+ { level: 1, directory: personalBook },
+ { level: 1, directory: historyBook }
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests that renaming or deleting books or lists is reflected in the UI.
+ */
+add_task(async function test_rename_and_delete() {
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+ let searchInput = abWindow.searchInput;
+ Assert.equal(booksList.rowCount, 3);
+
+ // Create a book.
+
+ EventUtils.synthesizeMouseAtCenter(booksList, {}, abWindow);
+ let newBook = await createAddressBookWithUI("New Book");
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let bookRow = booksList.getRowAtIndex(2);
+ Assert.equal(bookRow.querySelector(".bookRow-name").textContent, "New Book");
+ Assert.equal(bookRow.getAttribute("aria-label"), "New Book");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search New Book",
+ "search placeholder updated"
+ );
+
+ // Rename the book.
+
+ let menu = abDocument.getElementById("bookContext");
+ let propertiesMenuItem = abDocument.getElementById("bookContextProperties");
+
+ await rightClickOnIndex(2);
+
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.deepEqual(document.l10n.getAttributes(propertiesMenuItem), {
+ id: "about-addressbook-books-context-properties",
+ args: null,
+ });
+
+ let dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("name");
+ Assert.equal(nameInput.value, "New Book");
+ nameInput.value = "Old Book";
+
+ dialogDocument.querySelector("dialog").getButton("accept").click();
+ });
+ menu.activateItem(propertiesMenuItem);
+ await dialogPromise;
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ bookRow = booksList.getRowAtIndex(2);
+ Assert.equal(bookRow.querySelector(".bookRow-name").textContent, "Old Book");
+ Assert.equal(bookRow.getAttribute("aria-label"), "Old Book");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search Old Book",
+ "search placeholder updated"
+ );
+
+ // Create a list.
+
+ let newList = await createMailingListWithUI(newBook, "New List");
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.getIndexForUID(newList.UID), 3);
+ Assert.equal(booksList.selectedIndex, 3);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let listRow = booksList.getRowAtIndex(3);
+ Assert.equal(
+ listRow.compareDocumentPosition(bookRow),
+ Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_PRECEDING
+ );
+ Assert.equal(listRow.querySelector(".listRow-name").textContent, "New List");
+ Assert.equal(listRow.getAttribute("aria-label"), "New List");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search New List",
+ "search placeholder updated"
+ );
+
+ // Rename the list.
+
+ await rightClickOnIndex(3);
+
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.deepEqual(document.l10n.getAttributes(propertiesMenuItem), {
+ id: "about-addressbook-books-context-edit-list",
+ args: null,
+ });
+
+ dialogPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let nameInput = dialogDocument.getElementById("ListName");
+ Assert.equal(nameInput.value, "New List");
+ nameInput.value = "Old List";
+
+ dialogDocument.querySelector("dialog").getButton("accept").click();
+ });
+ menu.activateItem(propertiesMenuItem);
+ await dialogPromise;
+
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.getIndexForUID(newList.UID), 3);
+ Assert.equal(booksList.selectedIndex, 3);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ listRow = booksList.getRowAtIndex(3);
+ Assert.equal(listRow.querySelector(".listRow-name").textContent, "Old List");
+ Assert.equal(listRow.getAttribute("aria-label"), "Old List");
+
+ await TestUtils.waitForCondition(
+ () => searchInput.placeholder == "Search Old List",
+ "search placeholder updated"
+ );
+
+ // Delete the list.
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ EventUtils.synthesizeKey("KEY_Delete", {}, abWindow);
+ await promptPromise;
+ await selectPromise;
+ Assert.equal(newBook.childNodes.length, 0, "list was actually deleted");
+ await new Promise(r => abWindow.setTimeout(r));
+
+ Assert.equal(booksList.rowCount, 4);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), 2);
+ Assert.equal(booksList.getIndexForUID(newList.UID), -1);
+ // Moves to parent when last list is deleted.
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ bookRow = booksList.getRowAtIndex(2);
+ Assert.ok(!bookRow.classList.contains("children"));
+ Assert.ok(!bookRow.querySelector("ul, li"));
+
+ // Delete the book.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ EventUtils.synthesizeKey("KEY_Delete", {}, abWindow);
+ await promptPromise;
+ await selectPromise;
+ Assert.equal(
+ MailServices.ab.directories.length,
+ 2,
+ "book was actually deleted"
+ );
+
+ Assert.equal(booksList.rowCount, 3);
+ Assert.equal(booksList.getIndexForUID(newBook.UID), -1);
+ Assert.equal(booksList.selectedIndex, 2);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ // Attempt to delete the All Address Books entry.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 0;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Cannot delete the All Address Books item/,
+ "Attempting to delete All Address Books should fail."
+ );
+
+ // Attempt to delete Personal Address Book.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 1;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Refusing to delete a built-in address book/,
+ "Attempting to delete Personal Address Book should fail."
+ );
+
+ // Attempt to delete Collected Addresses.
+ // Synthesizing the delete key here does not throw immediately.
+
+ booksList.selectedIndex = 2;
+ await Assert.rejects(
+ booksList.deleteSelected(),
+ /Refusing to delete a built-in address book/,
+ "Attempting to delete Collected Addresses should fail."
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests the context menu of the list.
+ */
+add_task(async function test_context_menu() {
+ let book = createAddressBook("Ordinary Book");
+ book.addMailList(createMailingList("Ordinary List"));
+ createAddressBook("CardDAV Book", Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+
+ let menu = abWindow.document.getElementById("bookContext");
+ let propertiesMenuItem = abDocument.getElementById("bookContextProperties");
+ let synchronizeMenuItem = abDocument.getElementById("bookContextSynchronize");
+ let printMenuItem = abDocument.getElementById("bookContextPrint");
+ let deleteMenuItem = abDocument.getElementById("bookContextDelete");
+ let removeMenuItem = abDocument.getElementById("bookContextRemove");
+ let startupDefaultItem = abDocument.getElementById(
+ "bookContextStartupDefault"
+ );
+
+ Assert.equal(booksList.rowCount, 6);
+
+ // Test that the menu does not show for All Address Books.
+
+ await rightClickOnIndex(0);
+ Assert.equal(booksList.selectedIndex, 0);
+ Assert.equal(abDocument.activeElement, booksList);
+
+ let visibleItems = [...menu.children].filter(BrowserTestUtils.is_visible);
+ Assert.equal(visibleItems.length, 1);
+ Assert.equal(
+ visibleItems[0],
+ startupDefaultItem,
+ "only the startup default item should be visible"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+
+ // Test directories that can't be deleted.
+
+ for (let index of [1, booksList.rowCount - 1]) {
+ await rightClickOnIndex(index);
+ Assert.equal(booksList.selectedIndex, index);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(deleteMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(removeMenuItem));
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+ }
+
+ // Test and delete CardDAV directory at index 4.
+
+ await rightClickOnIndex(4);
+ Assert.equal(booksList.selectedIndex, 4);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(!synchronizeMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(removeMenuItem));
+ Assert.ok(!removeMenuItem.disabled);
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(removeMenuItem);
+ await promptPromise;
+ await selectPromise;
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+
+ Assert.equal(booksList.rowCount, 5);
+ Assert.equal(booksList.selectedIndex, 4);
+ Assert.equal(menu.state, "closed");
+
+ // Test and delete list at index 3, then directory at index 2.
+
+ for (let index of [3, 2]) {
+ await new Promise(r => abWindow.setTimeout(r, 250));
+ await rightClickOnIndex(index);
+ Assert.equal(booksList.selectedIndex, index);
+ Assert.ok(BrowserTestUtils.is_visible(propertiesMenuItem));
+ Assert.ok(!propertiesMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(synchronizeMenuItem));
+ Assert.ok(BrowserTestUtils.is_visible(printMenuItem));
+ Assert.ok(!printMenuItem.disabled);
+ Assert.ok(BrowserTestUtils.is_visible(deleteMenuItem));
+ Assert.ok(!deleteMenuItem.disabled);
+ Assert.ok(!BrowserTestUtils.is_visible(removeMenuItem));
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ selectPromise = BrowserTestUtils.waitForEvent(booksList, "select");
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(deleteMenuItem);
+ await promptPromise;
+ await selectPromise;
+ await hiddenPromise;
+ Assert.equal(abDocument.activeElement, booksList);
+
+ if (index == 3) {
+ Assert.equal(booksList.rowCount, 4);
+ // Moves to parent when last list is deleted.
+ Assert.equal(booksList.selectedIndex, 2);
+ } else {
+ Assert.equal(booksList.rowCount, 3);
+ Assert.equal(booksList.selectedIndex, 2);
+ }
+ Assert.equal(menu.state, "closed");
+ }
+
+ // Test that the menu does not show beyond the last book.
+
+ EventUtils.synthesizeMouseAtCenter(
+ booksList,
+ 100,
+ booksList.clientHeight - 10,
+ { type: "contextmenu" },
+ abWindow
+ );
+ Assert.equal(booksList.selectedIndex, 2);
+ await new Promise(r => abWindow.setTimeout(r, 500));
+ Assert.equal(menu.state, "closed", "menu stayed closed as expected");
+ Assert.equal(abDocument.activeElement, booksList);
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests the menu button on each item.
+ */
+add_task(async function test_context_menu_button() {
+ let book = createAddressBook("Ordinary Book");
+ book.addMailList(createMailingList("Ordinary List"));
+
+ let abWindow = await openAddressBookWindow();
+ let booksList = abWindow.booksList;
+ let menu = abWindow.document.getElementById("bookContext");
+
+ for (let row of booksList.rows) {
+ info(row.querySelector(".bookRow-name, .listRow-name").textContent);
+ let button = row.querySelector(".bookRow-menu, .listRow-menu");
+ Assert.ok(BrowserTestUtils.is_hidden(button), "menu button is hidden");
+
+ EventUtils.synthesizeMouse(row, 100, 5, { type: "mousemove" }, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(button), "menu button is visible");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(button, {}, abWindow);
+ await shownPromise;
+
+ let buttonRect = button.getBoundingClientRect();
+ let menuRect = menu.getBoundingClientRect();
+ Assert.less(
+ Math.abs(menuRect.top - buttonRect.bottom),
+ 13,
+ "menu appeared near the button vertically"
+ );
+ Assert.less(
+ Math.abs(menuRect.left - buttonRect.left),
+ 20,
+ "menu appeared near the button horizontally"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Tests that the collapsed state of books survives a reload of the page.
+ */
+add_task(async function test_collapse_expand() {
+ Services.xulStore.removeDocument("about:addressbook");
+
+ personalBook.addMailList(createMailingList("Personal List 1"));
+ personalBook.addMailList(createMailingList("Personal List 2"));
+
+ historyBook.addMailList(createMailingList("History List 1"));
+
+ let book1 = createAddressBook("Book 1");
+ book1.addMailList(createMailingList("Book 1 List 1"));
+ book1.addMailList(createMailingList("Book 1 List 2"));
+
+ let book2 = createAddressBook("Book 2");
+ book2.addMailList(createMailingList("Book 2 List 1"));
+ book2.addMailList(createMailingList("Book 2 List 2"));
+ book2.addMailList(createMailingList("Book 2 List 3"));
+
+ function getRowForBook(book) {
+ return abDocument.getElementById(`book-${book.UID}`);
+ }
+
+ function checkCollapsedState(book, expectedCollapsed) {
+ Assert.equal(
+ getRowForBook(book).classList.contains("collapsed"),
+ expectedCollapsed,
+ `${book.dirName} is ${expectedCollapsed ? "collapsed" : "expanded"}`
+ );
+ }
+
+ function toggleCollapsedState(book) {
+ let twisty = getRowForBook(book).querySelector(".twisty");
+ Assert.ok(
+ BrowserTestUtils.is_visible(twisty),
+ `twisty for ${book.dirName} is visible`
+ );
+ EventUtils.synthesizeMouseAtCenter(twisty, {}, abWindow);
+ }
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, false);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(book2, false);
+ checkCollapsedState(historyBook, false);
+
+ toggleCollapsedState(personalBook);
+ toggleCollapsedState(book1);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, true);
+ checkCollapsedState(book1, true);
+ checkCollapsedState(book2, false);
+ checkCollapsedState(historyBook, false);
+
+ toggleCollapsedState(book1);
+ toggleCollapsedState(book2);
+ toggleCollapsedState(historyBook);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, true);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(book2, true);
+ checkCollapsedState(historyBook, true);
+
+ toggleCollapsedState(personalBook);
+
+ info("Closing and re-opening");
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book2.URI);
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+
+ checkCollapsedState(personalBook, false);
+ checkCollapsedState(book1, false);
+ checkCollapsedState(historyBook, true);
+
+ await closeAddressBookWindow();
+
+ personalBook.childNodes.forEach(list => personalBook.deleteDirectory(list));
+ historyBook.childNodes.forEach(list => historyBook.deleteDirectory(list));
+ await promiseDirectoryRemoved(book1.URI);
+ Services.xulStore.removeDocument("about:addressbook");
+});
+
+/**
+ * Tests that the chosen default directory (or lack thereof) is opened when
+ * the page opens.
+ */
+add_task(async function test_startup_directory() {
+ const URI_PREF = "mail.addr_book.view.startupURI";
+ const DEFAULT_PREF = "mail.addr_book.view.startupURIisDefault";
+
+ Services.prefs.clearUserPref(URI_PREF);
+ Services.prefs.clearUserPref(DEFAULT_PREF);
+
+ async function checkMenuItem(index, expectChecked, toggle = false) {
+ await rightClickOnIndex(index);
+
+ let menu = abWindow.document.getElementById("bookContext");
+ let item = abWindow.document.getElementById("bookContextStartupDefault");
+ Assert.equal(
+ item.hasAttribute("checked"),
+ expectChecked,
+ `directory at index ${index} is the default?`
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ if (toggle) {
+ menu.activateItem(item);
+ } else {
+ menu.hidePopup();
+ }
+ await hiddenPromise;
+ }
+
+ // With the defaults, All Address Books should open.
+ // No changes should be made to the prefs.
+
+ let abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(0, true);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+
+ // Now we'll set the default to "last-used".
+ // The last-used book should be saved.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(0, true);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ Services.prefs.setBoolPref(DEFAULT_PREF, false);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), personalBook.URI);
+
+ // The last-used book should open.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(personalBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ openDirectory(historyBook);
+ await closeAddressBookWindow();
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), historyBook.URI);
+
+ // The last-used book should open.
+ // We'll set a default directory again.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(historyBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false, true);
+ openDirectory(personalBook);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), historyBook.URI);
+
+ // Check that the saved default opens. Change the default.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(historyBook);
+ await checkMenuItem(0, false);
+ await checkMenuItem(2, true);
+ await checkMenuItem(1, false, true);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.equal(Services.prefs.getStringPref(URI_PREF), personalBook.URI);
+
+ // Check that the saved default opens. Change the default to All Address Books.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed(personalBook);
+ await checkMenuItem(1, true);
+ await checkMenuItem(2, false);
+ await checkMenuItem(0, false, true);
+ await closeAddressBookWindow();
+ Assert.ok(Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+
+ // Check that the saved default opens. Clear the default.
+
+ abWindow = await openAddressBookWindow();
+ checkDirectoryDisplayed();
+ await checkMenuItem(1, false);
+ await checkMenuItem(2, false);
+ await checkMenuItem(0, true, true);
+ await closeAddressBookWindow();
+ Assert.ok(!Services.prefs.getBoolPref(DEFAULT_PREF));
+ Assert.ok(!Services.prefs.prefHasUserValue(URI_PREF));
+});
+
+add_task(async function test_total_address_book_count() {
+ let book1 = createAddressBook("First Book");
+ let book2 = createAddressBook("Second Book");
+ book1.addMailList(createMailingList("Ordinary List"));
+
+ book1.addCard(createContact("contact1", "book 1"));
+ book1.addCard(createContact("contact2", "book 1"));
+ book1.addCard(createContact("contact3", "book 1"));
+
+ book2.addCard(createContact("contact1", "book 2"));
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let booksList = abWindow.booksList;
+ let cardCount = abDocument.getElementById("cardCount");
+
+ await openAllAddressBooks();
+ Assert.deepEqual(abDocument.l10n.getAttributes(cardCount), {
+ id: "about-addressbook-card-count-all",
+ args: {
+ count: 5,
+ },
+ });
+
+ for (let [index, [name, count]] of [
+ ["Personal Address Book", 0],
+ ["First Book", 4],
+ ["Ordinary List", 0],
+ ["Second Book", 1],
+ ].entries()) {
+ booksList.getRowAtIndex(index + 1).click();
+ Assert.deepEqual(abDocument.l10n.getAttributes(cardCount), {
+ id: "about-addressbook-card-count",
+ args: { name, count },
+ });
+ }
+
+ // Create a contact and check that the count updates.
+ // Select second book.
+ booksList.getRowAtIndex(4).click();
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ book2.addCard(createContact("contact2", "book 2"));
+ await createdPromise;
+ Assert.deepEqual(
+ abDocument.l10n.getAttributes(cardCount),
+ {
+ id: "about-addressbook-card-count",
+ args: { name: "Second Book", count: 2 },
+ },
+ "Address Book count is updated on contact creation."
+ );
+
+ // Delete a contact an check that the count updates.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ let deletedPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ let cards = abWindow.cardsPane.cardsList;
+ EventUtils.synthesizeMouseAtCenter(cards.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
+ await promptPromise;
+ await deletedPromise;
+ Assert.deepEqual(
+ abDocument.l10n.getAttributes(cardCount),
+ {
+ id: "about-addressbook-card-count",
+ args: { name: "Second Book", count: 1 },
+ },
+ "Address Book count is updated on contact deletion."
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book1.URI);
+ await promiseDirectoryRemoved(book2.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_display_card.js b/comm/mail/components/addrbook/test/browser/browser_display_card.js
new file mode 100644
index 0000000000..4d468ed646
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_display_card.js
@@ -0,0 +1,1020 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(2);
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+var { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(aProtocolScheme) {},
+ getApplicationDescription(aScheme) {},
+ getProtocolHandlerInfo(aProtocolScheme) {},
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+ isExposedProtocol(aProtocolScheme) {},
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+add_setup(async function () {
+ // Card 0.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard("BEGIN:VCARD\r\nEND:VCARD\r\n")
+ );
+ // Card 1.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:basic person
+ EMAIL:basic@invalid
+ END:VCARD
+ `)
+ );
+ // Card 2.
+ personalBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:complex person
+ EMAIL:secondary@invalid
+ EMAIL;PREF=1:primary@invalid
+ EMAIL;TYPE=WORK:tertiary@invalid
+ TEL;VALUE=URI:tel:000-0000
+ TEL;TYPE=WORK,VOICE:callto:111-1111
+ TEL;TYPE=VOICE,WORK:222-2222
+ TEL;TYPE=HOME;TYPE=VIDEO:tel:333-3333
+ ADR:;;street,suburb;city;state;zip;country
+ ANNIVERSARY:2018-06-11
+ BDAY;VALUE=DATE:--0229
+ NOTE:mary had a little lamb\\nits fleece was white as snow\\nand everywhere t
+ hat mary went\\nthe lamb was sure to go
+ ORG:thunderbird;engineering
+ ROLE:sheriff
+ TITLE:senior engineering lead
+ TZ;VALUE=TEXT:Pacific/Auckland
+ URL;TYPE=work:https://www.thunderbird.net/
+ IMPP:xmpp:cowboy@example.org
+ END:VCARD
+ `)
+ );
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+
+ registerCleanupFunction(async () => {
+ personalBook.deleteCards(personalBook.childCards);
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+});
+
+/**
+ * Checks basic display.
+ */
+add_task(async function testDisplay() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewPrimaryEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editButton = abDocument.getElementById("editButton");
+
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let phoneNumbersSection = abDocument.getElementById("phoneNumbers");
+ let addressesSection = abDocument.getElementById("addresses");
+ let notesSection = abDocument.getElementById("notes");
+ let websitesSection = abDocument.getElementById("websites");
+ let imppSection = abDocument.getElementById("instantMessaging");
+ let otherInfoSection = abDocument.getElementById("otherInfo");
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+
+ Assert.equal(cardsList.view.rowCount, personalBook.childCardCount);
+ Assert.ok(detailsPane.hidden);
+
+ // Card 0: an empty card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 1: an basic card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "basic person");
+ Assert.equal(viewPrimaryEmail.textContent, "basic@invalid");
+
+ // Action buttons.
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ let items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[0].querySelector("a").href,
+ `mailto:basic%20person%20%3Cbasic%40invalid%3E`
+ );
+ Assert.equal(items[0].querySelector("a").textContent, "basic@invalid");
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(items[0].querySelector("a"), {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "basic person <basic@invalid>"
+ );
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 2: an complex card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "complex person");
+ Assert.equal(viewPrimaryEmail.textContent, "primary@invalid");
+
+ // Action buttons.
+ await checkActionButtons(
+ "primary@invalid",
+ "complex person",
+ "primary@invalid secondary@invalid tertiary@invalid"
+ );
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 3);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[0].querySelector("a").href,
+ `mailto:complex%20person%20%3Csecondary%40invalid%3E`
+ );
+ Assert.equal(items[0].querySelector("a").textContent, "secondary@invalid");
+
+ Assert.equal(items[1].querySelector(".entry-type").textContent, "");
+ Assert.equal(
+ items[1].querySelector("a").href,
+ `mailto:complex%20person%20%3Cprimary%40invalid%3E`
+ );
+ Assert.equal(items[1].querySelector("a").textContent, "primary@invalid");
+
+ Assert.equal(
+ items[2].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(
+ items[2].querySelector("a").href,
+ `mailto:complex%20person%20%3Ctertiary%40invalid%3E`
+ );
+ Assert.equal(items[2].querySelector("a").textContent, "tertiary@invalid");
+
+ composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(items[2].querySelector("a"), {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ "complex person <tertiary@invalid>"
+ );
+
+ // Phone numbers section.
+ Assert.ok(BrowserTestUtils.is_visible(phoneNumbersSection));
+ items = phoneNumbersSection.querySelectorAll("li");
+ Assert.equal(items.length, 4);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value a").href, `tel:0000000`);
+
+ Assert.equal(
+ items[1].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(items[1].querySelector(".entry-value").textContent, "111-1111");
+ Assert.equal(items[1].querySelector(".entry-value a").href, `callto:1111111`);
+
+ Assert.equal(
+ items[2].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(items[2].querySelector(".entry-value").textContent, "222-2222");
+
+ Assert.equal(
+ items[3].querySelector(".entry-type").dataset.l10nId,
+ "about-addressbook-entry-type-home"
+ );
+ Assert.equal(items[3].querySelector(".entry-value").textContent, "333-3333");
+ Assert.equal(items[3].querySelector(".entry-value a").href, `tel:3333333`);
+
+ // Addresses section.
+ Assert.ok(BrowserTestUtils.is_visible(addressesSection));
+ items = addressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value").childNodes.length, 11);
+ Assert.deepEqual(
+ Array.from(
+ items[0].querySelector(".entry-value").childNodes,
+ n => n.textContent
+ ),
+ ["street", "", "suburb", "", "city", "", "state", "", "zip", "", "country"]
+ );
+
+ // Notes section.
+ Assert.ok(BrowserTestUtils.is_visible(notesSection));
+ Assert.equal(
+ notesSection.querySelector("div").textContent,
+ "mary had a little lamb\nits fleece was white as snow\nand everywhere that mary went\nthe lamb was sure to go"
+ );
+
+ // Websites section
+ Assert.ok(BrowserTestUtils.is_visible(websitesSection));
+ items = websitesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-type-work"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "https://www.thunderbird.net/"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").textContent,
+ "www.thunderbird.net"
+ );
+ items[0].children[1].querySelector("a").scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ items[0].children[1].querySelector("a"),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(
+ () => mockExternalProtocolService.urlLoaded("https://www.thunderbird.net/"),
+ "attempted to load website in a browser"
+ );
+
+ // Instant messaging section
+ Assert.ok(BrowserTestUtils.is_visible(imppSection));
+ items = imppSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "xmpp:cowboy@example.org"
+ );
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 6, "number of <li> in section should be correct");
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-birthday"
+ );
+ Assert.equal(items[0].children[1].textContent, "February 29");
+ Assert.equal(
+ items[1].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[1].children[1].textContent, "June 11, 2018");
+
+ Assert.equal(
+ items[2].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-title"
+ );
+ Assert.equal(items[2].children[1].textContent, "senior engineering lead");
+ Assert.equal(
+ items[3].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-role"
+ );
+ Assert.equal(items[3].children[1].textContent, "sheriff");
+ Assert.equal(
+ items[4].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-organization"
+ );
+ Assert.deepEqual(
+ Array.from(
+ items[4].querySelector(".entry-value").childNodes,
+ n => n.textContent
+ ),
+ ["engineering", " • ", "thunderbird"]
+ );
+ Assert.equal(
+ items[5].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-time-zone"
+ );
+ Assert.equal(items[5].children[1].firstChild.nodeValue, "Pacific/Auckland");
+ Assert.equal(
+ items[5].children[1].lastChild.getAttribute("is"),
+ "active-time"
+ );
+ Assert.equal(
+ items[5].children[1].lastChild.getAttribute("tz"),
+ "Pacific/Auckland"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ // Card 0, again, just to prove that everything was cleared properly.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneNumbersSection));
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(notesSection));
+ Assert.ok(BrowserTestUtils.is_hidden(otherInfoSection));
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test the display of dates with various components missing.
+ */
+add_task(async function testDates() {
+ let abWindow = await openAddressBookWindow();
+ let otherInfoSection = abWindow.document.getElementById("otherInfo");
+
+ // Year only.
+
+ let yearCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic3@invalid
+ ANNIVERSARY:2005
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ let items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "2005");
+
+ // Year and month.
+
+ let yearMonthCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic4@invalid
+ ANNIVERSARY:2006-06
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "June 2006");
+
+ // Month only.
+ let monthCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic5@invalid
+ ANNIVERSARY:--12
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "December");
+
+ // Month and day.
+ let monthDayCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic6@invalid
+ ANNIVERSARY;VALUE=DATE:--0704
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "July 4");
+
+ // Day only.
+ let dayCard = await addAndDisplayCard(formatVCard`
+ BEGIN:VCARD
+ EMAIL:xbasic7@invalid
+ ANNIVERSARY:---30
+ END:VCARD
+ `);
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-anniversary"
+ );
+ Assert.equal(items[0].children[1].textContent, "30");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([
+ yearCard,
+ yearMonthCard,
+ monthCard,
+ monthDayCard,
+ dayCard,
+ ]);
+});
+
+/**
+ * Only an organisation name.
+ */
+add_task(async function testOrganisationNameOnly() {
+ let card = await addAndDisplayCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ ORG:organisation
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await getAddressBookWindow();
+ let viewContactName = abWindow.document.getElementById("viewContactName");
+ Assert.equal(viewContactName.textContent, "organisation");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Tests that custom properties (Custom1 etc.) are displayed.
+ */
+add_task(async function testCustomProperties() {
+ let card = new AddrBookCard();
+ card._properties = new Map([
+ ["PopularityIndex", 0],
+ ["Custom2", "custom two"],
+ ["Custom4", "custom four"],
+ [
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ FN:custom person
+ X-CUSTOM3:x-custom three
+ X-CUSTOM4:x-custom four
+ END:VCARD
+ `,
+ ],
+ ]);
+ card = await addAndDisplayCard(card);
+
+ let abWindow = await getAddressBookWindow();
+ let otherInfoSection = abWindow.document.getElementById("otherInfo");
+
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+
+ let items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 3);
+ // Custom 1 has no value, should not display.
+ // Custom 2 has an old property value, should display that.
+
+ await TestUtils.waitForCondition(() => {
+ return items[0].children[0].textContent;
+ }, "text not created in time");
+
+ Assert.equal(items[0].children[0].textContent, "Custom 2");
+ Assert.equal(items[0].children[1].textContent, "custom two");
+ // Custom 3 has a vCard property value, should display that.
+ Assert.equal(items[1].children[0].textContent, "Custom 3");
+ Assert.equal(items[1].children[1].textContent, "x-custom three");
+ // Custom 4 has both types of value, the vCard value should be displayed.
+ Assert.equal(items[2].children[0].textContent, "Custom 4");
+ Assert.equal(items[2].children[1].textContent, "x-custom four");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Checks that the edit button is hidden for read-only contacts.
+ */
+add_task(async function testReadOnlyActions() {
+ let readOnlyBook = createAddressBook("Read-Only Book");
+ let readOnlyList = readOnlyBook.addMailList(
+ createMailingList("Read-Only List")
+ );
+ readOnlyBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:read-only person
+ END:VCARD
+ `)
+ );
+ readOnlyList.addCard(
+ readOnlyBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:read-only person with email
+ EMAIL:read.only@invalid
+ END:VCARD
+ `)
+ )
+ );
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let contactView = abDocument.getElementById("viewContact");
+
+ let actions = abDocument.getElementById("detailsActions");
+ let editButton = abDocument.getElementById("editButton");
+ let editForm = abDocument.getElementById("editContactForm");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = cardsList.selectedIndex;
+ },
+ };
+
+ // Check contacts with the book displayed.
+
+ openDirectory(readOnlyBook);
+ Assert.equal(cardsList.view.rowCount, 3);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Without email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(actions),
+ "actions section should be hidden"
+ );
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Double clicking on the item will select but not edit it.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { clickCount: 1 },
+ abWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { clickCount: 2 },
+ abWindow
+ );
+ // Wait one loop to see if edit form was opened.
+ await TestUtils.waitForTick();
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(actions),
+ "actions section should be hidden"
+ );
+ Assert.equal(
+ cardsList.table.body,
+ abDocument.activeElement,
+ "Cards list should be the active element"
+ );
+
+ selectHandler.reset();
+ cardsList.addEventListener("select", selectHandler, { once: true });
+ // Same with Enter on the second item.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, abWindow);
+ await TestUtils.waitForCondition(
+ () => selectHandler.seenEvent,
+ `'select' event should get fired`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(actions),
+ "actions section should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editButton),
+ "editButton should be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ BrowserTestUtils.is_visible(contactView),
+ "contact view should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(actions),
+ "actions section should be shown"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(editForm),
+ "contact form should be hidden"
+ );
+
+ // Check contacts with the list displayed.
+
+ openDirectory(readOnlyList);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Check contacts with All Address Books displayed.
+
+ openAllAddressBooks();
+ Assert.equal(cardsList.view.rowCount, 6);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Basic person from Personal Address Books.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton), "edit button is shown");
+
+ // Without email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(4), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_hidden(actions), "actions section is hidden");
+
+ // With email.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(5), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("read.only@invalid", "read-only person with email");
+ Assert.ok(BrowserTestUtils.is_hidden(editButton), "editButton is hidden");
+
+ // Basic person again, to prove the buttons aren't hidden forever.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ Assert.ok(BrowserTestUtils.is_visible(contactView));
+ Assert.ok(BrowserTestUtils.is_visible(actions), "actions section is shown");
+ await checkActionButtons("basic@invalid", "basic person");
+ Assert.ok(BrowserTestUtils.is_visible(editButton), "edit button is shown");
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+});
+
+/**
+ * Tests that we correctly fix Google's bad escaping of colons in values, and
+ * other characters in URI values.
+ */
+add_task(async function testGoogleEscaping() {
+ let googleBook = createAddressBook("Google Book");
+ googleBook.wrappedJSObject._isGoogleCardDAV = true;
+ googleBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ N:test;en\\\\c\\:oding;;;
+ FN:en\\\\c\\:oding test
+ TITLE:title\\:title\\;title\\,title\\\\title\\\\\\:title\\\\\\;title\\\\\\,title\\\\\\\\
+ TEL:tel\\:0123\\\\4567
+ NOTE:notes\\:\\nnotes\\;\\nnotes\\,\\nnotes\\\\
+ URL:https\\://host/url\\:url\\;url\\,url\\\\url
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewPrimaryEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editButton = abDocument.getElementById("editButton");
+
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let phoneNumbersSection = abDocument.getElementById("phoneNumbers");
+ let addressesSection = abDocument.getElementById("addresses");
+ let notesSection = abDocument.getElementById("notes");
+ let websitesSection = abDocument.getElementById("websites");
+ let imppSection = abDocument.getElementById("instantMessaging");
+ let otherInfoSection = abDocument.getElementById("otherInfo");
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+
+ openDirectory(googleBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ // Header.
+ Assert.equal(viewContactName.textContent, "en\\c:oding test");
+ Assert.equal(viewPrimaryEmail.textContent, "");
+
+ // Action buttons.
+ await checkActionButtons();
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Email section.
+ Assert.ok(BrowserTestUtils.is_hidden(emailAddressesSection));
+
+ // Phone numbers section.
+ Assert.ok(BrowserTestUtils.is_visible(phoneNumbersSection));
+ let items = phoneNumbersSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].querySelector(".entry-type").textContent, "");
+ Assert.equal(items[0].querySelector(".entry-value").textContent, "01234567");
+
+ // Addresses section.
+ Assert.ok(BrowserTestUtils.is_hidden(addressesSection));
+
+ // Notes section.
+ Assert.ok(BrowserTestUtils.is_visible(notesSection));
+ Assert.equal(
+ notesSection.querySelector("div").textContent,
+ "notes:\nnotes;\nnotes,\nnotes\\"
+ );
+
+ // Websites section
+ Assert.ok(BrowserTestUtils.is_visible(websitesSection));
+ items = websitesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[1].querySelector("a").href,
+ "https://host/url:url;url,url/url"
+ );
+ Assert.equal(
+ items[0].children[1].querySelector("a").textContent,
+ "host/url:url;url,url/url"
+ );
+ items[0].children[1].querySelector("a").scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(
+ items[0].children[1].querySelector("a"),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ mockExternalProtocolService.urlLoaded("https://host/url:url;url,url/url"),
+ "attempted to load website in a browser"
+ );
+
+ // Instant messaging section.
+ Assert.ok(BrowserTestUtils.is_hidden(imppSection));
+
+ // Other sections.
+ Assert.ok(BrowserTestUtils.is_visible(otherInfoSection));
+ items = otherInfoSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].children[0].dataset.l10nId,
+ "about-addressbook-entry-name-title"
+ );
+ Assert.equal(
+ items[0].children[1].textContent,
+ "title:title;title,title\\title\\:title\\;title\\,title\\\\"
+ );
+
+ Assert.ok(BrowserTestUtils.is_hidden(selectedCardsSection));
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(googleBook.URI);
+});
+
+async function addAndDisplayCard(card) {
+ if (typeof card == "string") {
+ card = VCardUtils.vCardToAbCard(card);
+ }
+ card = personalBook.addCard(card);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let index = cardsList.view.getIndexForUID(card.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+ return card;
+}
+
+async function checkActionButtons(
+ primaryEmail,
+ displayName,
+ searchString = primaryEmail
+) {
+ let tabmail = document.getElementById("tabmail");
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let writeButton = abDocument.getElementById("detailsWriteButton");
+ let eventButton = abDocument.getElementById("detailsEventButton");
+ let searchButton = abDocument.getElementById("detailsSearchButton");
+ let newListButton = abDocument.getElementById("detailsNewListButton");
+
+ if (primaryEmail) {
+ // Write.
+ Assert.ok(
+ BrowserTestUtils.is_visible(writeButton),
+ "write button is visible"
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(writeButton, {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ `${displayName} <${primaryEmail}>`
+ );
+
+ // Search. Do this before the event test to stop a strange macOS failure.
+ Assert.ok(
+ BrowserTestUtils.is_visible(searchButton),
+ "search button is visible"
+ );
+
+ let searchTabPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, abWindow);
+ let {
+ detail: { tabInfo: searchTab },
+ } = await searchTabPromise;
+
+ let searchBox = tabmail.selectedTab.panel.querySelector(".searchBox");
+ Assert.equal(searchBox.value, searchString);
+
+ searchTabPromise = BrowserTestUtils.waitForEvent(window, "TabClose");
+ tabmail.closeTab(searchTab);
+ await searchTabPromise;
+
+ // Event.
+ Assert.ok(
+ BrowserTestUtils.is_visible(eventButton),
+ "event button is visible"
+ );
+
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(eventButton, {}, abWindow);
+ let eventWindow = await eventWindowPromise;
+
+ let iframe = eventWindow.document.getElementById(
+ "calendar-item-panel-iframe"
+ );
+ let tabPanels = iframe.contentDocument.getElementById(
+ "event-grid-tabpanels"
+ );
+ let attendeesTabPanel = iframe.contentDocument.getElementById(
+ "event-grid-tabpanel-attendees"
+ );
+ Assert.equal(
+ tabPanels.selectedPanel,
+ attendeesTabPanel,
+ "attendees are displayed"
+ );
+ let attendeeNames = attendeesTabPanel.querySelectorAll(
+ ".attendee-list .attendee-name"
+ );
+ Assert.deepEqual(
+ Array.from(attendeeNames, a => a.textContent),
+ [`${displayName} <${primaryEmail}>`],
+ "attendees are correct"
+ );
+
+ eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+ await eventWindowPromise;
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(writeButton),
+ "write button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(eventButton),
+ "event button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchButton),
+ "search button is hidden"
+ );
+ }
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(newListButton),
+ "new list button is hidden"
+ );
+}
diff --git a/comm/mail/components/addrbook/test/browser/browser_display_multiple.js b/comm/mail/components/addrbook/test/browser/browser_display_multiple.js
new file mode 100644
index 0000000000..02642f4408
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_display_multiple.js
@@ -0,0 +1,468 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+
+add_setup(async function () {
+ let card1 = personalBook.addCard(createContact("victor", "test"));
+ personalBook.addCard(createContact("romeo", "test", undefined, ""));
+ let card3 = personalBook.addCard(createContact("oscar", "test"));
+ personalBook.addCard(createContact("mike", "test", undefined, ""));
+ const card5 = personalBook.addCard(createContact("xray", "test"));
+ const card6 = personalBook.addCard(createContact("yankee", "test"));
+ const card7 = personalBook.addCard(createContact("zulu", "test"));
+ let list1 = personalBook.addMailList(createMailingList("list 1"));
+ list1.addCard(card1);
+ list1.addCard(card3);
+ list1.addCard(card5);
+ list1.addCard(card6);
+ list1.addCard(card7);
+ let list2 = personalBook.addMailList(createMailingList("list 2"));
+ list2.addCard(card3);
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+add_task(async function testSelectMultiple() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ // In order; list 1, list 2, mike, oscar, romeo, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 9);
+ Assert.ok(detailsPane.hidden);
+
+ // Select list 1 and check the list display.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await checkHeader({ listName: "list 1" });
+ await checkActionButtons(
+ ["list 1 <list 1>"],
+ [],
+ [
+ "victor test <victor.test@invalid>",
+ "oscar test <oscar.test@invalid>",
+ "xray test <xray.test@invalid>",
+ "yankee test <yankee.test@invalid>",
+ "zulu test <zulu.test@invalid>",
+ ]
+ );
+ await checkList([
+ "oscar test",
+ "victor test",
+ "xray test",
+ "yankee test",
+ "zulu test",
+ ]);
+
+ // list 1 and list 2.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "lists" });
+ await checkActionButtons(["list 1 <list 1>", "list 2 <list 2>"]);
+ await checkList(["list 1", "list 2"]);
+
+ // list 1 and mike (no address).
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkActionButtons(["list 1 <list 1>"]);
+ await checkList(["list 1", "mike test"]);
+
+ // list 1 and oscar.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkActionButtons(
+ ["list 1 <list 1>"],
+ ["oscar test <oscar.test@invalid>"]
+ );
+ await checkList(["list 1", "oscar test"]);
+
+ // mike (no address) and oscar.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkActionButtons([], ["oscar test <oscar.test@invalid>"]);
+ await checkList(["mike test", "oscar test"]);
+
+ // mike (no address), oscar, romeo (no address) and victor.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 4, selectionType: "contacts" });
+ await checkActionButtons(
+ [],
+ ["oscar test <oscar.test@invalid>", "victor test <victor.test@invalid>"]
+ );
+ await checkList(["mike test", "oscar test", "romeo test", "victor test"]);
+
+ // mike and romeo (no addresses).
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(4),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkActionButtons();
+ await checkList(["mike test", "romeo test"]);
+
+ // Everything.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 6, selectionType: "mixed" });
+ await checkActionButtons(
+ ["list 1 <list 1>", "list 2 <list 2>"],
+ ["oscar test <oscar.test@invalid>", "victor test <victor.test@invalid>"]
+ );
+ await checkList([
+ "list 1",
+ "list 2",
+ "mike test",
+ "oscar test",
+ "romeo test",
+ "victor test",
+ ]);
+
+ await closeAddressBookWindow();
+});
+
+add_task(async function testDeleteMultiple() {
+ const abWindow = await openAddressBookWindow();
+ const booksList = abWindow.booksList;
+
+ // Open mailing list list1.
+ booksList.getRowAtIndex(2).click();
+
+ const abDocument = abWindow.document;
+ const cardsList = abDocument.getElementById("cards");
+ const detailsPane = abDocument.getElementById("detailsPane");
+
+ // In order; oscar, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 5);
+ Assert.ok(detailsPane.hidden);
+
+ // Select victor and yankee.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(3),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "contacts" });
+ await checkList(["victor test", "yankee test"]);
+
+ // Delete victor and yankee.
+ let deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-list-member-removed");
+ Assert.equal(cardsList.view.rowCount, 3);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing two mailing list members."
+ );
+
+ // Select all contacts.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 3, selectionType: "contacts" });
+ await checkList(["oscar test", "xray test", "zulu test"]);
+
+ // Delete all contacts.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-list-member-removed");
+ Assert.equal(cardsList.view.rowCount, 0);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing all mailing list members."
+ );
+
+ // Open address book personalBook.
+ booksList.getRowAtIndex(1).click();
+
+ // In order; list 1, list 2, mike, oscar, romeo, victor, xray, yankee, zulu.
+ Assert.equal(cardsList.view.rowCount, 9);
+ Assert.ok(detailsPane.hidden);
+
+ // Select list 2 and victor.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(1), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(5),
+ { accelKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 2, selectionType: "mixed" });
+ await checkList(["list 2", "victor test"]);
+
+ // Delete list 2 and victor.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-contact-deleted");
+ Assert.equal(cardsList.view.rowCount, 7);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after deleting one list and one contact."
+ );
+
+ // Select all contacts.
+ await TestUtils.waitForTick();
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(6),
+ { shiftKey: true },
+ abWindow
+ );
+ await checkHeader({ selectionCount: 7, selectionType: "mixed" });
+ await checkList([
+ "list 1",
+ "mike test",
+ "oscar test",
+ "romeo test",
+ "xray test",
+ "yankee test",
+ "zulu test",
+ ]);
+
+ // Delete all contacts.
+ deletePromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await deletePromise;
+ await TestUtils.topicObserved("addrbook-contact-deleted");
+ Assert.equal(cardsList.view.rowCount, 0);
+ Assert.ok(
+ detailsPane.hidden,
+ "The details pane should be cleared after removing all contacts."
+ );
+ await closeAddressBookWindow();
+});
+
+function checkHeader({ listName, selectionCount, selectionType } = {}) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let contactPhoto = abDocument.getElementById("viewContactPhoto");
+ let contactName = abDocument.getElementById("viewContactName");
+ let listHeader = abDocument.getElementById("viewListName");
+ let selectionHeader = abDocument.getElementById("viewSelectionCount");
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(contactPhoto),
+ "contact photo should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(contactName),
+ "contact name should be hidden"
+ );
+ if (listName) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(listHeader),
+ "list header should be visible"
+ );
+ Assert.equal(
+ listHeader.textContent,
+ listName,
+ "list header text is correct"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(selectionHeader),
+ "selection header should be hidden"
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(listHeader),
+ "list header should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(selectionHeader),
+ "selection header should be visible"
+ );
+ Assert.deepEqual(abDocument.l10n.getAttributes(selectionHeader), {
+ id: `about-addressbook-selection-${selectionType}-header2`,
+ args: {
+ count: selectionCount,
+ },
+ });
+ }
+}
+
+async function checkActionButtons(
+ listAddresses = [],
+ cardAddresses = [],
+ eventAddresses = cardAddresses
+) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let writeButton = abDocument.getElementById("detailsWriteButton");
+ let eventButton = abDocument.getElementById("detailsEventButton");
+ let searchButton = abDocument.getElementById("detailsSearchButton");
+ let newListButton = abDocument.getElementById("detailsNewListButton");
+
+ if (cardAddresses.length || listAddresses.length) {
+ // Write.
+ Assert.ok(
+ BrowserTestUtils.is_visible(writeButton),
+ "write button is visible"
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ EventUtils.synthesizeMouseAtCenter(writeButton, {}, abWindow);
+ await checkComposeWindow(
+ await composeWindowPromise,
+ ...listAddresses,
+ ...cardAddresses
+ );
+ }
+
+ if (eventAddresses.length) {
+ // Event.
+ Assert.ok(
+ BrowserTestUtils.is_visible(eventButton),
+ "event button is visible"
+ );
+
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(eventButton, {}, abWindow);
+ let eventWindow = await eventWindowPromise;
+
+ let iframe = eventWindow.document.getElementById(
+ "calendar-item-panel-iframe"
+ );
+ let tabPanels = iframe.contentDocument.getElementById(
+ "event-grid-tabpanels"
+ );
+ let attendeesTabPanel = iframe.contentDocument.getElementById(
+ "event-grid-tabpanel-attendees"
+ );
+ Assert.equal(
+ tabPanels.selectedPanel,
+ attendeesTabPanel,
+ "attendees are displayed"
+ );
+ let attendeeNames = attendeesTabPanel.querySelectorAll(
+ ".attendee-list .attendee-name"
+ );
+ Assert.deepEqual(
+ Array.from(attendeeNames, a => a.textContent),
+ eventAddresses,
+ "attendees are correct"
+ );
+
+ eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+ await eventWindowPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(eventButton),
+ "event button is hidden"
+ );
+ }
+
+ if (cardAddresses.length) {
+ // New List.
+ Assert.ok(
+ BrowserTestUtils.is_visible(newListButton),
+ "new list button is visible"
+ );
+ let listWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(newListButton, {}, abWindow);
+ let listWindow = await listWindowPromise;
+ let memberNames = listWindow.document.querySelectorAll(
+ ".textbox-addressingWidget"
+ );
+ Assert.deepEqual(
+ Array.from(memberNames, aw => aw.value),
+ [...cardAddresses, ""],
+ "list members are correct"
+ );
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, listWindow);
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(newListButton),
+ "new list button is hidden"
+ );
+ }
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchButton),
+ "search button is hidden"
+ );
+}
+
+function checkList(names) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let selectedCardsSection = abDocument.getElementById("selectedCards");
+ let otherSections = abDocument.querySelectorAll(
+ "#detailsBody > section:not(#detailsActions, #selectedCards)"
+ );
+
+ Assert.ok(BrowserTestUtils.is_visible(selectedCardsSection));
+ for (let section of otherSections) {
+ Assert.ok(BrowserTestUtils.is_hidden(section), `${section.id} is hidden`);
+ }
+
+ Assert.deepEqual(
+ Array.from(
+ selectedCardsSection.querySelectorAll("li .name"),
+ li => li.textContent
+ ),
+ names
+ );
+}
diff --git a/comm/mail/components/addrbook/test/browser/browser_drag_drop.js b/comm/mail/components/addrbook/test/browser/browser_drag_drop.js
new file mode 100644
index 0000000000..4f3c23aa5b
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_drag_drop.js
@@ -0,0 +1,417 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+function doDrag(sourceIndex, destIndex, modifiers, expectedEffect) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.document.getElementById("cards");
+
+ let destElement = abWindow.document.body;
+ if (destIndex !== null) {
+ destElement = booksList.getRowAtIndex(destIndex);
+ }
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList.getRowAtIndex(sourceIndex),
+ destElement,
+ null,
+ null,
+ abWindow,
+ abWindow,
+ modifiers
+ );
+
+ Assert.equal(dataTransfer.effectAllowed, "all");
+ Assert.equal(dataTransfer.dropEffect, expectedEffect);
+
+ return [result, dataTransfer];
+}
+
+function doDragToBooksList(sourceIndex, destIndex, modifiers, expectedEffect) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ let [result, dataTransfer] = doDrag(
+ sourceIndex,
+ destIndex,
+ modifiers,
+ expectedEffect
+ );
+
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ booksList.getRowAtIndex(destIndex),
+ abWindow,
+ modifiers
+ );
+
+ dragService.endDragSession(true);
+}
+
+async function doDragToComposeWindow(sourceIndices, expectedPills) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+ let composeDocument = composeWindow.document;
+ let toAddrInput = composeDocument.getElementById("toAddrInput");
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ cardsList.selectedIndices = sourceIndices;
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ cardsList.getRowAtIndex(sourceIndices[0]),
+ toAddrInput,
+ null,
+ null,
+ abWindow,
+ composeWindow
+ );
+ EventUtils.synthesizeDropAfterDragOver(
+ result,
+ dataTransfer,
+ toAddrInput,
+ composeWindow
+ );
+
+ dragService.endDragSession(true);
+
+ let pills = toAddrRow.querySelectorAll("mail-address-pill");
+ Assert.equal(pills.length, expectedPills.length);
+ for (let i = 0; i < expectedPills.length; i++) {
+ Assert.equal(pills[i].label, expectedPills[i]);
+ }
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ composeWindow.goDoCommand("cmd_close");
+ await promptPromise;
+}
+
+function checkCardsInDirectory(directory, expectedCards = [], copiedCard) {
+ let actualCards = directory.childCards.slice();
+
+ for (let card of expectedCards) {
+ let index = actualCards.findIndex(c => c.UID == card.UID);
+ Assert.greaterOrEqual(index, 0);
+ actualCards.splice(index, 1);
+ }
+
+ if (copiedCard) {
+ Assert.equal(actualCards.length, 1);
+ Assert.equal(actualCards[0].firstName, copiedCard.firstName);
+ Assert.equal(actualCards[0].lastName, copiedCard.lastName);
+ Assert.equal(actualCards[0].primaryEmail, copiedCard.primaryEmail);
+ Assert.notEqual(actualCards[0].UID, copiedCard.UID);
+ } else {
+ Assert.equal(actualCards.length, 0);
+ }
+}
+
+add_task(async function test_drag() {
+ let sourceBook = createAddressBook("Source Book");
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+
+ // Drag just contact1.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ let [, dataTransfer] = doDrag(0, null, {}, "none");
+
+ let transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 1);
+ Assert.ok(transferCards[0].equals(contact1));
+
+ let transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(transferUnicode, "contact 1 <contact.1@invalid>");
+
+ let transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact1.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ // Drag contact2 without selecting it.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ [, dataTransfer] = doDrag(1, null, {}, "none");
+
+ transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 1);
+ Assert.ok(transferCards[0].equals(contact2));
+
+ transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(transferUnicode, "contact 2 <contact.2@invalid>");
+
+ transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact2.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ // Drag all contacts.
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(2),
+ { shiftKey: true },
+ abWindow
+ );
+ [, dataTransfer] = doDrag(0, null, {}, "none");
+
+ transferCards = dataTransfer.mozGetDataAt("moz/abcard-array", 0);
+ Assert.equal(transferCards.length, 3);
+ Assert.ok(transferCards[0].equals(contact1));
+ Assert.ok(transferCards[1].equals(contact2));
+ Assert.ok(transferCards[2].equals(contact3));
+
+ transferUnicode = dataTransfer.getData("text/plain");
+ Assert.equal(
+ transferUnicode,
+ "contact 1 <contact.1@invalid>,contact 2 <contact.2@invalid>,contact 3 <contact.3@invalid>"
+ );
+
+ transferVCard = dataTransfer.getData("text/vcard");
+ Assert.stringContains(transferVCard, `\r\nUID:${contact1.UID}\r\n`);
+
+ dragService.endDragSession(true);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(sourceBook.URI);
+});
+
+add_task(async function test_drop_on_books_list() {
+ let sourceBook = createAddressBook("Source Book");
+ let sourceList = sourceBook.addMailList(createMailingList("Source List"));
+ let destBook = createAddressBook("Destination Book");
+ let destList = destBook.addMailList(createMailingList("Destination List"));
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+
+ let abWindow = await openAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.document.getElementById("cards");
+
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList);
+ checkCardsInDirectory(destBook, [destList]);
+ checkCardsInDirectory(destList);
+
+ Assert.equal(booksList.rowCount, 7);
+ openDirectory(sourceBook);
+
+ // Check drag effect set correctly for dragging a card.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ doDrag(0, 0, {}, "none"); // All Address Books
+ doDrag(0, 0, { ctrlKey: true }, "none");
+
+ doDrag(0, 1, {}, "move"); // Personal Address Book
+ doDrag(0, 1, { ctrlKey: true }, "copy");
+
+ doDrag(0, 2, {}, "move"); // Destination Book
+ doDrag(0, 2, { ctrlKey: true }, "copy");
+
+ doDrag(0, 3, {}, "none"); // Destination List
+ doDrag(0, 3, { ctrlKey: true }, "none");
+
+ doDrag(0, 4, {}, "none"); // Source Book
+ doDrag(0, 4, { ctrlKey: true }, "none");
+
+ doDrag(0, 5, {}, "link"); // Source List
+ doDrag(0, 5, { ctrlKey: true }, "link");
+
+ doDrag(0, 6, {}, "move"); // Collected Addresses
+ doDrag(0, 6, { ctrlKey: true }, "copy");
+
+ dragService.endDragSession(true);
+
+ // Check drag effect set correctly for dragging a list.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(3), {}, abWindow);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+
+ doDrag(3, 0, {}, "none"); // All Address Books
+ doDrag(3, 0, { ctrlKey: true }, "none");
+
+ doDrag(3, 1, {}, "none"); // Personal Address Book
+ doDrag(3, 1, { ctrlKey: true }, "none");
+
+ doDrag(3, 2, {}, "none"); // Destination Book
+ doDrag(3, 2, { ctrlKey: true }, "none");
+
+ doDrag(3, 3, {}, "none"); // Destination List
+ doDrag(3, 3, { ctrlKey: true }, "none");
+
+ doDrag(3, 4, {}, "none"); // Source Book
+ doDrag(3, 4, { ctrlKey: true }, "none");
+
+ doDrag(3, 5, {}, "none"); // Source List
+ doDrag(3, 5, { ctrlKey: true }, "none");
+
+ doDrag(3, 6, {}, "none"); // Collected Addresses
+ doDrag(3, 6, { ctrlKey: true }, "none");
+
+ dragService.endDragSession(true);
+
+ // Drag contact1 into sourceList.
+
+ Assert.equal(cardsList.view.rowCount, 4);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ doDragToBooksList(0, 5, {}, "link");
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList, [contact1]);
+
+ // Drag contact1 into destList. Nothing should happen.
+
+ doDragToBooksList(0, 3, {}, "none");
+ checkCardsInDirectory(sourceBook, [contact1, contact2, contact3, sourceList]);
+ checkCardsInDirectory(destBook, [destList]);
+ checkCardsInDirectory(destList);
+
+ // Drag contact1 into destBook. It should be moved into destBook.
+
+ doDragToBooksList(0, 2, {}, "move");
+ checkCardsInDirectory(sourceBook, [contact2, contact3, sourceList]);
+ checkCardsInDirectory(sourceList);
+ checkCardsInDirectory(destBook, [contact1, destList]);
+
+ // Drag contact2 into destBook with Ctrl pressed.
+ // It should be copied into destBook.
+
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+
+ doDragToBooksList(0, 2, { ctrlKey: true }, "copy");
+ checkCardsInDirectory(sourceBook, [contact2, contact3, sourceList]);
+ checkCardsInDirectory(destBook, [contact1, destList], contact2);
+ checkCardsInDirectory(destList);
+
+ // Delete the cards from destBook as it's confusing.
+
+ destBook.deleteCards(destBook.childCards.filter(c => !c.isMailList));
+ checkCardsInDirectory(destBook, [destList]);
+
+ // Drag contact2 and contact3 to destBook.
+
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(1),
+ { shiftKey: true },
+ abWindow
+ );
+
+ doDragToBooksList(0, 2, {}, "move");
+ checkCardsInDirectory(sourceBook, [sourceList]);
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ // Drag contact2 to the book it's already in. Nothing should happen.
+ // This test doesn't actually catch the bug it was written for, but maybe
+ // one day it will catch something.
+
+ openDirectory(destBook);
+ Assert.equal(cardsList.view.rowCount, 3);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ doDragToBooksList(0, 2, {}, "none");
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ // Drag destList to the book it's already in. Nothing should happen.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(2), {}, abWindow);
+ doDragToBooksList(2, 2, {}, "none");
+ checkCardsInDirectory(destBook, [contact2, contact3, destList]);
+
+ await closeAddressBookWindow();
+
+ await promiseDirectoryRemoved(sourceBook.URI);
+ await promiseDirectoryRemoved(destBook.URI);
+});
+
+add_task(async function test_drop_on_compose() {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, true);
+ });
+
+ let sourceBook = createAddressBook("Source Book");
+ let sourceList = sourceBook.addMailList(createMailingList("Source List"));
+
+ let contact1 = sourceBook.addCard(createContact("contact", "1"));
+ let contact2 = sourceBook.addCard(createContact("contact", "2"));
+ let contact3 = sourceBook.addCard(createContact("contact", "3"));
+ sourceList.addCard(contact1);
+ sourceList.addCard(contact2);
+ sourceList.addCard(contact3);
+
+ let abWindow = await openAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ Assert.equal(cardsList.view.rowCount, 4);
+
+ // One contact.
+
+ await doDragToComposeWindow([0], ["contact 1 <contact.1@invalid>"]);
+
+ // Multiple contacts.
+
+ await doDragToComposeWindow(
+ [0, 1, 2],
+ [
+ "contact 1 <contact.1@invalid>",
+ "contact 2 <contact.2@invalid>",
+ "contact 3 <contact.3@invalid>",
+ ]
+ );
+
+ // A mailing list.
+
+ await doDragToComposeWindow([3], [`Source List <"Source List">`]);
+
+ // A mailing list and a contact.
+
+ await doDragToComposeWindow(
+ [3, 2],
+ ["contact 3 <contact.3@invalid>", `Source List <"Source List">`]
+ );
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(sourceBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_async.js b/comm/mail/components/addrbook/test/browser/browser_edit_async.js
new file mode 100644
index 0000000000..76588aee76
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_async.js
@@ -0,0 +1,363 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+
+let book;
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+add_setup(async function () {
+ CardDAVServer.open("alice", "alice");
+
+ book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "alice");
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "alice", "alice", "", "");
+ Services.logins.addLogin(loginInfo);
+});
+
+registerCleanupFunction(async function () {
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.close();
+ CardDAVServer.reset();
+ CardDAVServer.modifyCardOnPut = false;
+});
+
+/**
+ * Test the UI as we create/modify/delete a card and wait for responses from
+ * the server.
+ */
+add_task(async function testCreateCard() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let bookRow = abWindow.booksList.getRowForUID(book.UID);
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+
+ openDirectory(book);
+
+ // First, create a new contact.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "new contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise1 = TestUtils.topicObserved("addrbook-contact-created");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise1;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ let promise2 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay.resolve();
+ await promise2;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Edit the contact.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "edited contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise3 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise3;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ let promise4 = TestUtils.topicObserved("addrbook-contact-updated");
+ CardDAVServer.responseDelay.resolve();
+ await promise4;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ // Delete the contact.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise5 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promise5;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, searchInput);
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ CardDAVServer.responseDelay.resolve();
+ await TestUtils.waitForCondition(
+ () => !bookRow.classList.contains("requesting")
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Test the UI as we create a card and wait for responses from the server.
+ * In this test the server will assign the card a new UID, which means the
+ * client code has to do things differently, but the UI should behave as it
+ * did in the previous test.
+ */
+add_task(async function testCreateCardWithUIDChange() {
+ CardDAVServer.modifyCardOnPut = true;
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let bookRow = abWindow.booksList.getRowForUID(book.UID);
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+
+ openDirectory(book);
+
+ // First, create a new contact.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ abWindow.detailsPane.vCardEdit.displayName.value = "new contact";
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise1 = TestUtils.topicObserved("addrbook-contact-created");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promise1;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ let initialCard = abWindow.detailsPane.currentCard;
+ Assert.equal(initialCard.getProperty("_href", "RIGHT"), "RIGHT");
+
+ // Now allow the server to respond and check the UI state again.
+ let promise2 = TestUtils.topicObserved("addrbook-contact-created");
+ let promise3 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay.resolve();
+ let [changedCard] = await promise2;
+ let [deletedCard] = await promise3;
+ Assert.ok(!bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, editButton);
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+
+ Assert.equal(changedCard.UID, [...initialCard.UID].reverse().join(""));
+ Assert.equal(
+ changedCard.getProperty("_originalUID", "WRONG"),
+ initialCard.UID
+ );
+ Assert.equal(deletedCard.UID, initialCard.UID);
+
+ let displayedCard = abWindow.detailsPane.currentCard;
+ Assert.equal(displayedCard.directoryUID, book.UID);
+ Assert.notEqual(displayedCard.getProperty("_href", "WRONG"), "WRONG");
+ Assert.equal(displayedCard.UID, [...initialCard.UID].reverse().join(""));
+
+ // Delete the contact. This would fail if the UI hadn't been updated.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Saving the contact will get an immediate notification.
+ // Delay the server response so we can test the state of the UI.
+ let promise4 = TestUtils.topicObserved("addrbook-contact-deleted");
+ CardDAVServer.responseDelay = PromiseUtils.defer();
+ BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promise4;
+ await notInEditingMode();
+ Assert.ok(bookRow.classList.contains("requesting"));
+ Assert.equal(abDocument.activeElement, searchInput);
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+
+ // Now allow the server to respond and check the UI state again.
+ CardDAVServer.responseDelay.resolve();
+ await TestUtils.waitForCondition(
+ () => !bookRow.classList.contains("requesting")
+ );
+
+ await closeAddressBookWindow();
+ CardDAVServer.modifyCardOnPut = false;
+});
+
+/**
+ * Test that a modification to the card being edited causes a prompt to appear
+ * when saving the card.
+ */
+add_task(async function testModificationUpdatesUI() {
+ let card = personalBook.addCard(createContact("a", "person"));
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let contactName = abDocument.getElementById("viewContactName");
+ let editButton = abDocument.getElementById("editButton");
+ let emailAddressesSection = abDocument.getElementById("emailAddresses");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ openDirectory(personalBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+
+ // Display a card.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ let items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(items[0].querySelector("a").textContent, "a.person@invalid");
+
+ // Modify the card and check the display is updated.
+
+ let updatePromise = BrowserTestUtils.waitForMutationCondition(
+ detailsPane,
+ { childList: true, subtree: true },
+ () => true
+ );
+ card.vCardProperties.addValue("email", "person.a@lastfirst.invalid");
+ personalBook.modifyCard(card);
+
+ await updatePromise;
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 2);
+ Assert.equal(items[0].querySelector("a").textContent, "a.person@invalid");
+ Assert.equal(
+ items[1].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+
+ // Enter edit mode. Clear one of the email addresses.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ Assert.equal(abWindow.detailsPane.vCardEdit.displayName.value, "a person");
+ abDocument.querySelector(`#vcard-email tr input[type="email"]`).value = "";
+
+ // Modify the card. Nothing should happen at this point.
+
+ card.displayName = "a different person";
+ personalBook.modifyCard(card);
+
+ // Click to save.
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ [card] = personalBook.childCards;
+ Assert.equal(
+ card.displayName,
+ "a person",
+ "programmatic changes were overwritten"
+ );
+ Assert.deepEqual(
+ card.emailAddresses,
+ ["person.a@lastfirst.invalid"],
+ "UI changes were saved"
+ );
+
+ Assert.equal(contactName.textContent, "a person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 1);
+ Assert.equal(
+ items[0].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+
+ // Enter edit mode again. Change the display name.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ abWindow.detailsPane.vCardEdit.displayName.value = "a changed person";
+
+ // Modify the card. Nothing should happen at this point.
+
+ card.displayName = "a different person";
+ card.vCardProperties.addValue("email", "a.person@invalid");
+ personalBook.modifyCard(card);
+
+ // Click to cancel. The modified card should be shown.
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ Assert.equal(contactName.textContent, "a different person");
+ Assert.ok(BrowserTestUtils.is_visible(emailAddressesSection));
+ items = emailAddressesSection.querySelectorAll("li");
+ Assert.equal(items.length, 2);
+ Assert.equal(
+ items[0].querySelector("a").textContent,
+ "person.a@lastfirst.invalid"
+ );
+ Assert.equal(items[1].querySelector("a").textContent, "a.person@invalid");
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_card.js b/comm/mail/components/addrbook/test/browser/browser_edit_card.js
new file mode 100644
index 0000000000..27cabfa4d4
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_card.js
@@ -0,0 +1,3517 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
+var { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+
+requestLongerTimeout(2);
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "Waiting on entering editing mode"
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ abDocument.getElementById("detailsPaneBackdrop")
+ ),
+ "backdrop should be visible"
+ );
+ checkToolbarState(false);
+}
+
+/**
+ * Wait until we are no longer in editing mode.
+ *
+ * @param {Element} expectedFocus - The element that is expected to have focus
+ * after leaving editing.
+ */
+async function notInEditingMode(expectedFocus) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ abDocument.getElementById("detailsPaneBackdrop")
+ ),
+ "backdrop should be hidden"
+ );
+ checkToolbarState(true);
+ Assert.equal(
+ abDocument.activeElement,
+ expectedFocus,
+ `Focus should be on #${expectedFocus.id}`
+ );
+}
+
+function getInput(entryName, addIfNeeded = false) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ switch (entryName) {
+ case "DisplayName":
+ return abDocument.querySelector("vcard-fn #vCardDisplayName");
+ case "PreferDisplayName":
+ return abDocument.querySelector("vcard-fn #vCardPreferDisplayName");
+ case "NickName":
+ return abDocument.querySelector("vcard-nickname #vCardNickName");
+ case "Prefix":
+ let prefixInput = abDocument.querySelector("vcard-n #vcard-n-prefix");
+ if (addIfNeeded && BrowserTestUtils.is_hidden(prefixInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector("vcard-n #n-list-component-prefix button"),
+ {},
+ abWindow
+ );
+ }
+ return prefixInput;
+ case "FirstName":
+ return abDocument.querySelector("vcard-n #vcard-n-firstname");
+ case "MiddleName":
+ let middleNameInput = abDocument.querySelector(
+ "vcard-n #vcard-n-middlename"
+ );
+ if (addIfNeeded && BrowserTestUtils.is_hidden(middleNameInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector(
+ "vcard-n #n-list-component-middlename button"
+ ),
+ {},
+ abWindow
+ );
+ }
+ return middleNameInput;
+ case "LastName":
+ return abDocument.querySelector("vcard-n #vcard-n-lastname");
+ case "Suffix":
+ let suffixInput = abDocument.querySelector("vcard-n #vcard-n-suffix");
+ if (addIfNeeded && BrowserTestUtils.is_hidden(suffixInput)) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector("vcard-n #n-list-component-suffix button"),
+ {},
+ abWindow
+ );
+ }
+ return suffixInput;
+ case "PrimaryEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 1
+ ) {
+ let addButton = abDocument.getElementById("vcard-add-email");
+ addButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, abWindow);
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(1) input[type="email"]`
+ );
+ case "PrimaryEmailCheckbox":
+ return getInput("PrimaryEmail")
+ .closest(`tr`)
+ .querySelector(`input[type="checkbox"]`);
+ case "SecondEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 2
+ ) {
+ let addButton = abDocument.getElementById("vcard-add-email");
+ addButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addButton, {}, abWindow);
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(2) input[type="email"]`
+ );
+ case "SecondEmailCheckbox":
+ return getInput("SecondEmail")
+ .closest(`tr`)
+ .querySelector(`input[type="checkbox"]`);
+ }
+
+ return null;
+}
+
+function getFields(entryName, addIfNeeded = false, count) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let fieldsSelector;
+ let addButtonId;
+ let expectFocusSelector;
+ switch (entryName) {
+ case "email":
+ fieldsSelector = `#vcard-email tr`;
+ addButtonId = "vcard-add-email";
+ expectFocusSelector = "tr:last-of-type .vcard-type-selection";
+ break;
+ case "impp":
+ fieldsSelector = "vcard-impp";
+ addButtonId = "vcard-add-impp";
+ expectFocusSelector = "vcard-impp:last-of-type select";
+ break;
+ case "url":
+ fieldsSelector = "vcard-url";
+ addButtonId = "vcard-add-url";
+ expectFocusSelector = "vcard-url:last-of-type .vcard-type-selection";
+ break;
+ case "tel":
+ fieldsSelector = "vcard-tel";
+ addButtonId = "vcard-add-tel";
+ expectFocusSelector = "vcard-tel:last-of-type .vcard-type-selection";
+ break;
+ case "note":
+ fieldsSelector = "vcard-note";
+ addButtonId = "vcard-add-note";
+ expectFocusSelector = "vcard-note:last-of-type textarea";
+ break;
+ case "title":
+ fieldsSelector = "vcard-title";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "vcard-title:last-of-type input";
+ break;
+ case "custom":
+ fieldsSelector = "vcard-custom";
+ addButtonId = "vcard-add-custom";
+ expectFocusSelector = "vcard-custom:last-of-type input";
+ break;
+ case "specialDate":
+ fieldsSelector = "vcard-special-date";
+ addButtonId = "vcard-add-bday-anniversary";
+ expectFocusSelector =
+ "vcard-special-date:last-of-type .vcard-type-selection";
+ break;
+ case "adr":
+ fieldsSelector = "vcard-adr";
+ addButtonId = "vcard-add-adr";
+ expectFocusSelector = "vcard-adr:last-of-type .vcard-type-selection";
+ break;
+ case "tz":
+ fieldsSelector = "vcard-tz";
+ addButtonId = "vcard-add-tz";
+ expectFocusSelector = "vcard-tz:last-of-type select";
+ break;
+ case "org":
+ fieldsSelector = "vcard-org";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "#addr-book-edit-org input";
+ break;
+ case "role":
+ fieldsSelector = "vcard-role";
+ addButtonId = "vcard-add-org";
+ expectFocusSelector = "#addr-book-edit-org input";
+ break;
+ default:
+ throw new Error("entryName not found: " + entryName);
+ }
+ let fields = abDocument.querySelectorAll(fieldsSelector).length;
+ if (addIfNeeded && fields < count) {
+ let addButton = abDocument.getElementById(addButtonId);
+ for (let clickTimes = fields; clickTimes < count; clickTimes++) {
+ addButton.focus();
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ let expectFocus = abDocument.querySelector(expectFocusSelector);
+ Assert.ok(
+ expectFocus,
+ `Expected focus element should now exist for ${entryName}`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(expectFocus),
+ `Expected focus element for ${entryName} should be visible`
+ );
+ Assert.equal(
+ expectFocus,
+ abDocument.activeElement,
+ `Expected focus element for ${entryName} should be active`
+ );
+ }
+ }
+ return abDocument.querySelectorAll(fieldsSelector);
+}
+
+function checkToolbarState(shouldBeEnabled) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ for (let id of [
+ "toolbarCreateBook",
+ "toolbarCreateContact",
+ "toolbarCreateList",
+ "toolbarImport",
+ ]) {
+ Assert.equal(
+ abDocument.getElementById(id).disabled,
+ !shouldBeEnabled,
+ id + (!shouldBeEnabled ? " should not" : " should") + " be disabled"
+ );
+ }
+}
+
+function checkDisplayValues(expected) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, values] of Object.entries(expected)) {
+ let section = abWindow.document.getElementById(key);
+ let items = Array.from(
+ section.querySelectorAll("li .entry-value"),
+ li => li.textContent
+ );
+ Assert.deepEqual(items, values);
+ }
+}
+
+function checkInputValues(expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ Assert.ok(BrowserTestUtils.is_visible(input));
+ if (input.type == "checkbox") {
+ Assert.equal(input.checked, value, `${key} checked`);
+ } else {
+ Assert.equal(input.value, value, `${key} value`);
+ }
+ }
+}
+
+function checkVCardInputValues(expected) {
+ for (let [key, expectedEntries] of Object.entries(expected)) {
+ let fields = getFields(key, false, expectedEntries.length);
+
+ Assert.equal(
+ fields.length,
+ expectedEntries.length,
+ `${key} occurred ${fields.length} time(s) and ${expectedEntries.length} time(s) is expected.`
+ );
+
+ for (let [index, field] of fields.entries()) {
+ let expectedEntry = expectedEntries[index];
+ let valueField;
+ let typeField;
+ switch (key) {
+ case "email":
+ valueField = field.emailEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "impp":
+ valueField = field.imppEl;
+ break;
+ case "url":
+ valueField = field.urlEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "tel":
+ valueField = field.inputElement;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "note":
+ valueField = field.textAreaEl;
+ break;
+ case "title":
+ valueField = field.titleEl;
+ break;
+ case "specialDate":
+ Assert.equal(
+ expectedEntry.value[0],
+ field.year.value,
+ `Year value of ${key} at position ${index}`
+ );
+ Assert.equal(
+ expectedEntry.value[1],
+ field.month.value,
+ `Month value of ${key} at position ${index}`
+ );
+ Assert.equal(
+ expectedEntry.value[2],
+ field.day.value,
+ `Day value of ${key} at position ${index}`
+ );
+ break;
+ case "adr":
+ typeField = field.vCardType.selectEl;
+ let addressValue = [
+ field.streetEl.value,
+ field.localityEl.value,
+ field.regionEl.value,
+ field.codeEl.value,
+ field.countryEl.value,
+ ];
+
+ Assert.deepEqual(
+ expectedEntry.value,
+ addressValue,
+ `Value of ${key} at position ${index}`
+ );
+ break;
+ case "tz":
+ valueField = field.selectEl;
+ break;
+ case "org":
+ let orgValue = [field.orgEl.value];
+ if (field.unitEl.value) {
+ orgValue.push(field.unitEl.value);
+ }
+ Assert.deepEqual(
+ expectedEntry.value,
+ orgValue,
+ `Value of ${key} at position ${index}`
+ );
+ break;
+ case "role":
+ valueField = field.roleEl;
+ break;
+ }
+
+ // Check the input value of the field.
+ if (valueField) {
+ Assert.equal(
+ expectedEntry.value,
+ valueField.value,
+ `Value of ${key} at position ${index}`
+ );
+ }
+
+ // Check the type of the field.
+ if (expectedEntry.type || typeField) {
+ Assert.equal(
+ expectedEntry.type || "",
+ typeField.value,
+ `Type of ${key} at position ${index}`
+ );
+ }
+ }
+ }
+}
+
+function checkCardValues(card, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ if (value) {
+ Assert.equal(
+ card.getProperty(key, "WRONG!"),
+ value,
+ `${key} has the right value`
+ );
+ } else {
+ Assert.equal(
+ card.getProperty(key, "RIGHT!"),
+ "RIGHT!",
+ `${key} has no value`
+ );
+ }
+ }
+}
+
+function checkVCardValues(card, expected) {
+ for (let [key, expectedEntries] of Object.entries(expected)) {
+ let cardValues = card.vCardProperties.getAllEntries(key);
+
+ Assert.equal(
+ expectedEntries.length,
+ cardValues.length,
+ `${key} is expected to occur ${expectedEntries.length} time(s) and ${cardValues.length} time(s) is found.`
+ );
+
+ for (let [index, entry] of cardValues.entries()) {
+ let expectedEntry = expectedEntries[index];
+
+ Assert.deepEqual(
+ expectedEntry.value,
+ entry.value,
+ `Value of ${key} at position ${index}`
+ );
+
+ if (entry.params.type || expectedEntry.type) {
+ Assert.equal(
+ expectedEntry.type,
+ entry.params.type,
+ `Type of ${key} at position ${index}`
+ );
+ }
+
+ if (entry.params.pref || expectedEntry.pref) {
+ Assert.equal(
+ expectedEntry.pref,
+ entry.params.pref,
+ `Pref of ${key} at position ${index}`
+ );
+ }
+ }
+ }
+}
+
+function setInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, value] of Object.entries(changes)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ if (input.type == "checkbox") {
+ EventUtils.synthesizeMouseAtCenter(input, {}, abWindow);
+ Assert.equal(
+ input.checked,
+ value,
+ `${key} ${value ? "checked" : "unchecked"}`
+ );
+ } else {
+ input.select();
+ if (value) {
+ EventUtils.sendString(value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+/**
+ * Uses EventUtils.synthesizeMouseAtCenter and XULPopup.activateItem to
+ * activate optionValue from the select element typeField.
+ *
+ * @param {HTMLSelectElement} typeField Select element.
+ * @param {string} optionValue The value attribute of the option element from
+ * typeField.
+ */
+async function activateTypeSelect(typeField, optionValue) {
+ let abWindow = getAddressBookWindow();
+ // Ensure that the select field is inside the viewport.
+ typeField.scrollIntoView({ block: "nearest" });
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ EventUtils.synthesizeMouseAtCenter(typeField, {}, abWindow);
+ let selectPopup = await shownPromise;
+
+ // Get the index of the optionValue from typeField
+ let index = Array.from(typeField.children).findIndex(
+ child => child.value === optionValue
+ );
+ Assert.ok(index >= 0, "Type in select field found");
+
+ // No change event is fired if the same option is activated.
+ if (index === typeField.selectedIndex) {
+ let popupHidden = BrowserTestUtils.waitForEvent(selectPopup, "popuphidden");
+ selectPopup.hidePopup();
+ await popupHidden;
+ return;
+ }
+
+ // The change event saves the vCard value.
+ let changeEvent = BrowserTestUtils.waitForEvent(typeField, "change");
+ selectPopup.activateItem(selectPopup.children[index]);
+ await changeEvent;
+}
+
+async function setVCardInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, entries] of Object.entries(changes)) {
+ let fields = getFields(key, true, entries.length);
+ // Somehow prevents an error on macOS when using <select> widgets that
+ // have just been added.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 250));
+
+ for (let [index, field] of fields.entries()) {
+ let changeEntry = entries[index];
+ let valueField;
+ let typeField;
+ switch (key) {
+ case "email":
+ valueField = field.emailEl;
+ typeField = field.vCardType.selectEl;
+
+ if (
+ (field.checkboxEl.checked && changeEntry && !changeEntry.pref) ||
+ (!field.checkboxEl.checked &&
+ changeEntry &&
+ changeEntry.pref == "1")
+ ) {
+ field.checkboxEl.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(field.checkboxEl, {}, abWindow);
+ }
+ break;
+ case "impp":
+ valueField = field.imppEl;
+ break;
+ case "url":
+ valueField = field.urlEl;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "tel":
+ valueField = field.inputElement;
+ typeField = field.vCardType.selectEl;
+ break;
+ case "note":
+ valueField = field.textAreaEl;
+ break;
+ case "specialDate":
+ if (changeEntry && changeEntry.value) {
+ field.month.value = changeEntry.value[1];
+ field.day.value = changeEntry.value[2];
+ field.year.value = changeEntry.value[0];
+ } else {
+ field.month.value = "";
+ field.day.value = "";
+ field.year.value = "";
+ }
+
+ if (changeEntry && changeEntry.key === "bday") {
+ field.selectEl.value = "bday";
+ } else {
+ field.selectEl.value = "anniversary";
+ }
+ break;
+ case "adr":
+ typeField = field.vCardType.selectEl;
+
+ for (let [index, input] of [
+ field.streetEl,
+ field.localityEl,
+ field.regionEl,
+ field.codeEl,
+ field.countryEl,
+ ].entries()) {
+ input.select();
+ if (
+ changeEntry &&
+ Array.isArray(changeEntry.value) &&
+ changeEntry.value[index]
+ ) {
+ EventUtils.sendString(changeEntry.value[index]);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ break;
+ case "tz":
+ if (changeEntry && changeEntry.value) {
+ field.selectEl.value = changeEntry.value;
+ } else {
+ field.selectEl.value = "";
+ }
+ break;
+ case "title":
+ valueField = field.titleEl;
+ break;
+ case "org":
+ for (let [index, input] of [field.orgEl, field.unitEl].entries()) {
+ input.select();
+ if (
+ changeEntry &&
+ Array.isArray(changeEntry.value) &&
+ changeEntry.value[index]
+ ) {
+ EventUtils.sendString(changeEntry.value[index]);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ break;
+ case "role":
+ valueField = field.roleEl;
+ break;
+ case "custom":
+ valueField = field.querySelector("vcard-custom:last-of-type input");
+ break;
+ }
+
+ if (valueField) {
+ valueField.select();
+ if (changeEntry && changeEntry.value) {
+ EventUtils.sendString(changeEntry.value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+
+ if (typeField && changeEntry && changeEntry.type) {
+ await activateTypeSelect(typeField, changeEntry.type);
+ } else if (typeField) {
+ await activateTypeSelect(typeField, "");
+ }
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+/**
+ * Open the contact at the given index in the #cards element.
+ *
+ * @param {number} index - The index of the contact to edit.
+ * @param {object} options - Options for how the contact is selected for
+ * editing.
+ * @param {boolean} options.useMouse - Whether to use mouse events to select the
+ * contact. Otherwise uses keyboard events.
+ * @param {boolean} options.useActivate - Whether to activate the contact for
+ * editing directly from the #cards list using "Enter" or double click.
+ * Otherwise uses the "Edit" button in the contact display.
+ */
+async function editContactAtIndex(index, options) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = cardsList.selectedIndex;
+ },
+ };
+
+ if (!options.useMouse) {
+ cardsList.table.body.focus();
+ if (cardsList.currentIndex != index) {
+ selectHandler.reset();
+ cardsList.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeKey("KEY_Home", {}, abWindow);
+ for (let i = 0; i < index; i++) {
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, abWindow);
+ }
+ await TestUtils.waitForCondition(
+ () => selectHandler.seenEvent,
+ `'select' event should get fired`
+ );
+ }
+ }
+
+ if (options.useActivate) {
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { clickCount: 1 },
+ abWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ { clickCount: 2 },
+ abWindow
+ );
+ } else {
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ }
+ } else {
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ }
+
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ if (options.useMouse) {
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ } else {
+ while (abDocument.activeElement != editButton) {
+ EventUtils.synthesizeKey("KEY_Tab", {}, abWindow);
+ }
+ EventUtils.synthesizeKey(" ", {}, abWindow);
+ }
+ }
+
+ await inEditingMode();
+}
+
+add_task(async function test_basic_edit() {
+ let book = createAddressBook("Test Book");
+ book.addCard(createContact("contact", "1"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let booksList = abDocument.getElementById("books");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ let viewContactName = abDocument.getElementById("viewContactName");
+ let viewContactNickName = abDocument.getElementById("viewContactNickName");
+ let viewContactEmail = abDocument.getElementById("viewPrimaryEmail");
+ let editContactName = abDocument.getElementById("editContactHeadingName");
+ let editContactNickName = abDocument.getElementById(
+ "editContactHeadingNickName"
+ );
+ let editContactEmail = abDocument.getElementById("editContactHeadingEmail");
+
+ /**
+ * Assert that the heading has the expected text content and visibility.
+ *
+ * @param {Element} headingEl - The heading to test.
+ * @param {string} expect - The expected text content. If this is "", the
+ * heading is expected to be hidden as well.
+ */
+ function assertHeading(headingEl, expect) {
+ Assert.equal(
+ headingEl.textContent,
+ expect,
+ `Heading ${headingEl.id} content should match`
+ );
+ if (expect) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(headingEl),
+ `Heading ${headingEl.id} should be visible`
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(headingEl),
+ `Heading ${headingEl.id} should be visible`
+ );
+ }
+ }
+
+ /**
+ * Assert the headings shown in the contact view page.
+ *
+ * @param {string} name - The expected name, or an empty string if none is
+ * expected.
+ * @param {string} nickname - The expected nickname, or an empty string if
+ * none is expected.
+ * @param {string} email - The expected email, or an empty string if none is
+ * expected.
+ */
+ function assertViewHeadings(name, nickname, email) {
+ assertHeading(viewContactName, name);
+ assertHeading(viewContactNickName, nickname);
+ assertHeading(viewContactEmail, email);
+ }
+
+ /**
+ * Assert the headings shown in the contact edit page.
+ *
+ * @param {string} name - The expected name, or an empty string if none is
+ * expected.
+ * @param {string} nickname - The expected nickname, or an empty string if
+ * none is expected.
+ * @param {string} email - The expected email, or an empty string if none is
+ * expected.
+ */
+ function assertEditHeadings(name, nickname, email) {
+ assertHeading(editContactName, name);
+ assertHeading(editContactNickName, nickname);
+ assertHeading(editContactEmail, email);
+ }
+
+ Assert.ok(detailsPane.hidden);
+ Assert.ok(!document.querySelector("vcard-n"));
+ Assert.ok(!abDocument.getElementById("vcard-email").children.length);
+
+ // Select a card in the list. Check the display in view mode.
+
+ Assert.equal(cardsList.view.rowCount, 1);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ assertViewHeadings("contact 1", "", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Try to trigger the creation of a new contact while in edit mode.
+ EventUtils.synthesizeKey("n", { ctrlKey: true }, abWindow);
+
+ // Headings reflect initial values and shouldn't have changed.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ // Check that pressing Tab can't get us stuck on an element that shouldn't
+ // have focus.
+
+ abDocument.documentElement.focus();
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.documentElement,
+ "focus should be on the root element"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+ Assert.ok(
+ abDocument
+ .getElementById("editContactForm")
+ .contains(abDocument.activeElement),
+ "focus should be on the editing form"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.documentElement,
+ "focus should be on the root element again"
+ );
+
+ // Check that clicking outside the form doesn't steal focus.
+
+ EventUtils.synthesizeMouseAtCenter(booksList, {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.body,
+ "focus should be on the body element"
+ );
+ EventUtils.synthesizeMouseAtCenter(cardsList, {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ abDocument.body,
+ "focus should be on the body element still"
+ );
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ // Make sure the header values reflect the fields values.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ // Make some changes but cancel them.
+
+ setInputValues({
+ LastName: "one",
+ DisplayName: "contact one",
+ NickName: "contact nickname",
+ PrimaryEmail: "contact.1.edited@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Headings reflect new values.
+ assertEditHeadings(
+ "contact one",
+ "contact nickname",
+ "contact.1.edited@invalid"
+ );
+
+ // Change the preferred email to the secondary.
+ setInputValues({
+ SecondEmailCheckbox: true,
+ });
+ // The new email value should be reflected in the heading.
+ assertEditHeadings("contact one", "contact nickname", "i@roman.invalid");
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Heading reflects initial values.
+ assertViewHeadings("contact 1", "", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ PrimaryEmail: "contact.1@invalid",
+ });
+
+ // Click to edit again. The changes should have been reversed.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ // Headings are restored.
+ assertEditHeadings("contact 1", "", "contact.1@invalid");
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ // Make some changes again, and this time save them.
+
+ setInputValues({
+ LastName: "one",
+ DisplayName: "contact one",
+ NickName: "contact nickname",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ assertEditHeadings("contact one", "contact nickname", "contact.1@invalid");
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Headings show new values
+ assertViewHeadings("contact one", "contact nickname", "contact.1@invalid");
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_hidden(saveEditButton));
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid", "i@roman.invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "one",
+ DisplayName: "contact one",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Click to edit again. The new values should be shown.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(cancelEditButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ checkInputValues({
+ FirstName: "contact",
+ LastName: "one",
+ DisplayName: "contact one",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: "i@roman.invalid",
+ });
+
+ // Cancel the edit by pressing the Escape key.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Click to edit again. This time make some changes.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ setInputValues({
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Cancel the edit by pressing the Escape key and cancel the prompt.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ Assert.ok(
+ abWindow.detailsPane.isEditing,
+ "still editing after cancelling prompt"
+ );
+
+ // Cancel the edit by pressing the Escape key and accept the prompt.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(editButton);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ checkCardValues(book.childCards[0], {
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Click to edit again.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ setInputValues({
+ LastName: "11",
+ DisplayName: "person 11",
+ SecondEmail: "xi@roman.invalid",
+ });
+
+ // Cancel the edit by pressing the Escape key and discard the changes.
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(editButton);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ checkCardValues(book.childCards[0], {
+ FirstName: "person",
+ DisplayName: "person one",
+ });
+
+ // Click to edit again.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Make some changes again, and this time save them by pressing Enter.
+
+ setInputValues({
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ SecondEmail: null,
+ });
+
+ getInput("SecondEmail").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkDisplayValues({
+ emailAddresses: ["contact.1@invalid"],
+ });
+ checkCardValues(book.childCards[0], {
+ FirstName: "contact",
+ LastName: "1",
+ DisplayName: "contact 1",
+ NickName: "",
+ PrimaryEmail: "contact.1@invalid",
+ SecondEmail: null,
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_special_fields() {
+ Services.prefs.setStringPref("mail.addr_book.show_phonetic_fields", "true");
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // The order of the FirstName and LastName fields can be reversed by L10n.
+ // This means they can be broken by L10n. Check that they're alright in the
+ // default configuration. We need to find a more robust way of doing this,
+ // but it is what it is for now.
+
+ let firstName = abDocument.getElementById("FirstName");
+ let lastName = abDocument.getElementById("LastName");
+ Assert.equal(
+ firstName.compareDocumentPosition(lastName),
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ "LastName follows FirstName"
+ );
+
+ // The phonetic name fields should be visible, because the preference is set.
+ // They can also be broken by L10n.
+
+ let phoneticFirstName = abDocument.getElementById("PhoneticFirstName");
+ let phoneticLastName = abDocument.getElementById("PhoneticLastName");
+ Assert.ok(BrowserTestUtils.is_visible(phoneticFirstName));
+ Assert.ok(BrowserTestUtils.is_visible(phoneticLastName));
+ Assert.equal(
+ phoneticFirstName.compareDocumentPosition(phoneticLastName),
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ "PhoneticLastName follows PhoneticFirstName"
+ );
+
+ await closeAddressBookWindow();
+
+ Services.prefs.setStringPref("mail.addr_book.show_phonetic_fields", "false");
+
+ abWindow = await openAddressBookWindow();
+ abDocument = abWindow.document;
+ createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // The phonetic name fields should be visible, because the preference is set.
+ // They can also be broken by L10n.
+
+ phoneticFirstName = abDocument.getElementById("PhoneticFirstName");
+ phoneticLastName = abDocument.getElementById("PhoneticLastName");
+ Assert.ok(BrowserTestUtils.is_hidden(phoneticFirstName));
+ Assert.ok(BrowserTestUtils.is_hidden(phoneticLastName));
+
+ await closeAddressBookWindow();
+
+ Services.prefs.clearUserPref("mail.addr_book.show_phonetic_fields");
+}).skip(); // Phonetic fields not implemented.
+
+/**
+ * Test that the display name field is populated when it should be, and not
+ * when it shouldn't be.
+ */
+add_task(async function test_generate_display_name() {
+ Services.prefs.setBoolPref("mail.addr_book.displayName.autoGeneration", true);
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "false"
+ );
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ // Try saving an empty contact.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await inEditingMode();
+
+ // First name, no last name.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "first" });
+
+ // Last name, no first name.
+ setInputValues({ FirstName: "", LastName: "last" });
+ checkInputValues({ DisplayName: "last" });
+
+ // Both names.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "first last" });
+
+ // Modify the display name, it should not be overwritten.
+ setInputValues({ DisplayName: "don't touch me" });
+ setInputValues({ FirstName: "second" });
+ checkInputValues({ DisplayName: "don't touch me" });
+
+ // Clear the modified display name, it should still not be overwritten.
+ setInputValues({ DisplayName: "" });
+ setInputValues({ FirstName: "third" });
+ checkInputValues({ DisplayName: "" });
+
+ // Flip the order.
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "true"
+ );
+ setInputValues({ FirstName: "fourth" });
+ checkInputValues({ DisplayName: "" });
+
+ // Turn off generation.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.displayName.autoGeneration",
+ false
+ );
+ setInputValues({ FirstName: "fifth" });
+ checkInputValues({ DisplayName: "" });
+
+ setInputValues({ DisplayName: "last, fourth" });
+
+ // Save the card and check the values.
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+ checkCardValues(personalBook.childCards[0], {
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+ Assert.ok(!abWindow.detailsPane.isDirty, "dirty flag is cleared");
+
+ // Reset the order and turn generation back on.
+ Services.prefs.setBoolPref("mail.addr_book.displayName.autoGeneration", true);
+ Services.prefs.setStringPref(
+ "mail.addr_book.displayName.lastnamefirst",
+ "false"
+ );
+
+ // Reload the card and check the values.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+ checkInputValues({
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+
+ // Clear all required values.
+ setInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ });
+
+ // Try saving the empty contact.
+ promptPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await inEditingMode();
+
+ // Close the edit without saving.
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+
+ // Enter edit mode again. The values shouldn't have changed.
+ EventUtils.synthesizeKey("KEY_Enter", {}, abWindow);
+ await inEditingMode();
+ checkInputValues({
+ FirstName: "fifth",
+ LastName: "last",
+ DisplayName: "last, fourth",
+ });
+
+ // Check the saved name isn't overwritten.
+ setInputValues({ FirstName: "first" });
+ checkInputValues({ DisplayName: "last, fourth" });
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ Services.prefs.clearUserPref("mail.addr_book.displayName.autoGeneration");
+ Services.prefs.clearUserPref("mail.addr_book.displayName.lastnamefirst");
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Test that the "prefer display name" checkbox is visible when it should be
+ * (in edit mode and only if there is a display name).
+ */
+add_task(async function test_prefer_display_name() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Make a new card. Check the default value is true.
+ // The display name shouldn't be affected by first and last name if the field
+ // is not empty.
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+
+ checkInputValues({ DisplayName: "", PreferDisplayName: true });
+
+ setInputValues({ DisplayName: "test" });
+ setInputValues({ FirstName: "first" });
+
+ checkInputValues({ DisplayName: "test" });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.equal(personalBook.childCardCount, 1);
+ checkCardValues(personalBook.childCards[0], {
+ DisplayName: "test",
+ PreferDisplayName: "1",
+ });
+
+ // Edit the card. Check the UI matches the card value.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({ DisplayName: "test" });
+ checkInputValues({ FirstName: "first" });
+
+ // Change the card value.
+
+ let preferDisplayName = abDocument.querySelector(
+ "vcard-fn #vCardPreferDisplayName"
+ );
+ EventUtils.synthesizeMouseAtCenter(preferDisplayName, {}, abWindow);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.equal(personalBook.childCardCount, 1);
+ checkCardValues(personalBook.childCards[0], {
+ DisplayName: "test",
+ PreferDisplayName: "0",
+ });
+
+ // Edit the card. Check the UI matches the card value.
+
+ preferDisplayName.checked = true; // Ensure it gets set.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Clear the display name. The first and last name shouldn't affect it.
+ setInputValues({ DisplayName: "" });
+ checkInputValues({ FirstName: "first" });
+
+ setInputValues({ LastName: "last" });
+ checkInputValues({ DisplayName: "" });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Checks the state of the toolbar buttons is restored after editing.
+ */
+add_task(async function test_toolbar_state() {
+ personalBook.addCard(createContact("contact", "2"));
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // In All Address Books, the "create card" and "create list" buttons should
+ // be disabled.
+
+ await openAllAddressBooks();
+ checkToolbarState(true);
+
+ // In other directories, all buttons should be enabled.
+
+ await openDirectory(personalBook);
+ checkToolbarState(true);
+
+ // Back to All Address Books.
+
+ await openAllAddressBooks();
+ checkToolbarState(true);
+
+ // Select a card, no change.
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ checkToolbarState(true);
+
+ // Edit a card, all buttons disabled.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Cancel editing, button states restored.
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Edit a card again, all buttons disabled.
+
+ EventUtils.synthesizeKey(" ", {}, abWindow);
+ await inEditingMode();
+
+ // Cancel editing, button states restored.
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+add_task(async function test_delete_button() {
+ let abWindow = await openAddressBookWindow();
+ openDirectory(personalBook);
+
+ let abDocument = abWindow.document;
+ let searchInput = abDocument.getElementById("searchInput");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let editButton = abDocument.getElementById("editButton");
+ let deleteButton = abDocument.getElementById("detailsDeleteButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane), "details pane is hidden");
+
+ // Create a new card. The delete button shouldn't be visible at this point.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+ Assert.ok(BrowserTestUtils.is_visible(saveEditButton));
+
+ setInputValues({
+ FirstName: "delete",
+ LastName: "me",
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+
+ Assert.equal(personalBook.childCardCount, 1, "contact was not deleted");
+ let contact = personalBook.childCards[0];
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(deleteButton));
+
+ // Click to delete, cancel the deletion.
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ Assert.ok(abWindow.detailsPane.isEditing, "still in editing mode");
+ Assert.equal(personalBook.childCardCount, 1, "contact was not deleted");
+
+ // Click to delete, accept the deletion.
+
+ let deletionPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(searchInput);
+
+ let [subject, data] = await deletionPromise;
+ Assert.equal(subject.UID, contact.UID, "correct card was deleted");
+ Assert.equal(data, personalBook.UID, "card was deleted from correct place");
+ Assert.equal(personalBook.childCardCount, 0, "contact was deleted");
+ Assert.equal(
+ cardsList.view.directory.UID,
+ personalBook.UID,
+ "view didn't change"
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_hidden(detailsPane)
+ );
+
+ // Now let's delete a contact while viewing a list.
+
+ let listContact = createContact("delete", "me too");
+ let list = personalBook.addMailList(createMailingList("a list"));
+ list.addCard(listContact);
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+
+ openDirectory(list);
+ Assert.equal(cardsList.view.rowCount, 1);
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ Assert.ok(BrowserTestUtils.is_visible(editButton));
+ Assert.ok(BrowserTestUtils.is_hidden(deleteButton));
+
+ // Click to edit.
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.ok(BrowserTestUtils.is_hidden(editButton));
+ Assert.ok(BrowserTestUtils.is_visible(deleteButton));
+
+ // Click to delete, accept the deletion.
+ deletionPromise = TestUtils.topicObserved("addrbook-contact-deleted");
+ promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(searchInput);
+
+ [subject, data] = await deletionPromise;
+ Assert.equal(subject.UID, listContact.UID, "correct card was deleted");
+ Assert.equal(data, personalBook.UID, "card was deleted from correct place");
+ Assert.equal(personalBook.childCardCount, 0, "contact was deleted");
+ Assert.equal(cardsList.view.directory.UID, list.UID, "view didn't change");
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_hidden(detailsPane)
+ );
+
+ personalBook.deleteDirectory(list);
+ await closeAddressBookWindow();
+});
+
+function checkNFieldState({ prefix, middlename, suffix }) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ Assert.equal(abDocument.querySelectorAll("vcard-n").length, 1);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById("vcard-n-firstname")),
+ "Firstname is always shown."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById("vcard-n-lastname")),
+ "Lastname is always shown."
+ );
+
+ for (let [subValueName, inputId, buttonSelector, inputVisible] of [
+ ["prefix", "vcard-n-prefix", "#n-list-component-prefix button", prefix],
+ [
+ "middlename",
+ "vcard-n-middlename",
+ "#n-list-component-middlename button",
+ middlename,
+ ],
+ ["suffix", "vcard-n-suffix", "#n-list-component-suffix button", suffix],
+ ]) {
+ let inputEl = abDocument.getElementById(inputId);
+ Assert.ok(inputEl);
+ let buttonEl = abDocument.querySelector(buttonSelector);
+ Assert.ok(buttonEl);
+
+ if (inputVisible) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(inputEl),
+ `${subValueName} input is shown with an initial value or a click on the button.`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(buttonEl),
+ `${subValueName} button is hidden when the input is shown.`
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(inputEl),
+ `${subValueName} input is not shown initially.`
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(buttonEl),
+ `${subValueName} button is shown when the input is hidden.`
+ );
+ }
+ }
+}
+
+/**
+ * Save repeatedly names of two contacts and ensure that no fields are leaking
+ * to another card.
+ */
+add_task(async function test_name_fields() {
+ let book = createAddressBook("Test Book N Field");
+ book.addCard(createContact("contact1", "lastname1"));
+ book.addCard(createContact("contact2", "lastname2"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ // Edit contact1.
+ await editContactAtIndex(0, {});
+
+ // Check for the original values of contact1.
+ checkInputValues({ FirstName: "contact1", LastName: "lastname1" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "", "", ""] }],
+ });
+
+ // Edit contact1 set all n values.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ Prefix: "prefix 1",
+ FirstName: "contact1 changed",
+ MiddleName: "middle name 1",
+ LastName: "lastname1 changed",
+ Suffix: "suffix 1",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [
+ {
+ value: [
+ "lastname1 changed",
+ "contact1 changed",
+ "middle name 1",
+ "prefix 1",
+ "suffix 1",
+ ],
+ },
+ ],
+ });
+
+ // Edit contact2.
+ await editContactAtIndex(1, {});
+
+ // Check for the original values of contact2 after saving contact1.
+ checkInputValues({ FirstName: "contact2", LastName: "lastname2" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Ensure that both vCardValues of contact1 and contact2 are correct.
+ checkVCardValues(book.childCards[0], {
+ n: [
+ {
+ value: [
+ "lastname1 changed",
+ "contact1 changed",
+ "middle name 1",
+ "prefix 1",
+ "suffix 1",
+ ],
+ },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Edit contact1 and change the values to only firstname and lastname values
+ // to see that the button/input handling of the field is correct.
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ Prefix: "prefix 1",
+ FirstName: "contact1 changed",
+ MiddleName: "middle name 1",
+ LastName: "lastname1 changed",
+ Suffix: "suffix 1",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ setInputValues({
+ Prefix: "",
+ FirstName: "contact1 changed",
+ MiddleName: "",
+ LastName: "lastname1 changed",
+ Suffix: "",
+ });
+
+ // Fields are still visible until the contact is saved and edited again.
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1 changed", "contact1 changed", "", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Check in contact1 that prefix, middlename and suffix inputs are hidden
+ // again. Then remove the N last values and save.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ checkInputValues({
+ FirstName: "contact1 changed",
+ LastName: "lastname1 changed",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ // Let firstname and lastname empty for contact1.
+ setInputValues({
+ FirstName: "",
+ LastName: "",
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ // If useActivate is called, expect the focus to return to the cards list.
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [{ value: ["lastname2", "contact2", "", "", ""] }],
+ });
+
+ // Edit contact2.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkInputValues({ FirstName: "contact2", LastName: "lastname2" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Edit contact1.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ checkInputValues({ FirstName: "", LastName: "" });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: false });
+
+ setInputValues({
+ FirstName: "contact1",
+ MiddleName: "middle name 1",
+ LastName: "lastname1",
+ });
+
+ checkNFieldState({ prefix: false, middlename: true, suffix: false });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Now check when cancelling that no data is leaked between edits.
+ // Edit contact2 for this first.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ setInputValues({
+ Prefix: "prefix 2",
+ FirstName: "contact2",
+ MiddleName: "middle name",
+ LastName: "lastname2",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: true, middlename: true, suffix: true });
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Ensure that prefix, middlename and lastname are correctly shown after
+ // cancelling contact2. Then cancel contact2 again and look at contact1.
+ await editContactAtIndex(1, {});
+
+ checkInputValues({
+ FirstName: "contact2 changed",
+ LastName: "lastname2 changed",
+ Suffix: "suffix 2",
+ });
+
+ checkNFieldState({ prefix: false, middlename: false, suffix: true });
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ n: [{ value: ["lastname1", "contact1", "middle name 1", "", ""] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ n: [
+ { value: ["lastname2 changed", "contact2 changed", "", "", "suffix 2"] },
+ ],
+ });
+
+ // Ensure that a cancel from contact2 doesn't leak to contact1.
+ await editContactAtIndex(0, {});
+
+ checkNFieldState({ prefix: false, middlename: true, suffix: false });
+
+ checkInputValues({
+ FirstName: "contact1",
+ MiddleName: "middle name 1",
+ LastName: "lastname1",
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Checks if the default choice is visible or hidden.
+ * If the default choice is expected checks that at maximum one
+ * default email is ticked.
+ *
+ * @param {boolean} expectedDefaultChoiceVisible
+ * @param {number} expectedDefaultIndex
+ */
+async function checkDefaultEmailChoice(
+ expectedDefaultChoiceVisible,
+ expectedDefaultIndex
+) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let emailFields = abDocument.querySelectorAll(`#vcard-email tr`);
+
+ for (let [index, emailField] of emailFields.entries()) {
+ if (expectedDefaultChoiceVisible) {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(emailField.checkboxEl),
+ `Email at index ${index} has a visible default email choice.`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_hidden(emailField.checkboxEl),
+ `Email at index ${index} has a hidden default email choice.`
+ );
+ }
+
+ // Default email checking of the field.
+ Assert.equal(
+ expectedDefaultIndex === index,
+ emailField.checkboxEl.checked,
+ `Pref of email at position ${index}`
+ );
+ }
+
+ // Check that at max one checkbox is ticked.
+ if (expectedDefaultChoiceVisible) {
+ let checked = Array.from(emailFields).filter(
+ emailField => emailField.checkboxEl.checked
+ );
+ Assert.ok(
+ checked.length <= 1,
+ "At maximum one email is ticked for the default email."
+ );
+ }
+}
+
+add_task(async function test_email_fields() {
+ let book = createAddressBook("Test Book Email Field");
+ book.addCard(createContact("contact1", "lastname1"));
+ book.addCard(createContact("contact2", "lastname2"));
+
+ let abWindow = await openAddressBookWindow();
+ openDirectory(book);
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+
+ // Edit contact1.
+ await editContactAtIndex(0, { useActivate: true });
+
+ // Check for the original values of contact1.
+ checkVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ // Focus moves to cards list if we activate the edit directly from the list.
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", pref: "1" }],
+ });
+
+ // Edit contact1 set type.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ await setVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid", type: "work" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", type: "work", pref: "1" }],
+ });
+
+ // Check for the original values of contact2.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ email: [{ value: "contact2.lastname2@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // Ensure that both vCardValues of contact1 and contact2 are correct.
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "contact1.lastname1@invalid", type: "work", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Edit contact1 and add another email to see that the default email
+ // choosing is visible.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [{ value: "contact1.lastname1@invalid", type: "work" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", pref: "1", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 0);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1.lastname1@invalid", pref: "1", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Choose another default email in contact1.
+ await editContactAtIndex(0, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Remove the first email from contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1.lastname1@invalid", type: "work" },
+ { value: "another.contact1@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [{}, { value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ // The default email choosing is still visible until the contact is saved.
+ await checkDefaultEmailChoice(true, 1);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "contact2.lastname2@invalid", pref: "1" }],
+ });
+
+ // Add multiple emails to contact2 and click each as the default email.
+ // The last default clicked email should be set as default email and
+ // only one should be selected.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ email: [{ value: "contact2.lastname2@invalid" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 1);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid", pref: "1" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [
+ { value: "home.contact2@invalid", type: "home" },
+ { value: "work.contact2@invalid", type: "work" },
+ { value: "some.contact2@invalid" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ // Remove 3 emails from contact2.
+ await editContactAtIndex(1, { useActivate: true, useMouse: true });
+
+ checkVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home" },
+ { value: "work.contact2@invalid", type: "work" },
+ { value: "some.contact2@invalid" },
+ { value: "default.email.contact2@invalid", type: "home" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ await setVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ // The default email choosing is still visible until the contact is saved.
+ // For this case the default email is left on an empty field which will be
+ // removed.
+ await checkDefaultEmailChoice(true, 3);
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Now check when cancelling that no data is leaked between edits.
+ // Edit contact2 for this first.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await setVCardInputValues({
+ email: [
+ { value: "home.contact2@invalid", type: "home", pref: "1" },
+ { value: "work.contact2@invalid", type: "work", pref: "1" },
+ { value: "some.contact2@invalid", pref: "1" },
+ { value: "default.email.contact2@invalid", type: "home", pref: "1" },
+ ],
+ });
+
+ await checkDefaultEmailChoice(true, 3);
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Ensure that the default email choosing is not shown after
+ // cancelling contact2. Then cancel contact2 again and look at contact1.
+ await editContactAtIndex(1, { useMouse: true });
+
+ checkVCardInputValues({
+ email: [{ value: "home.contact2@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [{ value: "another.contact1@invalid", type: "home", pref: "1" }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ email: [{ value: "home.contact2@invalid", type: "home", pref: "1" }],
+ });
+
+ // Ensure that a cancel from contact2 doesn't leak to contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ email: [{ value: "another.contact1@invalid", type: "home" }],
+ });
+
+ await checkDefaultEmailChoice(false, 0);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_vCard_fields() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book VCard Fields");
+
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+ let contact2 = createContact("contact2", "lastname");
+ book.addCard(contact2);
+
+ openDirectory(book);
+
+ let cardsList = abDocument.getElementById("cards");
+ let searchInput = abDocument.getElementById("searchInput");
+ let editButton = abDocument.getElementById("editButton");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Check that no field is initially shown with a new contact.
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ for (let [selector, label] of [
+ ["vcard-impp", "Chat accounts"],
+ ["vcard-url", "Websites"],
+ ["vcard-tel", "Phone numbers"],
+ ["vcard-note", "Notes"],
+ ["vcard-special-dates", "Special dates"],
+ ["vcard-adr", "Addresses"],
+ ["vcard-tz", "Time Zone"],
+ ["vcard-role", "Organizational properties"],
+ ["vcard-title", "Organizational properties"],
+ ["vcard-org", "Organizational properties"],
+ ]) {
+ Assert.equal(
+ abDocument.querySelectorAll(selector).length,
+ 0,
+ `${label} are not initially shown.`
+ );
+ }
+
+ // Cancel the new contact creation.
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(searchInput);
+
+ // Set values for contact1 with one entry for each field.
+ await editContactAtIndex(0, { useMouse: true, useActivate: true });
+
+ await setVCardInputValues({
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ ],
+ adr: [
+ {
+ value: ["123 Main Street", "Any Town", "CA", "91921-1234", "U.S.A"],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [{ value: "1980-12-15" }],
+ adr: [
+ {
+ value: [
+ "",
+ "",
+ "123 Main Street",
+ "Any Town",
+ "CA",
+ "91921-1234",
+ "U.S.A",
+ ],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ // Edit the same contact and set multiple fields.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ impp: [{ value: "matrix:u/contact1:example.com" }],
+ url: [{ value: "https://www.example.com" }],
+ tel: [{ value: "+123456 789" }],
+ note: [{ value: "A note to this contact" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ ],
+ adr: [
+ {
+ value: ["123 Main Street", "Any Town", "CA", "91921-1234", "U.S.A"],
+ },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Inc.", "European Division"] }],
+ });
+
+ await setVCardInputValues({
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ { value: [1960, 9, 17], key: "anniversary" },
+ { value: [2010, 7, 1], key: "anniversary" },
+ ],
+ adr: [
+ { value: ["123 Main Street", "", "", "", ""] },
+ { value: ["456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [
+ { value: "1980-12-15" },
+ { value: "1960-09-17" },
+ { value: "2010-07-01" },
+ ],
+ adr: [
+ { value: ["", "", "123 Main Street", "", "", "", ""] },
+ { value: ["", "", "456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["", "", "789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ // Switch from contact1 to contact2 and set some entries.
+ // Ensure that no fields from contact1 are leaked.
+ await editContactAtIndex(1, { useMouse: true });
+
+ checkVCardInputValues({
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ specialDate: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ await setVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: ["Organization contact 2"] }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ bday: [{ value: "2000-03-31" }],
+ anniversary: [
+ { value: "1980-12-15" },
+ { value: "1960-09-17" },
+ { value: "2010-07-01" },
+ ],
+ adr: [
+ { value: ["", "", "123 Main Street", "", "", "", ""] },
+ { value: ["", "", "456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["", "", "789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Ensure that no fields from contact2 are leaked to contact1.
+ // Check and remove all values from contact1.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ impp: [
+ { value: "matrix:u/contact1:example.com" },
+ { value: "irc://irc.example.com/contact1,isuser" },
+ { value: "xmpp:test@example.com" },
+ ],
+ url: [
+ { value: "https://example.com" },
+ { value: "https://hello", type: "home" },
+ { value: "https://www.example.invalid", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 77 666 8" },
+ { value: "+1113456789", type: "work" },
+ ],
+ note: [{ value: "Another note contact1\n\n\n" }],
+ specialDate: [
+ { value: [2000, 3, 31], key: "bday" },
+ { value: [1980, 12, 15], key: "anniversary" },
+ { value: [1960, 9, 17], key: "anniversary" },
+ { value: [2010, 7, 1], key: "anniversary" },
+ ],
+ adr: [
+ { value: ["123 Main Street", "", "", "", ""] },
+ { value: ["456 Side Street", "", "", "", ""], type: "home" },
+ { value: ["789 Side Street", "", "", "", ""], type: "work" },
+ ],
+ tz: [{ value: "Africa/Abidjan" }],
+ role: [{ value: "Role" }],
+ title: [{ value: "Title" }],
+ org: [{ value: ["Example Co.", "South American Division"] }],
+ });
+
+ await setVCardInputValues({
+ impp: [{}, {}, {}],
+ url: [{}, {}, {}],
+ tel: [{}, {}, {}],
+ note: [{}],
+ specialDate: [{}, {}, {}, {}],
+ adr: [{}, {}, {}],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check contact2 make changes and cancel.
+ await editContactAtIndex(1, { useActivate: true });
+
+ checkVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ await setVCardInputValues({
+ impp: [{ value: "" }],
+ url: [
+ { value: "https://www.thunderbird.net" },
+ { value: "www.another.url", type: "work" },
+ ],
+ tel: [{ value: "650-903-0800" }, { value: "+123 456 789", type: "home" }],
+ note: [],
+ specialDate: [{}, { value: [1980, 12, 15], key: "anniversary" }],
+ adr: [],
+ tz: [],
+ role: [{ value: "Some Role contact 2" }],
+ title: [],
+ org: [{ value: "Some Organization" }],
+ });
+
+ // Cancel the changes.
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await promptPromise;
+ await notInEditingMode(cardsList.table.body);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check that the cancel for contact2 worked cancel afterwards.
+ await editContactAtIndex(1, {});
+
+ checkVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [
+ { value: [1966, 12, 15], key: "bday" },
+ { value: [1954, 9, 17], key: "anniversary" },
+ ],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ bday: [],
+ anniversary: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ checkVCardValues(book.childCards[1], {
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ bday: [{ value: "1966-12-15" }],
+ anniversary: [{ value: "1954-09-17" }],
+ adr: [{ value: ["", "", "123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ });
+
+ // Check that no values from contact2 are leaked to contact1 when cancelling.
+ await editContactAtIndex(0, {});
+
+ checkVCardInputValues({
+ impp: [],
+ url: [],
+ tel: [],
+ note: [],
+ specialDate: [],
+ adr: [],
+ tz: [],
+ role: [],
+ title: [],
+ org: [],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_vCard_minimal() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ let addOrgButton = abDocument.getElementById("vcard-add-org");
+ addOrgButton.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addOrgButton, {}, abWindow);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-title")),
+ "Title should be visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-role")),
+ "Role should be visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-org")),
+ "Organization should be visible"
+ );
+
+ abDocument.querySelector("vcard-org input").value = "FBI";
+
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let editButton = abDocument.getElementById("editButton");
+
+ // Should allow to save with only Organization filled.
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(personalBook.childCards[0], {
+ org: [{ value: "FBI" }],
+ });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+});
+
+/**
+ * Switches to different types to verify that all works accordingly.
+ */
+add_task(async function test_type_selection() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book Type Selection");
+
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+
+ openDirectory(book);
+
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ await editContactAtIndex(0, {});
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1@invalid" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1@invalid", pref: "1" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ email: [
+ { value: "contact1@invalid", pref: "1" },
+ { value: "home.contact1@invalid", type: "home" },
+ { value: "work.contact1@invalid", type: "work" },
+ ],
+ url: [
+ { value: "https://none.example.com" },
+ { value: "https://home.example.com", type: "home" },
+ { value: "https://work.example.com", type: "work" },
+ ],
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ await setVCardInputValues({
+ email: [
+ { value: "contact1@invalid", type: "work" },
+ { value: "home.contact1@invalid" },
+ { value: "work.contact1@invalid", type: "home" },
+ ],
+ url: [
+ { value: "https://none.example.com", type: "work" },
+ { value: "https://home.example.com" },
+ { value: "https://work.example.com", type: "home" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "pager" },
+ { value: "809 HOME 77 666 8" },
+ { value: "+111 WORK 3456789", type: "home" },
+ { value: "+123 CELL 456 789" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "cell" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ checkVCardValues(book.childCards[0], {
+ email: [
+ { value: "contact1@invalid", type: "work", pref: "1" },
+ { value: "home.contact1@invalid" },
+ { value: "work.contact1@invalid", type: "home" },
+ ],
+ url: [
+ { value: "https://none.example.com", type: "work" },
+ { value: "https://home.example.com" },
+ { value: "https://work.example.com", type: "home" },
+ ],
+ tel: [
+ { value: "+123456 789", type: "pager" },
+ { value: "809 HOME 77 666 8" },
+ { value: "+111 WORK 3456789", type: "home" },
+ { value: "+123 CELL 456 789" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "cell" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+/**
+ * Other vCard contacts are using uppercase types for the predefined spec
+ * labels. This tests our support for them for the edit of a contact.
+ */
+add_task(async function test_support_types_uppercase() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let book = createAddressBook("Test Book Uppercase Type Support");
+
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ // Add a card with uppercase types.
+ book.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ FN:contact 1
+ TEL:+123456 789
+ TEL;TYPE=HOME:809 HOME 77 666 8
+ TEL;TYPE=WORK:+111 WORK 3456789
+ TEL;TYPE=CELL:+123 CELL 456 789
+ TEL;TYPE=FAX:809 FAX 77 666 8
+ TEL;TYPE=PAGER:+111 PAGER 3456789
+ END:VCARD
+`)
+ );
+
+ openDirectory(book);
+
+ // First open the edit and check that the values are shown.
+ // Do not change anything.
+ await editContactAtIndex(0, {});
+
+ // The UI uses lowercase types but only changes them when the type is
+ // touched.
+ checkVCardInputValues({
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // We haven't touched these values so they are not changed to lower case.
+ checkVCardValues(book.childCards[0], {
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "HOME" },
+ { value: "+111 WORK 3456789", type: "WORK" },
+ { value: "+123 CELL 456 789", type: "CELL" },
+ { value: "809 FAX 77 666 8", type: "FAX" },
+ { value: "+111 PAGER 3456789", type: "PAGER" },
+ ],
+ });
+
+ // Now make changes to the types.
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ checkVCardInputValues({
+ tel: [
+ { value: "+123456 789" },
+ { value: "809 HOME 77 666 8", type: "home" },
+ { value: "+111 WORK 3456789", type: "work" },
+ { value: "+123 CELL 456 789", type: "cell" },
+ { value: "809 FAX 77 666 8", type: "fax" },
+ { value: "+111 PAGER 3456789", type: "pager" },
+ ],
+ });
+
+ await setVCardInputValues({
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 HOME 77 666 8", type: "cell" },
+ { value: "+111 WORK 3456789", type: "pager" },
+ { value: "+123 CELL 456 789", type: "fax" },
+ { value: "809 FAX 77 666 8", type: "" },
+ { value: "+111 PAGER 3456789", type: "work" },
+ ],
+ });
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ // As we touched the type values they are now saved in lowercase.
+ // At this point it is up to the other vCard implementation to handle this.
+ checkVCardValues(book.childCards[0], {
+ tel: [
+ { value: "+123456 789", type: "home" },
+ { value: "809 HOME 77 666 8", type: "cell" },
+ { value: "+111 WORK 3456789", type: "pager" },
+ { value: "+123 CELL 456 789", type: "fax" },
+ { value: "809 FAX 77 666 8", type: "" },
+ { value: "+111 PAGER 3456789", type: "work" },
+ ],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_special_date_field() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+
+ openDirectory(personalBook);
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ checkInputValues({
+ FirstName: "",
+ LastName: "",
+ DisplayName: "",
+ PreferDisplayName: true,
+ });
+
+ // Add data to the default values to allow saving.
+ setInputValues({
+ FirstName: "contact",
+ PrimaryEmail: "contact.1.edited@invalid",
+ });
+
+ let addSpecialDate = abDocument.getElementById("vcard-add-bday-anniversary");
+ addSpecialDate.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(addSpecialDate, {}, abWindow);
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.querySelector("vcard-special-date")),
+ "The special date field is visible."
+ );
+ // Somehow prevents an error on macOS when using <select> widgets that have
+ // just been added.
+ await new Promise(resolve => abWindow.setTimeout(resolve, 250));
+
+ let firstYear = abDocument.querySelector(
+ `vcard-special-date input[type="number"]`
+ );
+ Assert.ok(!firstYear.value, "year empty");
+ let firstMonth = abDocument.querySelector(
+ `vcard-special-date .vcard-month-select`
+ );
+ Assert.equal(firstMonth.value, "", "month should be on placeholder");
+ let firstDay = abDocument.querySelector(
+ `vcard-special-date .vcard-day-select`
+ );
+ Assert.equal(firstDay.value, "", "day should be on placeholder");
+ Assert.equal(firstDay.childNodes.length, 32, "all days should be possible");
+
+ // Set date to a leap year.
+ firstYear.value = 2004;
+
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ firstMonth.scrollIntoView({ block: "nearest" });
+ EventUtils.synthesizeMouseAtCenter(firstMonth, {}, abWindow);
+ let selectPopup = await shownPromise;
+
+ let changePromise = BrowserTestUtils.waitForEvent(firstMonth, "change");
+ selectPopup.activateItem(selectPopup.children[2]);
+ await changePromise;
+
+ await BrowserTestUtils.waitForCondition(
+ () => firstDay.childNodes.length == 30, // 29 days + empty option 0.
+ "day options filled with leap year"
+ );
+
+ // No leap year.
+ firstYear.select();
+ EventUtils.sendString("2003");
+ await BrowserTestUtils.waitForCondition(
+ () => firstDay.childNodes.length == 29, // 28 days + empty option 0.
+ "day options filled without leap year"
+ );
+
+ // Remove the field.
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.querySelector(`vcard-special-date .remove-property-button`),
+ {},
+ abWindow
+ );
+
+ Assert.ok(
+ !abDocument.querySelector("vcard-special-date"),
+ "The special date field was removed."
+ );
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests that custom properties (Custom1 etc.) are editable.
+ */
+add_task(async function testCustomProperties() {
+ let card = new AddrBookCard();
+ card._properties = new Map([
+ ["PopularityIndex", 0],
+ ["Custom2", "custom two"],
+ ["Custom4", "custom four"],
+ [
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ FN:custom person
+ X-CUSTOM3:x-custom three
+ X-CUSTOM4:x-custom four
+ END:VCARD
+ `,
+ ],
+ ]);
+ card = personalBook.addCard(card);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+
+ let index = cardsList.view.getIndexForUID(card.UID);
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(index),
+ {},
+ abWindow
+ );
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ let customField = getFields("custom")[0];
+ let inputs = customField.querySelectorAll("input");
+ Assert.equal(inputs.length, 4);
+ Assert.equal(inputs[0].value, "");
+ Assert.equal(inputs[1].value, "custom two");
+ Assert.equal(inputs[2].value, "x-custom three");
+ Assert.equal(inputs[3].value, "x-custom four");
+
+ inputs[0].value = "x-custom one";
+ inputs[1].value = "x-custom two";
+ inputs[3].value = "";
+
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ card = personalBook.childCards.find(c => c.UID == card.UID);
+ checkCardValues(card, {
+ Custom2: null,
+ Custom4: null,
+ });
+ checkVCardValues(card, {
+ "x-custom1": [{ value: "x-custom one" }],
+ "x-custom2": [{ value: "x-custom two" }],
+ "x-custom3": [{ value: "x-custom three" }],
+ "x-custom4": [],
+ });
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards([card]);
+});
+
+/**
+ * Tests that we correctly fix Google's bad escaping of colons in values, and
+ * other characters in URI values.
+ */
+add_task(async function testGoogleEscaping() {
+ let googleBook = createAddressBook("Google Book");
+ googleBook.wrappedJSObject._isGoogleCardDAV = true;
+ googleBook.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ N:test;en\\\\c\\:oding;;;
+ FN:en\\\\c\\:oding test
+ TITLE:title\\:title\\;title\\,title\\\\title\\\\\\:title\\\\\\;title\\\\\\,title\\\\\\\\
+ TEL:tel\\:0123\\\\4567
+ EMAIL:test\\\\test@invalid
+ NOTE:notes\\:\\nnotes\\;\\nnotes\\,\\nnotes\\\\
+ URL:https\\://host/url\\:url\\;url\\,url\\\\url
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ openDirectory(googleBook);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ FirstName: "en\\c:oding",
+ LastName: "test",
+ DisplayName: "en\\c:oding test",
+ });
+
+ checkVCardInputValues({
+ title: [
+ { value: "title:title;title,title\\title\\:title\\;title\\,title\\\\" },
+ ],
+ tel: [{ value: "tel:01234567" }],
+ email: [{ value: "test\\test@invalid" }],
+ note: [{ value: "notes:\nnotes;\nnotes,\nnotes\\" }],
+ url: [{ value: "https://host/url:url;url,url\\url" }],
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(googleBook.URI);
+});
+
+/**
+ * Tests that contacts with nickname can be edited.
+ */
+add_task(async function testNickname() {
+ let book = createAddressBook("Nick");
+ book.addCard(
+ VCardUtils.vCardToAbCard(formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:jsmith@example.org
+ NICKNAME:Johnny
+ N:SMITH;JOHN;;;
+ END:VCARD
+ `)
+ );
+
+ let abWindow = await openAddressBookWindow();
+
+ let abDocument = abWindow.document;
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ openDirectory(book);
+ Assert.equal(cardsList.view.rowCount, 1);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await editContactAtIndex(0, {});
+
+ checkInputValues({
+ FirstName: "JOHN",
+ LastName: "SMITH",
+ NickName: "Johnny",
+ PrimaryEmail: "jsmith@example.org",
+ });
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
+
+add_task(async function test_remove_button() {
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let book = createAddressBook("Test Book VCard Fields");
+ let contact1 = createContact("contact1", "lastname");
+ book.addCard(contact1);
+
+ openDirectory(book);
+
+ await editContactAtIndex(0, {});
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ let removeButtons = detailsPane.querySelectorAll(".remove-property-button");
+ Assert.equal(
+ removeButtons.length,
+ 2,
+ "Email and Organization Properties remove button is present."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ abDocument
+ .getElementById("addr-book-edit-email")
+ .querySelector(".remove-property-button")
+ ),
+ "Email is present and remove button is visible."
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ abDocument
+ .getElementById("addr-book-edit-org")
+ .querySelector(".remove-property-button")
+ ),
+ "Organization Properties are not filled and the remove button is not visible."
+ );
+
+ // Set a value for each field.
+ await setVCardInputValues({
+ impp: [{ value: "invalid:example.com" }],
+ url: [{ value: "https://www.thunderbird.net" }],
+ tel: [{ value: "650-903-0800" }],
+ note: [{ value: "Another note\nfor contact 2" }],
+ specialDate: [{ value: [1966, 12, 15], key: "bday" }],
+ adr: [{ value: ["123 Work Street", "", "", "", ""], type: "work" }],
+ tz: [{ value: "Africa/Accra" }],
+ role: [{ value: "Role contact 2" }],
+ title: [{ value: "Title contact 2" }],
+ org: [{ value: "Organization contact 2" }],
+ custom: [{ value: "foo" }],
+ });
+
+ let vCardEdit = detailsPane.querySelector("vcard-edit");
+
+ // Click the remove buttons and check that the properties are removed.
+
+ for (let [propertyName, fieldsetId, propertySelector, addButton] of [
+ ["adr", "addr-book-edit-address", "vcard-adr"],
+ ["impp", "addr-book-edit-impp", "vcard-impp"],
+ ["tel", "addr-book-edit-tel", "vcard-tel"],
+ ["url", "addr-book-edit-url", "vcard-url"],
+ ["email", "addr-book-edit-email", "#vcard-email tr"],
+ ["bday", "addr-book-edit-bday-anniversary", "vcard-special-date"],
+ ["tz", "addr-book-edit-tz", "vcard-tz", "vcard-add-tz"],
+ ["note", "addr-book-edit-note", "vcard-note", "vcard-add-note"],
+ ["org", "addr-book-edit-org", "vcard-org", "vcard-add-org"],
+ ["x-custom1", "addr-book-edit-custom", "vcard-custom", "vcard-add-custom"],
+ ]) {
+ Assert.ok(
+ vCardEdit.vCardProperties.getFirstEntry(propertyName),
+ `${propertyName} is present.`
+ );
+ let removeButton = abDocument
+ .getElementById(fieldsetId)
+ .querySelector(".remove-property-button");
+
+ removeButton.scrollIntoView({ block: "nearest" });
+ let removeEvent = BrowserTestUtils.waitForEvent(
+ vCardEdit,
+ "vcard-remove-property"
+ );
+ EventUtils.synthesizeMouseAtCenter(removeButton, {}, abWindow);
+ await removeEvent;
+
+ await Assert.ok(
+ !vCardEdit.vCardProperties.getFirstEntry(propertyName),
+ `${propertyName} is removed.`
+ );
+ Assert.equal(
+ vCardEdit.querySelectorAll(propertySelector).length,
+ 0,
+ `All elements representing ${propertyName} are removed.`
+ );
+
+ // For single entries the add button have to be visible again.
+ // Time Zone, Notes, Organizational Properties, Custom Properties
+ if (addButton) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(abDocument.getElementById(addButton)),
+ `Add button for ${propertyName} is visible after remove.`
+ );
+ Assert.equal(
+ abDocument.activeElement.id,
+ addButton,
+ `The focus for ${propertyName} was moved to the add button.`
+ );
+ }
+ }
+
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let editButton = abDocument.getElementById("editButton");
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode(editButton);
+
+ await closeAddressBookWindow();
+ await promiseDirectoryRemoved(book.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_edit_photo.js b/comm/mail/components/addrbook/test/browser/browser_edit_photo.js
new file mode 100644
index 0000000000..0b0da4771d
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_edit_photo.js
@@ -0,0 +1,866 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+const { CardDAVServer } = ChromeUtils.import(
+ "resource://testing-common/CardDAVServer.jsm"
+);
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+async function waitForDialogOpenState(state) {
+ let abWindow = getAddressBookWindow();
+ let dialog = abWindow.document.getElementById("photoDialog");
+ await TestUtils.waitForCondition(
+ () => dialog.open == state,
+ "waiting for photo dialog to change state"
+ );
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+}
+
+async function waitForPreviewChange() {
+ let abWindow = getAddressBookWindow();
+ let preview = abWindow.document.querySelector("#photoDialog svg > image");
+ let oldValue = preview.getAttribute("href");
+ await BrowserTestUtils.waitForEvent(
+ preview,
+ "load",
+ false,
+ () => preview.getAttribute("href") != oldValue
+ );
+ await new Promise(resolve => abWindow.requestAnimationFrame(resolve));
+}
+
+async function waitForPhotoChange() {
+ let abWindow = getAddressBookWindow();
+ let photo = abWindow.document.querySelector("#photoButton .contact-photo");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let oldValue = photo.src;
+ await BrowserTestUtils.waitForMutationCondition(
+ photo,
+ { attributes: true },
+ () => photo.src != oldValue
+ );
+ await new Promise(resolve => abWindow.requestAnimationFrame(resolve));
+ Assert.ok(!dialog.open, "dialog was closed when photo changed");
+}
+
+function dropFile(target, path) {
+ let abWindow = getAddressBookWindow();
+ let file = new FileUtils.File(getTestFilePath(path));
+
+ let dataTransfer = new DataTransfer();
+ dataTransfer.dropEffect = "copy";
+ dataTransfer.mozSetDataAt("application/x-moz-file", file, 0);
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_COPY);
+ dragService.getCurrentSession().dataTransfer = dataTransfer;
+
+ EventUtils.synthesizeDragOver(
+ target,
+ target,
+ [{ type: "application/x-moz-file", data: file }],
+ "copy",
+ abWindow
+ );
+
+ // This make sure that the fake dataTransfer has still the expected drop
+ // effect after the synthesizeDragOver call.
+ dataTransfer.dropEffect = "copy";
+
+ EventUtils.synthesizeDropAfterDragOver(null, dataTransfer, target, abWindow, {
+ _domDispatchOnly: true,
+ });
+
+ dragService.endDragSession(true);
+}
+
+function checkDialogElements({
+ dropTargetClass = "",
+ svgVisible = false,
+ saveButtonVisible = false,
+ saveButtonDisabled = false,
+ discardButtonVisible = false,
+}) {
+ let abWindow = getAddressBookWindow();
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton, discardButton } = dialog;
+ let dropTarget = dialog.querySelector("#photoDropTarget");
+ let svg = dialog.querySelector("svg");
+ Assert.equal(
+ BrowserTestUtils.is_visible(dropTarget),
+ !!dropTargetClass,
+ "drop target visibility"
+ );
+ if (dropTargetClass) {
+ Assert.stringContains(
+ dropTarget.className,
+ dropTargetClass,
+ "drop target message"
+ );
+ }
+ Assert.equal(BrowserTestUtils.is_visible(svg), svgVisible, "SVG visibility");
+ Assert.equal(
+ BrowserTestUtils.is_visible(saveButton),
+ saveButtonVisible,
+ "save button visibility"
+ );
+ Assert.equal(
+ saveButton.disabled,
+ saveButtonDisabled,
+ "save button disabled state"
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(discardButton),
+ discardButtonVisible,
+ "discard button visibility"
+ );
+}
+
+function getInput(entryName, addIfNeeded = false) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ switch (entryName) {
+ case "DisplayName":
+ return abDocument.querySelector("vcard-fn #vCardDisplayName");
+ case "FirstName":
+ return abDocument.querySelector("vcard-n #vcard-n-firstname");
+ case "LastName":
+ return abDocument.querySelector("vcard-n #vcard-n-lastname");
+ case "PrimaryEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 1
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.getElementById("vcard-add-email"),
+ {},
+ abWindow
+ );
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(1) input[type="email"]`
+ );
+ case "SecondEmail":
+ if (
+ addIfNeeded &&
+ abDocument.getElementById("vcard-email").children.length < 2
+ ) {
+ EventUtils.synthesizeMouseAtCenter(
+ abDocument.getElementById("vcard-add-email"),
+ {},
+ abWindow
+ );
+ }
+ return abDocument.querySelector(
+ `#vcard-email tr:nth-child(2) input[type="email"]`
+ );
+ }
+
+ return null;
+}
+
+function setInputValues(changes) {
+ let abWindow = getAddressBookWindow();
+
+ for (let [key, value] of Object.entries(changes)) {
+ let input = getInput(key, !!value);
+ if (!input) {
+ Assert.ok(!value, `${key} input exists to put a value in`);
+ continue;
+ }
+
+ input.select();
+ if (value) {
+ EventUtils.sendString(value);
+ } else {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, abWindow);
+ }
+ }
+ EventUtils.synthesizeKey("VK_TAB", {}, abWindow);
+}
+
+add_setup(async function () {
+ await openAddressBookWindow();
+ openDirectory(personalBook);
+});
+
+registerCleanupFunction(async function cleanUp() {
+ await closeAddressBookWindow();
+ personalBook.deleteCards(personalBook.childCards);
+ await CardDAVServer.close();
+});
+
+/** Create a new contact. We'll add a photo to this contact. */
+async function subtest_add_photo(book) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton } = dialog;
+
+ openDirectory(book);
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // Open the photo dialog by clicking on the photo.
+
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Drop a file on the photo dialog.
+
+ let previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo1.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Accept the photo dialog.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, abWindow);
+ await photoChangePromise;
+ Assert.notEqual(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown"
+ );
+
+ // Save the contact.
+
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ setInputValues({
+ DisplayName: "Person with Photo 1",
+ });
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ // Photo shown in view.
+ Assert.notEqual(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown in contact view"
+ );
+
+ let [card, uid] = await createdPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Create another new contact. This time we'll add a photo, but discard it. */
+async function subtest_dont_add_photo(book) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { saveButton, cancelButton, discardButton } = dialog;
+ let svg = dialog.querySelector("svg");
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ // Drop a file on the photo.
+
+ dropFile(photoButton, "data/photo2.jpg");
+ await waitForDialogOpenState(true);
+ await TestUtils.waitForCondition(() => BrowserTestUtils.is_visible(svg));
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Cancel the photo dialog.
+
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, abWindow);
+ await waitForDialogOpenState(false);
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Drop a file on the photo dialog.
+
+ let previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo1.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Drop another file on the photo dialog.
+
+ previewChangePromise = waitForPreviewChange();
+ dropFile(dialog, "data/photo2.jpg");
+ await previewChangePromise;
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ });
+
+ // Accept the photo dialog.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(saveButton, {}, abWindow);
+ await photoChangePromise;
+ Assert.notEqual(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "a photo is shown"
+ );
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ discardButtonVisible: true,
+ });
+
+ // Click to discard the photo.
+
+ photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(discardButton, {}, abWindow);
+ await photoChangePromise;
+
+ // Open the photo dialog by clicking on the photo.
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, abWindow);
+ await waitForDialogOpenState(false);
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ // Save the contact and check the photo was NOT saved.
+
+ let createdPromise = TestUtils.topicObserved("addrbook-contact-created");
+ setInputValues({
+ DisplayName: "Person with Photo 2",
+ });
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+
+ Assert.equal(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown in contact view"
+ );
+
+ let [card, uid] = await createdPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Go back to the first contact and discard the photo. */
+async function subtest_discard_photo(book, checkPhotoCallback) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let cardsList = abDocument.getElementById("cards");
+ let editButton = abDocument.getElementById("editButton");
+ let saveEditButton = abDocument.getElementById("saveEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let viewPhoto = abDocument.getElementById("viewContactPhoto");
+ let dialog = abWindow.document.getElementById("photoDialog");
+ let { discardButton } = dialog;
+
+ openDirectory(book);
+
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.ok(
+ checkPhotoCallback(viewPhoto.src),
+ "saved photo shown in contact view"
+ );
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ await inEditingMode();
+
+ // Open the photo dialog by clicking on the photo.
+
+ Assert.ok(
+ checkPhotoCallback(editPhoto.src),
+ "saved photo shown in edit view"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ discardButtonVisible: true,
+ });
+
+ // Click to discard the photo.
+
+ let photoChangePromise = waitForPhotoChange();
+ EventUtils.synthesizeMouseAtCenter(discardButton, {}, abWindow);
+ await photoChangePromise;
+
+ // Save the contact and check the photo was removed.
+
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ EventUtils.synthesizeMouseAtCenter(saveEditButton, {}, abWindow);
+ await notInEditingMode();
+ Assert.equal(
+ viewPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "photo no longer shown in contact view"
+ );
+
+ let [card, uid] = await updatedPromise;
+ Assert.equal(uid, book.UID);
+ return card;
+}
+
+/** Check that pasting URLs on photo widgets works. */
+async function subtest_paste_url() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let createContactButton = abDocument.getElementById("toolbarCreateContact");
+ let cancelEditButton = abDocument.getElementById("cancelEditButton");
+ let photoButton = abDocument.getElementById("photoButton");
+ let editPhoto = photoButton.querySelector(".contact-photo");
+ let dropTarget = abDocument.getElementById("photoDropTarget");
+
+ // Start a new contact and focus on the photo button.
+
+ EventUtils.synthesizeMouseAtCenter(createContactButton, {}, abWindow);
+ await inEditingMode();
+
+ Assert.equal(
+ editPhoto.src,
+ "chrome://messenger/skin/icons/new/compact/user.svg",
+ "no photo shown"
+ );
+
+ Assert.equal(abDocument.activeElement.id, "vcard-n-firstname");
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ // Focus is on name prefix button.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement,
+ photoButton,
+ "photo button is focused"
+ );
+
+ // Paste a URL.
+
+ let previewChangePromise = waitForPreviewChange();
+
+ let wrapper1 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper1.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/photo1.jpg";
+ let transfer1 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer1.init(null);
+ transfer1.addDataFlavor("text/plain");
+ transfer1.setTransferData("text/plain", wrapper1);
+ Services.clipboard.setData(transfer1, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await waitForDialogOpenState(true);
+ await previewChangePromise;
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ saveButtonDisabled: false,
+ });
+
+ // Close then reopen the dialog.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Paste a URL.
+
+ previewChangePromise = waitForPreviewChange();
+
+ let wrapper2 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper2.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/photo2.jpg";
+ let transfer2 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer2.init(null);
+ transfer2.addDataFlavor("text/plain");
+ transfer2.setTransferData("text/plain", wrapper2);
+ Services.clipboard.setData(transfer2, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await previewChangePromise;
+ checkDialogElements({
+ svgVisible: true,
+ saveButtonVisible: true,
+ saveButtonDisabled: false,
+ });
+
+ // Close then reopen the dialog.
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(photoButton, {}, abWindow);
+ await waitForDialogOpenState(true);
+ checkDialogElements({
+ dropTargetClass: "drop-target",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ // Paste an invalid URL.
+
+ let wrapper3 = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wrapper3.data =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/fake.jpg";
+ let transfer3 = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ transfer3.init(null);
+ transfer3.addDataFlavor("text/plain");
+ transfer3.setTransferData("text/plain", wrapper3);
+ Services.clipboard.setData(transfer3, null, Ci.nsIClipboard.kGlobalClipboard);
+ EventUtils.synthesizeKey("v", { accelKey: true }, abWindow);
+
+ await TestUtils.waitForCondition(() =>
+ dropTarget.classList.contains("drop-error")
+ );
+
+ checkDialogElements({
+ dropTargetClass: "drop-error",
+ saveButtonVisible: true,
+ saveButtonDisabled: true,
+ });
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ await waitForDialogOpenState(false);
+
+ EventUtils.synthesizeMouseAtCenter(cancelEditButton, {}, abWindow);
+ await notInEditingMode();
+}
+
+/** Test photo operations with a local address book. */
+add_task(async function test_local() {
+ // Create a new contact. We'll add a photo to this contact.
+
+ let card1 = await subtest_add_photo(personalBook);
+ let photo1Name = card1.getProperty("PhotoName", "");
+ Assert.ok(photo1Name, "PhotoName property saved on card");
+
+ let photo1Path = PathUtils.join(profileDir, "Photos", photo1Name);
+ let photo1File = new FileUtils.File(photo1Path);
+ Assert.ok(photo1File.exists(), "photo saved to disk");
+
+ let image = new Image();
+ let loadedPromise = BrowserTestUtils.waitForEvent(image, "load");
+ image.src = Services.io.newFileURI(photo1File).spec;
+ await loadedPromise;
+
+ Assert.equal(image.naturalWidth, 300, "photo saved at correct width");
+ Assert.equal(image.naturalHeight, 300, "photo saved at correct height");
+
+ // Create another new contact. This time we'll add a photo, but discard it.
+
+ let card2 = await subtest_dont_add_photo(personalBook);
+ Assert.equal(
+ card2.getProperty("PhotoName", "NO VALUE"),
+ "NO VALUE",
+ "PhotoName property not saved on card"
+ );
+
+ // Go back to the first contact and discard the photo.
+
+ let card3 = await subtest_discard_photo(personalBook, src =>
+ src.endsWith(photo1Name)
+ );
+ Assert.equal(
+ card3.getProperty("PhotoName", "NO VALUE"),
+ "NO VALUE",
+ "PhotoName property removed from card"
+ );
+ Assert.ok(
+ !new FileUtils.File(photo1Path).exists(),
+ "photo removed from disk"
+ );
+
+ // Check that pasting URLs on photo widgets works.
+
+ await subtest_paste_url(personalBook);
+});
+
+/**
+ * Test photo operations with a CardDAV address book and a server that only
+ * speaks vCard 3, i.e. Google.
+ */
+add_task(async function test_add_photo_carddav3() {
+ // Set up the server, address book and password.
+
+ CardDAVServer.open("alice", "alice");
+ CardDAVServer.mimicGoogle = true;
+
+ let book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "alice");
+ book.setBoolValue("carddav.vcard3", true);
+ book.wrappedJSObject._isGoogleCardDAV = true;
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "alice", "alice", "", "");
+ Services.logins.addLogin(loginInfo);
+
+ // Create a new contact. We'll add a photo to this contact.
+
+ // This notification fires when we retrieve the saved card from the server,
+ // which happens before subtest_add_photo finishes.
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ let card1 = await subtest_add_photo(book);
+ Assert.equal(
+ card1.getProperty("PhotoName", "RIGHT"),
+ "RIGHT",
+ "PhotoName property not saved on card"
+ );
+
+ // Check the card we sent.
+ let photoProp = card1.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card1.vCardProperties.designSet === ICAL.design.vcard3);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, "B");
+ Assert.equal(photoProp.type, "binary");
+ Assert.ok(photoProp.value.startsWith("/9j/"));
+
+ // Check the card we received from the server. If the server didn't like it,
+ // the photo will be removed and this will fail.
+ let [card2] = await updatedPromise;
+ photoProp = card2.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card2.vCardProperties.designSet === ICAL.design.vcard3);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, "B");
+ Assert.equal(photoProp.type, "binary");
+ Assert.ok(photoProp.value.startsWith("/9j/"));
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ let [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ serverCard.vCard.includes("\nPHOTO;ENCODING=B:/9j/"),
+ "photo included in card on server"
+ );
+
+ // Discard the photo.
+
+ let card3 = await subtest_discard_photo(book, src =>
+ src.startsWith("data:image/jpeg;base64,/9j/")
+ );
+
+ // Check the card we sent.
+ Assert.equal(card3.vCardProperties.getFirstEntry("photo"), null);
+
+ // This notification is the second of two, and fires when we retrieve the
+ // saved card from the server, which doesn't happen before
+ // subtest_discard_photo finishes.
+ let [card4] = await TestUtils.topicObserved("addrbook-contact-updated");
+ Assert.equal(card4.vCardProperties.getFirstEntry("photo"), null);
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ !serverCard.vCard.includes("PHOTO:"),
+ "photo removed from card on server"
+ );
+
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.mimicGoogle = false;
+ CardDAVServer.close();
+ CardDAVServer.reset();
+});
+
+/**
+ * Test photo operations with a CardDAV address book and a server that can
+ * handle vCard 4.
+ */
+add_task(async function test_add_photo_carddav4() {
+ // Set up the server, address book and password.
+
+ CardDAVServer.open("bob", "bob");
+
+ let book = createAddressBook(
+ "CardDAV Book",
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE
+ );
+ book.setIntValue("carddav.syncinterval", 0);
+ book.setStringValue("carddav.url", CardDAVServer.url);
+ book.setStringValue("carddav.username", "bob");
+
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ loginInfo.init(CardDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+
+ // Create a new contact. We'll add a photo to this contact.
+
+ // This notification fires when we retrieve the saved card from the server,
+ // which happens before subtest_add_photo finishes.
+ let updatedPromise = TestUtils.topicObserved("addrbook-contact-updated");
+ let card1 = await subtest_add_photo(book);
+ Assert.equal(
+ card1.getProperty("PhotoName", "RIGHT"),
+ "RIGHT",
+ "PhotoName property not saved on card"
+ );
+
+ // Check the card we sent.
+ let photoProp = card1.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card1.vCardProperties.designSet === ICAL.design.vcard);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, undefined);
+ Assert.equal(photoProp.type, "uri");
+ Assert.ok(photoProp.value.startsWith("data:image/jpeg;base64,/9j/"));
+
+ // Check the card we received from the server.
+ let [card2] = await updatedPromise;
+ photoProp = card2.vCardProperties.getFirstEntry("photo");
+ Assert.ok(card2.vCardProperties.designSet === ICAL.design.vcard);
+ Assert.ok(photoProp);
+ Assert.equal(photoProp.params.encoding, undefined);
+ Assert.equal(photoProp.type, "uri");
+ Assert.ok(photoProp.value.startsWith("data:image/jpeg;base64,/9j/"));
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ let [serverCard] = [...CardDAVServer.cards.values()];
+ Assert.ok(
+ serverCard.vCard.includes("\nPHOTO:data:image/jpeg;base64\\,/9j/"),
+ "photo included in card on server"
+ );
+
+ // Discard the photo.
+
+ let card3 = await subtest_discard_photo(book, src =>
+ src.startsWith("data:image/jpeg;base64,/9j/")
+ );
+
+ // Check the card we sent.
+ Assert.equal(card3.vCardProperties.getFirstEntry("photo"), null);
+
+ // This notification is the second of two, and fires when we retrieve the
+ // saved card from the server, which doesn't happen before
+ // subtest_discard_photo finishes.
+ let [card4] = await TestUtils.topicObserved("addrbook-contact-updated");
+ Assert.equal(card4.vCardProperties.getFirstEntry("photo"), null);
+
+ // Check the card on the server.
+ Assert.equal(CardDAVServer.cards.size, 1);
+ [serverCard] = [...CardDAVServer.cards.values()];
+ console.log(serverCard.vCard);
+ Assert.ok(
+ !serverCard.vCard.includes("PHOTO:"),
+ "photo removed from card on server"
+ );
+
+ await promiseDirectoryRemoved(book.URI);
+ CardDAVServer.close();
+ CardDAVServer.reset();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_ldap_search.js b/comm/mail/components/addrbook/test/browser/browser_ldap_search.js
new file mode 100644
index 0000000000..6eb7322bb4
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_ldap_search.js
@@ -0,0 +1,180 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+const jsonFile =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/ldap_contacts.json";
+
+add_task(async () => {
+ function waitForCountChange(expectedCount) {
+ return new Promise(resolve => {
+ cardsList.addEventListener("rowcountchange", function onRowCountChange() {
+ console.log(cardsList.view.rowCount, expectedCount);
+ if (cardsList.view.rowCount == expectedCount) {
+ cardsList.removeEventListener("rowcountchange", onRowCountChange);
+ resolve();
+ }
+ });
+ });
+ }
+
+ // Set up some local people.
+
+ let cardsToRemove = [];
+ for (let name of ["daniel", "jonathan", "nathan"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = personalBook.addCard(card);
+ cardsToRemove.push(card);
+ }
+
+ // Set up the LDAP server.
+
+ LDAPServer.open();
+ let response = await fetch(jsonFile);
+ let ldapContacts = await response.json();
+
+ let bookPref = MailServices.ab.newAddressBook(
+ "Mochitest",
+ `ldap://localhost:${LDAPServer.port}/`,
+ 0
+ );
+ let book = MailServices.ab.getDirectoryFromId(bookPref);
+
+ let abWindow = await openAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let searchBox = abDocument.getElementById("searchInput");
+ let cardsList = abWindow.cardsPane.cardsList;
+ let noSearchResults = abDocument.getElementById("placeholderNoSearchResults");
+ let detailsPane = abDocument.getElementById("detailsPane");
+
+ // Search for some people in the LDAP directory.
+
+ openDirectory(book);
+ checkPlaceholders(["placeholderSearchOnly"]);
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.sendString("holmes", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.mycroft);
+ LDAPServer.writeSearchResultEntry(ldapContacts.sherlock);
+ LDAPServer.writeSearchResultDone();
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ await waitForCountChange(2);
+ checkNamesListed("Mycroft Holmes", "Sherlock Holmes");
+ checkPlaceholders();
+
+ // Check that displaying an LDAP card works without error.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(detailsPane)
+ );
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("john", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("John Watson");
+ checkPlaceholders();
+
+ // Now move back to the "All Address Books" view and search again.
+ // The search string is retained when switching books.
+
+ openAllAddressBooks();
+ checkNamesListed();
+ Assert.equal(searchBox.value, "john");
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("John Watson");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("irene", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.irene);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(1);
+ checkNamesListed("Irene Adler");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("jo", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed("jonathan");
+ checkPlaceholders();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry(ldapContacts.john);
+ LDAPServer.writeSearchResultDone();
+
+ await waitForCountChange(2);
+ checkNamesListed("John Watson", "jonathan");
+ checkPlaceholders();
+
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString("mark", abWindow);
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+ checkNamesListed();
+ checkPlaceholders(["placeholderSearching"]);
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultDone();
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(noSearchResults)
+ );
+ checkNamesListed();
+ checkPlaceholders(["placeholderNoSearchResults"]);
+
+ await closeAddressBookWindow();
+ personalBook.deleteCards(cardsToRemove);
+ await promiseDirectoryRemoved(book.URI);
+ LDAPServer.close();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js b/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js
new file mode 100644
index 0000000000..64d679ec13
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_mailing_lists.js
@@ -0,0 +1,474 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals MailServices, MailUtils */
+
+var { DisplayNameUtils } = ChromeUtils.import(
+ "resource:///modules/DisplayNameUtils.jsm"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const inputs = {
+ abName: "Mochitest Address Book",
+ mlName: "Mochitest Mailing List",
+ nickName: "Nicky",
+ description: "Just a test mailing list.",
+ addresses: [
+ "alan@example.com",
+ "betty@example.com",
+ "clyde@example.com",
+ "deb@example.com",
+ ],
+ modification: " (modified)",
+};
+
+const getDisplayedAddress = address => `${address} <${address}>`;
+
+let global = {};
+
+/**
+ * Set up: create a new address book to hold the mailing list.
+ */
+add_task(async () => {
+ let bookPrefName = MailServices.ab.newAddressBook(
+ inputs.abName,
+ null,
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let addressBook = MailServices.ab.getDirectoryFromId(bookPrefName);
+
+ let abWindow = await openAddressBookWindow();
+
+ global = {
+ abWindow,
+ addressBook,
+ booksList: abWindow.booksList,
+ mailListUID: undefined,
+ };
+});
+
+/**
+ * Create a new mailing list with some addresses, in the new address book.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ ).then(async function (mlWindow) {
+ let mlDocument = mlWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ let listName = mlDocument.getElementById("ListName");
+ if (mlDocument.activeElement != listName) {
+ await BrowserTestUtils.waitForEvent(listName, "focus");
+ }
+
+ let abPopup = mlDocument.getElementById("abPopup");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInputsCount = mlDocument
+ .getElementById("addressingWidget")
+ .querySelectorAll("input").length;
+
+ Assert.equal(
+ abPopup.label,
+ global.addressBook.dirName,
+ "the correct address book is selected in the menu"
+ );
+ Assert.equal(
+ abPopup.value,
+ global.addressBook.URI,
+ "the address book selected in the menu has the correct address book URI"
+ );
+ Assert.equal(listName.value, "", "no text in the list name field");
+ Assert.equal(listNickName.value, "", "no text in the list nickname field");
+ Assert.equal(listDescription.value, "", "no text in the description field");
+ Assert.equal(addressInput1.value, "", "no text in the addresses list");
+ Assert.equal(addressInputsCount, 1, "only one address list input exists");
+
+ EventUtils.sendString(inputs.mlName, mlWindow);
+
+ // Tab to nickname input.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.nickName, mlWindow);
+
+ // Tab to description input.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.description, mlWindow);
+
+ // Tab to address input and add addresses zero and one by entering
+ // both of them there.
+ EventUtils.sendKey("TAB", mlWindow);
+ EventUtils.sendString(inputs.addresses.slice(0, 2).join(", "), mlWindow);
+
+ mlDocElement.getButton("accept").click();
+ });
+
+ // Select the address book.
+ openDirectory(global.addressBook);
+
+ // Open the new mailing list dialog, the callback above interacts with it.
+ EventUtils.synthesizeMouseAtCenter(
+ global.abWindow.document.getElementById("toolbarCreateList"),
+ { clickCount: 1 },
+ global.abWindow
+ );
+
+ await mailingListWindowPromise;
+
+ // Confirm that the mailing list and addresses were saved in the backend.
+
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[0]),
+ "address zero was saved"
+ );
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[1]),
+ "address one was saved"
+ );
+
+ let childCards = global.addressBook.childCards;
+
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[0]),
+ "address zero was saved in the correct address book"
+ );
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[1]),
+ "address one was saved in the correct address book"
+ );
+
+ let mailList = MailUtils.findListInAddressBooks(inputs.mlName);
+
+ // Save the mailing list UID so we can confirm it is the same later.
+ global.mailListUID = mailList.UID;
+
+ Assert.ok(mailList, "mailing list was created");
+ Assert.ok(
+ global.addressBook.hasMailListWithName(inputs.mlName),
+ "mailing list was created in the correct address book"
+ );
+ Assert.equal(mailList.dirName, inputs.mlName, "mailing list name was saved");
+ Assert.equal(
+ mailList.listNickName,
+ inputs.nickName,
+ "mailing list nick name was saved"
+ );
+ Assert.equal(
+ mailList.description,
+ inputs.description,
+ "mailing list description was saved"
+ );
+
+ let listCards = mailList.childCards;
+ Assert.equal(listCards.length, 2, "two cards exist in the mailing list");
+ Assert.ok(
+ listCards[0].hasEmailAddress(inputs.addresses[0]),
+ "address zero was saved in the mailing list"
+ );
+ Assert.ok(
+ listCards[1].hasEmailAddress(inputs.addresses[1]),
+ "address one was saved in the mailing list"
+ );
+});
+
+/**
+ * Open the mailing list dialog and modify the mailing list.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (mlWindow) {
+ let mlDocument = mlWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ if (!mlDocument.getElementById("addressCol1#3")) {
+ // The address input nodes are not there yet when the dialog window is
+ // loaded, so wait until they exist.
+ await mailTestUtils.awaitElementExistence(
+ MutationObserver,
+ mlDocument,
+ "addressingWidget",
+ "addressCol1#3"
+ );
+ }
+
+ if (mlDocument.activeElement.id != "addressCol1#3") {
+ await BrowserTestUtils.waitForEvent(
+ mlDocument.getElementById("addressCol1#3"),
+ "focus"
+ );
+ }
+
+ let listName = mlDocument.getElementById("ListName");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInput2 = mlDocument.getElementById("addressCol1#2");
+
+ Assert.equal(
+ listName.value,
+ inputs.mlName,
+ "list name is displayed correctly"
+ );
+ Assert.equal(
+ listNickName.value,
+ inputs.nickName,
+ "list nickname is displayed correctly"
+ );
+ Assert.equal(
+ listDescription.value,
+ inputs.description,
+ "list description is displayed correctly"
+ );
+ Assert.equal(
+ addressInput1 && addressInput1.value,
+ getDisplayedAddress(inputs.addresses[0]),
+ "address zero is displayed correctly"
+ );
+ Assert.equal(
+ addressInput2 && addressInput2.value,
+ getDisplayedAddress(inputs.addresses[1]),
+ "address one is displayed correctly"
+ );
+
+ let textInputs = mlDocument.querySelectorAll(".textbox-addressingWidget");
+ Assert.equal(textInputs.length, 3, "no extraneous addresses are displayed");
+
+ // Add addresses two and three.
+ EventUtils.sendString(inputs.addresses.slice(2, 4).join(", "), mlWindow);
+ EventUtils.sendKey("RETURN", mlWindow);
+ await new Promise(resolve => mlWindow.setTimeout(resolve));
+
+ // Delete the address in the second row (address one).
+ EventUtils.synthesizeMouseAtCenter(
+ addressInput2,
+ { clickCount: 1 },
+ mlWindow
+ );
+ EventUtils.synthesizeKey("a", { accelKey: true }, mlWindow);
+ EventUtils.sendKey("BACK_SPACE", mlWindow);
+
+ // Modify the list's name, nick name, and description fields.
+ let modifyField = id => {
+ id.focus();
+ EventUtils.sendKey("DOWN", mlWindow);
+ EventUtils.sendString(inputs.modification, mlWindow);
+ };
+ modifyField(listName);
+ modifyField(listNickName);
+ modifyField(listDescription);
+
+ mlDocElement.getButton("accept").click();
+ });
+
+ // Open the mailing list dialog, the callback above interacts with it.
+ global.booksList.selectedIndex = 3;
+ global.booksList.showPropertiesOfSelected();
+
+ await mailingListWindowPromise;
+
+ // Confirm that the mailing list and addresses were saved in the backend.
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(3).querySelector("span").textContent,
+ inputs.mlName + inputs.modification,
+ `mailing list ("${
+ inputs.mlName + inputs.modification
+ }") is displayed in the address book list`
+ );
+
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[2]),
+ "address two was saved"
+ );
+ Assert.ok(
+ MailServices.ab.cardForEmailAddress(inputs.addresses[3]),
+ "address three was saved"
+ );
+
+ let childCards = global.addressBook.childCards;
+
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[2]),
+ "address two was saved in the correct address book"
+ );
+ Assert.ok(
+ childCards.find(card => card.primaryEmail == inputs.addresses[3]),
+ "address three was saved in the correct address book"
+ );
+
+ let mailList = MailUtils.findListInAddressBooks(
+ inputs.mlName + inputs.modification
+ );
+
+ Assert.equal(
+ mailList && mailList.UID,
+ global.mailListUID,
+ "mailing list still exists"
+ );
+
+ Assert.ok(
+ global.addressBook.hasMailListWithName(inputs.mlName + inputs.modification),
+ "mailing list is still in the correct address book"
+ );
+ Assert.equal(
+ mailList.dirName,
+ inputs.mlName + inputs.modification,
+ "modified mailing list name was saved"
+ );
+ Assert.equal(
+ mailList.listNickName,
+ inputs.nickName + inputs.modification,
+ "modified mailing list nick name was saved"
+ );
+ Assert.equal(
+ mailList.description,
+ inputs.description + inputs.modification,
+ "modified mailing list description was saved"
+ );
+
+ let listCards = mailList.childCards;
+
+ Assert.equal(listCards.length, 3, "three cards exist in the mailing list");
+
+ Assert.ok(
+ listCards[0].hasEmailAddress(inputs.addresses[0]),
+ "address zero was saved in the mailing list (is still there)"
+ );
+ Assert.ok(
+ listCards[1].hasEmailAddress(inputs.addresses[2]),
+ "address two was saved in the mailing list"
+ );
+ Assert.ok(
+ listCards[2].hasEmailAddress(inputs.addresses[3]),
+ "address three was saved in the mailing list"
+ );
+
+ let hasAddressOne = listCards.find(card =>
+ card.hasEmailAddress(inputs.addresses[1])
+ );
+
+ Assert.ok(!hasAddressOne, "address one was deleted from the mailing list");
+});
+
+/**
+ * Open the mailing list dialog and confirm the changes are displayed.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abEditListDialog.xhtml"
+ ).then(async function (mailingListWindow) {
+ let mlDocument = mailingListWindow.document;
+ let mlDocElement = mlDocument.querySelector("dialog");
+
+ if (!mlDocument.getElementById("addressCol1#4")) {
+ // The address input nodes are not there yet when the dialog window is
+ // loaded, so wait until they exist.
+ await mailTestUtils.awaitElementExistence(
+ MutationObserver,
+ mlDocument,
+ "addressingWidget",
+ "addressCol1#4"
+ );
+ }
+
+ if (mlDocument.activeElement.id != "addressCol1#4") {
+ await BrowserTestUtils.waitForEvent(
+ mlDocument.getElementById("addressCol1#4"),
+ "focus"
+ );
+ }
+
+ let listName = mlDocument.getElementById("ListName");
+ let listNickName = mlDocument.getElementById("ListNickName");
+ let listDescription = mlDocument.getElementById("ListDescription");
+ let addressInput1 = mlDocument.getElementById("addressCol1#1");
+ let addressInput2 = mlDocument.getElementById("addressCol1#2");
+ let addressInput3 = mlDocument.getElementById("addressCol1#3");
+
+ Assert.equal(
+ listName.value,
+ inputs.mlName + inputs.modification,
+ "modified list name is displayed correctly"
+ );
+ Assert.equal(
+ listNickName.value,
+ inputs.nickName + inputs.modification,
+ "modified list nickname is displayed correctly"
+ );
+ Assert.equal(
+ listDescription.value,
+ inputs.description + inputs.modification,
+ "modified list description is displayed correctly"
+ );
+ Assert.equal(
+ addressInput1 && addressInput1.value,
+ getDisplayedAddress(inputs.addresses[0]),
+ "address zero is displayed correctly (is still there)"
+ );
+ Assert.equal(
+ addressInput2 && addressInput2.value,
+ getDisplayedAddress(inputs.addresses[2]),
+ "address two is displayed correctly"
+ );
+ Assert.equal(
+ addressInput3 && addressInput3.value,
+ getDisplayedAddress(inputs.addresses[3]),
+ "address three is displayed correctly"
+ );
+
+ let textInputs = mlDocument.querySelectorAll(".textbox-addressingWidget");
+ Assert.equal(textInputs.length, 4, "no extraneous addresses are displayed");
+
+ mlDocElement.getButton("cancel").click();
+ });
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(3).querySelector("span").textContent,
+ inputs.mlName + inputs.modification,
+ `mailing list ("${
+ inputs.mlName + inputs.modification
+ }") is still displayed in the address book list`
+ );
+
+ // Open the mailing list dialog, the callback above interacts with it.
+ global.booksList.selectedIndex = 3;
+ global.booksList.showPropertiesOfSelected();
+
+ await mailingListWindowPromise;
+});
+
+/**
+ * Tear down: delete the address book and close the address book window.
+ */
+add_task(async () => {
+ let mailingListWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ "accept",
+ "chrome://global/content/commonDialog.xhtml"
+ );
+ let deletePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+
+ Assert.equal(
+ global.booksList.getRowAtIndex(2).querySelector("span").textContent,
+ inputs.abName,
+ `address book ("${inputs.abName}") is displayed in the address book list`
+ );
+
+ global.booksList.focus();
+ global.booksList.selectedIndex = 2;
+ EventUtils.sendKey("DELETE", global.abWindow);
+
+ await Promise.all([mailingListWindowPromise, deletePromise]);
+
+ let addressBook = MailServices.ab.directories.find(
+ directory => directory.dirName == inputs.abName
+ );
+
+ Assert.ok(!addressBook, "address book was deleted");
+
+ closeAddressBookWindow();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_open_actions.js b/comm/mail/components/addrbook/test/browser/browser_open_actions.js
new file mode 100644
index 0000000000..cb6f681ec8
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_open_actions.js
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let tabmail = document.getElementById("tabmail");
+let writableBook, writableCard, readOnlyBook, readOnlyCard;
+
+add_setup(function () {
+ writableBook = createAddressBook("writable book");
+ writableCard = writableBook.addCard(createContact("writable", "card"));
+
+ readOnlyBook = createAddressBook("read-only book");
+ readOnlyCard = readOnlyBook.addCard(createContact("read-only", "card"));
+ readOnlyBook.setBoolValue("readOnly", true);
+
+ registerCleanupFunction(async function () {
+ await promiseDirectoryRemoved(writableBook.URI);
+ await promiseDirectoryRemoved(readOnlyBook.URI);
+ });
+});
+
+async function inEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => abWindow.detailsPane.isEditing,
+ "entering editing mode"
+ );
+}
+
+async function notInEditingMode() {
+ let abWindow = getAddressBookWindow();
+ await TestUtils.waitForCondition(
+ () => !abWindow.detailsPane.isEditing,
+ "leaving editing mode"
+ );
+}
+
+/**
+ * Tests than a `toAddressBook` call with no argument opens the Address Book.
+ * Then call it again with the tab open and check that it doesn't reload.
+ */
+add_task(async function testNoAction() {
+ let abWindow1 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ await notInEditingMode();
+
+ let abWindow2 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ Assert.equal(
+ abWindow2.browsingContext.currentWindowGlobal.innerWindowId,
+ abWindow1.browsingContext.currentWindowGlobal.innerWindowId,
+ "address book page did not reload"
+ );
+ await notInEditingMode();
+
+ tabmail.selectTabByIndex(undefined, 1);
+ let abWindow3 = await window.toAddressBook();
+ Assert.equal(tabmail.tabInfo.length, 2);
+ Assert.equal(tabmail.currentTabInfo.mode.name, "addressBookTab");
+ Assert.equal(
+ abWindow3.browsingContext.currentWindowGlobal.innerWindowId,
+ abWindow1.browsingContext.currentWindowGlobal.innerWindowId,
+ "address book page did not reload"
+ );
+ await notInEditingMode();
+
+ await closeAddressBookWindow();
+ Assert.equal(tabmail.tabInfo.length, 1);
+});
+
+/**
+ * Tests than a call to toAddressBook with only a create action opens the
+ * Address Book. A new blank card should open in edit mode.
+ */
+add_task(async function testCreateBlank() {
+ await window.toAddressBook({ action: "create" });
+ await inEditingMode();
+ // TODO check blank
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a create action and an email
+ * address opens the Address Book. A new card with the email address should
+ * open in edit mode.
+ */
+add_task(async function testCreateWithAddress() {
+ await window.toAddressBook({ action: "create", address: "test@invalid" });
+ await inEditingMode();
+ // TODO check address matches
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a create action and a vCard opens
+ * the Address Book. A new card should open in edit mode.
+ */
+add_task(async function testCreateWithVCard() {
+ await window.toAddressBook({
+ action: "create",
+ vCard:
+ "BEGIN:VCARD\r\nFN:a test person\r\nN:person;test;;a;\r\nEND:VCARD\r\n",
+ });
+ await inEditingMode();
+ // TODO check card matches
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with a display action opens the Address
+ * Book. The card should be displayed.
+ */
+add_task(async function testDisplayCard() {
+ await window.toAddressBook({ action: "display", card: writableCard });
+ checkDirectoryDisplayed(writableBook);
+ await notInEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "writable contact");
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with an edit action and a writable card
+ * opens the Address Book. The card should open in edit mode.
+ */
+add_task(async function testEditCardWritable() {
+ await window.toAddressBook({ action: "edit", card: writableCard });
+ checkDirectoryDisplayed(writableBook);
+ await inEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "writable contact");
+
+ await closeAddressBookWindow();
+});
+
+/**
+ * Tests than a call to toAddressBook with an edit action and a read-only card
+ * opens the Address Book. The card should open in display mode.
+ */
+add_task(async function testEditCardReadOnly() {
+ await window.toAddressBook({ action: "edit", card: readOnlyCard });
+ checkDirectoryDisplayed(readOnlyBook);
+ await notInEditingMode();
+
+ // let abWindow = getAddressBookWindow();
+ // let h1 = abWindow.document.querySelector("h1");
+ // Assert.equal(h1.textContent, "read-only contact");
+
+ await closeAddressBookWindow();
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_search.js b/comm/mail/components/addrbook/test/browser/browser_search.js
new file mode 100644
index 0000000000..ab4f7a221f
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_search.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ async function doSearch(searchString, ...expectedCards) {
+ let viewChangePromise = BrowserTestUtils.waitForEvent(
+ cardsList,
+ "viewchange"
+ );
+ EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow);
+ if (searchString) {
+ EventUtils.synthesizeKey("a", { accelKey: true }, abWindow);
+ EventUtils.sendString(searchString, abWindow);
+ EventUtils.synthesizeKey("VK_RETURN", {}, abWindow);
+ } else {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, abWindow);
+ }
+
+ await viewChangePromise;
+ checkCardsListed(...expectedCards);
+ checkPlaceholders(
+ expectedCards.length ? [] : ["placeholderNoSearchResults"]
+ );
+ }
+
+ let cards = {};
+ let cardsToRemove = {
+ personal: [],
+ history: [],
+ };
+ for (let name of ["daniel", "jonathan", "nathan"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = personalBook.addCard(card);
+ cards[name] = card;
+ cardsToRemove.personal.push(card);
+ }
+ for (let name of ["danielle", "katherine", "natalie", "susanah"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = name;
+
+ card = historyBook.addCard(card);
+ cards[name] = card;
+ cardsToRemove.history.push(card);
+ }
+
+ let abWindow = await openAddressBookWindow();
+
+ registerCleanupFunction(() => {
+ abWindow.close();
+ personalBook.deleteCards(cardsToRemove.personal);
+ historyBook.deleteCards(cardsToRemove.history);
+ });
+
+ let abDocument = abWindow.document;
+ let searchBox = abDocument.getElementById("searchInput");
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ Assert.equal(
+ abDocument.activeElement,
+ searchBox,
+ "search box was focused when the page loaded"
+ );
+
+ // All address books.
+
+ checkCardsListed(
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+ checkPlaceholders();
+
+ // Personal address book.
+
+ openDirectory(personalBook);
+ checkCardsListed(cards.daniel, cards.jonathan, cards.nathan);
+ checkPlaceholders();
+
+ await doSearch("daniel", cards.daniel);
+ await doSearch("nathan", cards.jonathan, cards.nathan);
+
+ // History address book.
+
+ openDirectory(historyBook);
+ checkCardsListed();
+ checkPlaceholders(["placeholderNoSearchResults"]);
+
+ await doSearch(
+ null,
+ cards.danielle,
+ cards.katherine,
+ cards.natalie,
+ cards.susanah
+ );
+
+ await doSearch("daniel", cards.danielle);
+ await doSearch("nathan");
+
+ // All address books.
+
+ openAllAddressBooks();
+ checkCardsListed(cards.jonathan, cards.nathan);
+ checkPlaceholders();
+
+ await doSearch(
+ null,
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+
+ await doSearch("daniel", cards.daniel, cards.danielle);
+ await doSearch("nathan", cards.jonathan, cards.nathan);
+ await doSearch(
+ null,
+ cards.daniel,
+ cards.danielle,
+ cards.jonathan,
+ cards.katherine,
+ cards.natalie,
+ cards.nathan,
+ cards.susanah
+ );
+});
diff --git a/comm/mail/components/addrbook/test/browser/browser_telemetry.js b/comm/mail/components/addrbook/test/browser/browser_telemetry.js
new file mode 100644
index 0000000000..36b73207c2
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/browser_telemetry.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test telemetry related to address book.
+ */
+
+let { MailTelemetryForTests } = ChromeUtils.import(
+ "resource:///modules/MailGlue.jsm"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Test we're counting address books and contacts.
+ */
+add_task(async function test_address_book_count() {
+ Services.telemetry.clearScalars();
+
+ // Adding some address books and contracts.
+ let addrBook1 = createAddressBook("AB 1");
+ let addrBook2 = createAddressBook("AB 2");
+ let ldapBook = createAddressBook(
+ "LDAP Book",
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ let contact1 = createContact("test1", "example");
+ let contact2 = createContact("test2", "example");
+ let contact3 = createContact("test3", "example");
+ addrBook1.addCard(contact1);
+ addrBook2.addCard(contact2);
+ addrBook2.addCard(contact3);
+
+ // Run the probe.
+ MailTelemetryForTests.reportAddressBookTypes();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.addressbook.addressbook_count"]["moz-abldapdirectory"],
+ 1,
+ "LDAP address book count must be correct"
+ );
+ Assert.equal(
+ scalars["tb.addressbook.addressbook_count"].jsaddrbook,
+ 4,
+ "JS address book count must be correct"
+ );
+ Assert.equal(
+ scalars["tb.addressbook.contact_count"].jsaddrbook,
+ 3,
+ "Contact count must be correct"
+ );
+
+ await promiseDirectoryRemoved(addrBook1.URI);
+ await promiseDirectoryRemoved(addrBook2.URI);
+ await promiseDirectoryRemoved(ldapBook.URI);
+});
diff --git a/comm/mail/components/addrbook/test/browser/data/addressbook.sjs b/comm/mail/components/addrbook/test/browser/data/addressbook.sjs
new file mode 100644
index 0000000000..bd28437261
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/addressbook.sjs
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <getetag/>
+ // <getctag/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:"
+ xmlns:card="urn:ietf:params:xml:ns:carddav"
+ xmlns:cs="http://calendarserver.org/ns/">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <card:addressbook/>
+ </resourcetype>
+ <cs:getctag>0</cs:getctag>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <getetag/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs b/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs
new file mode 100644
index 0000000000..0380dee3ab
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-privilege-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <displayname>Things found by DNS</displayname>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbook.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <card:addressbook/>
+ </resourcetype>
+ <displayname>You found me!</displayname>
+ <current-user-privilege-set>
+ <privilege>
+ <all/>
+ </privilege>
+ </current-user-privilege-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs b/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs
new file mode 100644
index 0000000000..640d2acc54
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Echoes request headers as JSON so a test can check what was sent.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setHeader("Content-Type", "application/json", false);
+
+ let headers = {};
+ let enumerator = request.headers;
+ while (enumerator.hasMoreElements()) {
+ let header = enumerator.getNext().data;
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/dns.sjs b/comm/mail/components/addrbook/test/browser/data/dns.sjs
new file mode 100644
index 0000000000..11121cce7c
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/dns.sjs
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-principal/>
+ // <current-user-privilege-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/dns.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <current-user-principal>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/principal.sjs</href>
+ </current-user-principal>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-principal/>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/photo1.jpg b/comm/mail/components/addrbook/test/browser/data/photo1.jpg
new file mode 100644
index 0000000000..35608787bf
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/photo1.jpg
Binary files differ
diff --git a/comm/mail/components/addrbook/test/browser/data/photo2.jpg b/comm/mail/components/addrbook/test/browser/data/photo2.jpg
new file mode 100644
index 0000000000..41fd1e90fc
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/photo2.jpg
Binary files differ
diff --git a/comm/mail/components/addrbook/test/browser/data/principal.sjs b/comm/mail/components/addrbook/test/browser/data/principal.sjs
new file mode 100644
index 0000000000..659cd3cd91
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/principal.sjs
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <addressbook-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <response>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/principal.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <principal/>
+ </resourcetype>
+ <card:addressbook-home-set>
+ <href>/browser/comm/mail/components/addrbook/test/browser/data/addressbooks.sjs</href>
+ </card:addressbook-home-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs b/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs
new file mode 100644
index 0000000000..a9285c21d0
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the authorisation endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams", "URL"]);
+
+function handleRequest(request, response) {
+ let params = new URLSearchParams(request.queryString);
+
+ if (request.method == "POST") {
+ response.setStatusLine(request.httpVersion, 303, "Redirected");
+ } else {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ }
+
+ let url = new URL(params.get("redirect_uri"));
+ url.searchParams.set("code", "success");
+ response.setHeader("Location", url.href);
+}
diff --git a/comm/mail/components/addrbook/test/browser/data/token.sjs b/comm/mail/components/addrbook/test/browser/data/token.sjs
new file mode 100644
index 0000000000..e070f8d55f
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/data/token.sjs
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the token endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(request.bodyInputStream);
+
+ let input = stream.readBytes(request.bodyInputStream.available());
+ let params = new URLSearchParams(input);
+
+ response.setHeader("Content-Type", "application/json", false);
+
+ if (params.get("refresh_token") == "expired_token") {
+ response.setStatusLine("1.1", 400, "Bad Request");
+ response.write(JSON.stringify({ error: "invalid_grant" }));
+ return;
+ }
+
+ let data = { access_token: "bobs_access_token" };
+
+ if (params.get("code") == "success") {
+ // Authorisation just happened, set a different access token so the test
+ // can detect it, and provide a refresh token.
+ data.access_token = "new_access_token";
+ data.refresh_token = "new_refresh_token";
+ }
+
+ response.write(JSON.stringify(data));
+}
diff --git a/comm/mail/components/addrbook/test/browser/head.js b/comm/mail/components/addrbook/test/browser/head.js
new file mode 100644
index 0000000000..37fc445410
--- /dev/null
+++ b/comm/mail/components/addrbook/test/browser/head.js
@@ -0,0 +1,445 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const personalBook = MailServices.ab.getDirectoryFromId("ldap_2.servers.pab");
+const historyBook = MailServices.ab.getDirectoryFromId(
+ "ldap_2.servers.history"
+);
+
+add_setup(async () => {
+ // Force the window to be full screen to avoid issues with buttons not being
+ // reachable. This is a temporary solution while we update the details pane
+ // UI to be properly responsive and wrap elements correctly.
+ window.fullScreen = true;
+});
+
+// We want to check that everything has been removed/reset, but if we register
+// a cleanup function here, it will run before any other cleanup function has
+// had a chance to run. Instead, when it runs register another cleanup
+// function which will run last.
+registerCleanupFunction(function () {
+ registerCleanupFunction(async function () {
+ Assert.equal(
+ MailServices.ab.directories.length,
+ 2,
+ "Only Personal ab and Collected Addresses should be left."
+ );
+ for (let directory of MailServices.ab.directories) {
+ if (
+ directory.dirPrefId == "ldap_2.servers.history" ||
+ directory.dirPrefId == "ldap_2.servers.pab"
+ ) {
+ Assert.equal(
+ directory.childCardCount,
+ 0,
+ `All contacts should have been removed from ${directory.dirName}`
+ );
+ if (directory.childCardCount) {
+ directory.deleteCards(directory.childCards);
+ }
+ } else {
+ await promiseDirectoryRemoved(directory.URI);
+ }
+ }
+ closeAddressBookWindow();
+
+ // TODO: convert this to UID.
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURI");
+ Services.prefs.clearUserPref("mail.addr_book.view.startupURIisDefault");
+
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ // Focus an element in the main window, then blur it again to avoid it
+ // hijacking keypresses.
+ let mainWindowElement = document.getElementById("button-appmenu");
+ mainWindowElement.focus();
+ mainWindowElement.blur();
+ // Reset the window to its default size.
+ window.fullScreen = false;
+ });
+});
+
+async function openAddressBookWindow() {
+ return new Promise(resolve => {
+ window.openTab("addressBookTab", {
+ onLoad(event, browser) {
+ resolve(browser.contentWindow);
+ },
+ });
+ });
+}
+
+function closeAddressBookWindow() {
+ let abTab = getAddressBookTab();
+ if (abTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(abTab);
+ }
+}
+
+function getAddressBookTab() {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail.tabInfo.find(
+ t => t.browser?.currentURI.spec == "about:addressbook"
+ );
+}
+
+function getAddressBookWindow() {
+ let tab = getAddressBookTab();
+ return tab?.browser.contentWindow;
+}
+
+async function openAllAddressBooks() {
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.querySelector("#books > li"),
+ {},
+ abWindow
+ );
+ await new Promise(r => abWindow.setTimeout(r));
+}
+
+function openDirectory(directory) {
+ let abWindow = getAddressBookWindow();
+ let row = abWindow.booksList.getRowForUID(directory.UID);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector("span"), {}, abWindow);
+}
+
+function createAddressBook(dirName, type = Ci.nsIAbManager.JS_DIRECTORY_TYPE) {
+ let prefName = MailServices.ab.newAddressBook(dirName, null, type);
+ return MailServices.ab.getDirectoryFromId(prefName);
+}
+
+async function createAddressBookWithUI(abName) {
+ let newAddressBookPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"
+ );
+
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.getElementById("toolbarCreateBook"),
+ {},
+ abWindow
+ );
+
+ let abNameDialog = await newAddressBookPromise;
+ EventUtils.sendString(abName, abNameDialog);
+ abNameDialog.document.querySelector("dialog").getButton("accept").click();
+
+ let addressBook = MailServices.ab.directories.find(
+ directory => directory.dirName == abName
+ );
+
+ Assert.ok(addressBook, "a new address book was created");
+
+ // At this point we need to wait for the UI to update.
+ await new Promise(r => abWindow.setTimeout(r));
+
+ return addressBook;
+}
+
+function createContact(firstName, lastName, displayName, primaryEmail) {
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = displayName ?? `${firstName} ${lastName}`;
+ contact.firstName = firstName;
+ contact.lastName = lastName;
+ contact.primaryEmail =
+ primaryEmail ?? `${firstName}.${lastName}@invalid`.toLowerCase();
+ return contact;
+}
+
+function createMailingList(name) {
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = name;
+ return list;
+}
+
+async function createMailingListWithUI(mlParent, mlName) {
+ openDirectory(mlParent);
+
+ let newAddressBookPromise = promiseLoadSubDialog(
+ "chrome://messenger/content/addressbook/abMailListDialog.xhtml"
+ );
+
+ let abWindow = getAddressBookWindow();
+ EventUtils.synthesizeMouseAtCenter(
+ abWindow.document.getElementById("toolbarCreateList"),
+ {},
+ abWindow
+ );
+
+ let abListDialog = await newAddressBookPromise;
+ let abListDocument = abListDialog.document;
+ await new Promise(resolve => abListDialog.setTimeout(resolve));
+
+ abListDocument.getElementById("abPopup").value = mlParent.URI;
+ abListDocument.getElementById("ListName").value = mlName;
+ abListDocument.querySelector("dialog").getButton("accept").click();
+
+ let list = mlParent.childNodes.find(list => list.dirName == mlName);
+
+ Assert.ok(list, "a new list was created");
+
+ // At this point we need to wait for the UI to update.
+ await new Promise(r => abWindow.setTimeout(r));
+
+ return list;
+}
+
+function checkDirectoryDisplayed(directory) {
+ let abWindow = getAddressBookWindow();
+ let booksList = abWindow.document.getElementById("books");
+ let cardsList = abWindow.cardsPane.cardsList;
+
+ if (directory) {
+ Assert.equal(
+ booksList.selectedIndex,
+ booksList.getIndexForUID(directory.UID)
+ );
+ Assert.equal(cardsList.view.directory?.UID, directory.UID);
+ } else {
+ Assert.equal(booksList.selectedIndex, 0);
+ Assert.ok(!cardsList.view.directory);
+ }
+}
+
+function checkCardsListed(...expectedCards) {
+ checkNamesListed(
+ ...expectedCards.map(card =>
+ card.isMailList ? card.dirName : card.displayName
+ )
+ );
+
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ for (let i = 0; i < expectedCards.length; i++) {
+ let row = cardsList.getRowAtIndex(i);
+ Assert.equal(
+ row.classList.contains("MailList"),
+ expectedCards[i].isMailList,
+ `row ${
+ expectedCards[i].isMailList ? "should" : "should not"
+ } be a mailing list row`
+ );
+ Assert.equal(
+ row.address.textContent,
+ expectedCards[i].primaryEmail ?? "",
+ "correct address should be displayed"
+ );
+ Assert.equal(
+ row.avatar.childElementCount,
+ 1,
+ "only one avatar image should be displayed"
+ );
+ }
+}
+
+function checkNamesListed(...expectedNames) {
+ let abWindow = getAddressBookWindow();
+ let cardsList = abWindow.document.getElementById("cards");
+ let expectedCount = expectedNames.length;
+
+ Assert.equal(
+ cardsList.view.rowCount,
+ expectedCount,
+ "Tree view has the right number of rows"
+ );
+
+ for (let i = 0; i < expectedCount; i++) {
+ Assert.equal(
+ cardsList.view.getCellText(i, { id: "GeneratedName" }),
+ expectedNames[i],
+ "view should give the correct name"
+ );
+ Assert.equal(
+ cardsList.getRowAtIndex(i).querySelector(".generatedname-column, .name")
+ .textContent,
+ expectedNames[i],
+ "correct name should be displayed"
+ );
+ }
+}
+
+function checkPlaceholders(expectedVisible = []) {
+ let abWindow = getAddressBookWindow();
+ let placeholder = abWindow.cardsPane.cardsList.placeholder;
+
+ if (!expectedVisible.length) {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(placeholder),
+ "placeholders are hidden"
+ );
+ return;
+ }
+
+ for (let element of placeholder.children) {
+ let id = element.id;
+ if (expectedVisible.includes(id)) {
+ Assert.ok(BrowserTestUtils.is_visible(element), `${id} is visible`);
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(element), `${id} is hidden`);
+ }
+ }
+}
+
+async function showSortMenu(name, value) {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let displayButton = abDocument.getElementById("displayButton");
+ let sortContext = abDocument.getElementById("sortContext");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortContext, "popuphidden");
+ sortContext.activateItem(
+ sortContext.querySelector(`[name="${name}"][value="${value}"]`)
+ );
+ if (name == "toggle") {
+ sortContext.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function showPickerMenu(name, value) {
+ let abWindow = getAddressBookWindow();
+ let cardsHeader = abWindow.cardsPane.table.header;
+ let pickerButton = cardsHeader.querySelector(
+ `th[is="tree-view-table-column-picker"] button`
+ );
+ let menupopup = cardsHeader.querySelector(
+ `th[is="tree-view-table-column-picker"] menupopup`
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(pickerButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.activateItem(
+ menupopup.querySelector(`[name="${name}"][value="${value}"]`)
+ );
+ if (name == "toggle") {
+ menupopup.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function toggleLayout() {
+ let abWindow = getAddressBookWindow();
+ let abDocument = abWindow.document;
+
+ let displayButton = abDocument.getElementById("displayButton");
+ let sortContext = abDocument.getElementById("sortContext");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, abWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortContext, "popuphidden");
+ sortContext.activateItem(abDocument.getElementById("sortContextTableLayout"));
+ await hiddenPromise;
+}
+
+async function checkComposeWindow(composeWindow, ...expectedAddresses) {
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ let composeDocument = composeWindow.document;
+ let toAddrRow = composeDocument.getElementById("addressRowTo");
+
+ let pills = toAddrRow.querySelectorAll("mail-address-pill");
+ Assert.equal(pills.length, expectedAddresses.length);
+ for (let i = 0; i < expectedAddresses.length; i++) {
+ Assert.equal(pills[i].label, expectedAddresses[i]);
+ }
+
+ await Promise.all([
+ BrowserTestUtils.closeWindow(composeWindow),
+ BrowserTestUtils.waitForEvent(window, "activate"),
+ ]);
+}
+
+function promiseDirectoryRemoved(uri) {
+ let removePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(uri);
+ return removePromise;
+}
+
+function promiseLoadSubDialog(url) {
+ let abWindow = getAddressBookWindow();
+
+ return new Promise((resolve, reject) => {
+ abWindow.SubDialog._dialogStack.addEventListener(
+ "dialogopen",
+ function dialogopen(aEvent) {
+ if (
+ aEvent.detail.dialog._frame.contentWindow.location == "about:blank"
+ ) {
+ return;
+ }
+ abWindow.SubDialog._dialogStack.removeEventListener(
+ "dialogopen",
+ dialogopen
+ );
+
+ Assert.equal(
+ aEvent.detail.dialog._frame.contentWindow.location.toString(),
+ url,
+ "Check the proper URL is loaded"
+ );
+
+ // Check visibility
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ aEvent.detail.dialog._overlay,
+ "Overlay is visible"
+ )
+ );
+
+ // Check that stylesheets were injected
+ let expectedStyleSheetURLs =
+ aEvent.detail.dialog._injectedStyleSheets.slice(0);
+ for (let styleSheet of aEvent.detail.dialog._frame.contentDocument
+ .styleSheets) {
+ let i = expectedStyleSheetURLs.indexOf(styleSheet.href);
+ if (i >= 0) {
+ info("found " + styleSheet.href);
+ expectedStyleSheetURLs.splice(i, 1);
+ }
+ }
+ Assert.equal(
+ expectedStyleSheetURLs.length,
+ 0,
+ "All expectedStyleSheetURLs should have been found"
+ );
+
+ // Wait for the next event tick to make sure the remaining part of the
+ // testcase runs after the dialog gets ready for input.
+ executeSoon(() => resolve(aEvent.detail.dialog._frame.contentWindow));
+ }
+ );
+ });
+}
+
+function formatVCard(strings, ...values) {
+ let arr = [];
+ for (let str of strings) {
+ arr.push(str);
+ arr.push(values.shift());
+ }
+ let lines = arr.join("").split("\n");
+ let indent = lines[1].length - lines[1].trimLeft().length;
+ let outLines = [];
+ for (let line of lines) {
+ if (line.length > 0) {
+ outLines.push(line.substring(indent) + "\r\n");
+ }
+ }
+ return outLines.join("");
+}
diff --git a/comm/mail/components/cloudfile/cloudFileAccounts.jsm b/comm/mail/components/cloudfile/cloudFileAccounts.jsm
new file mode 100644
index 0000000000..3cb478f60f
--- /dev/null
+++ b/comm/mail/components/cloudfile/cloudFileAccounts.jsm
@@ -0,0 +1,215 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["cloudFileAccounts"];
+
+var ACCOUNT_ROOT = "mail.cloud_files.accounts.";
+
+var { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+
+var cloudFileAccounts = new (class extends EventEmitter {
+ get constants() {
+ return {
+ offlineErr: 0x80550014, // NS_MSG_ERROR_OFFLINE
+ authErr: 0x8055001e, // NS_MSG_USER_NOT_AUTHENTICATED
+ uploadErr: 0x8055311a, // NS_MSG_ERROR_ATTACHING_FILE
+ uploadWouldExceedQuota: 0x8055311b,
+ uploadExceedsFileLimit: 0x8055311c,
+ uploadCancelled: 0x8055311d,
+ uploadErrWithCustomMessage: 0x8055311f,
+ renameErr: 0x80553120,
+ renameErrWithCustomMessage: 0x80553121,
+ renameNotSupported: 0x80553122,
+ deleteErr: 0x80553123,
+ attachmentErr: 0x80553124,
+ accountErr: 0x80553125,
+ };
+ }
+
+ constructor() {
+ super();
+ this._providers = new Map();
+ this._accounts = new Map();
+ this._highestOrdinal = 0;
+ }
+
+ get _accountKeys() {
+ let accountKeySet = new Set();
+ let branch = Services.prefs.getBranch(ACCOUNT_ROOT);
+ let children = branch.getChildList("");
+ for (let child of children) {
+ let subbranch = child.substr(0, child.indexOf("."));
+ accountKeySet.add(subbranch);
+
+ let match = /^account(\d+)$/.exec(subbranch);
+ if (match) {
+ let ordinal = parseInt(match[1], 10);
+ this._highestOrdinal = Math.max(this._highestOrdinal, ordinal);
+ }
+ }
+
+ // TODO: sort by ordinal
+ return accountKeySet.keys();
+ }
+
+ /**
+ * Ensure that we have the account key for an account. If we already have the
+ * key, just return it. If we have the account, get the key from it.
+ *
+ * @param aKeyOrAccount the key or the account object
+ * @returns the account key
+ */
+ _ensureKey(aKeyOrAccount) {
+ if (typeof aKeyOrAccount == "string") {
+ return aKeyOrAccount;
+ }
+ if ("accountKey" in aKeyOrAccount) {
+ return aKeyOrAccount.accountKey;
+ }
+ throw new Error("String or cloud file account expected");
+ }
+
+ /**
+ * Register a cloudfile provider, e.g. from an extension.
+ *
+ * @param {object} The implementation to register
+ */
+ registerProvider(aType, aProvider) {
+ if (this._providers.has(aType)) {
+ throw new Error(`Cloudfile provider ${aType} is already registered`);
+ }
+ this._providers.set(aType, aProvider);
+ this.emit("providerRegistered", aProvider);
+ }
+
+ /**
+ * Unregister a cloudfile provider.
+ *
+ * @param {string} aType - The provider type to unregister
+ */
+ unregisterProvider(aType) {
+ if (!this._providers.has(aType)) {
+ throw new Error(`Cloudfile provider ${aType} is not registered`);
+ }
+
+ for (let account of this.getAccountsForType(aType)) {
+ this._accounts.delete(account.accountKey);
+ }
+
+ this._providers.delete(aType);
+ this.emit("providerUnregistered", aType);
+ }
+
+ get providers() {
+ return [...this._providers.values()];
+ }
+
+ getProviderForType(aType) {
+ return this._providers.get(aType);
+ }
+
+ createAccount(aType) {
+ this._highestOrdinal++;
+ let key = "account" + this._highestOrdinal;
+
+ try {
+ let provider = this.getProviderForType(aType);
+ let account = provider.initAccount(key);
+
+ Services.prefs.setCharPref(ACCOUNT_ROOT + key + ".type", aType);
+ Services.prefs.setCharPref(
+ ACCOUNT_ROOT + key + ".displayName",
+ account.displayName
+ );
+
+ this._accounts.set(key, account);
+ this.emit("accountAdded", account);
+ return account;
+ } catch (e) {
+ for (let prefName of Services.prefs.getChildList(
+ `${ACCOUNT_ROOT}${key}.`
+ )) {
+ Services.prefs.clearUserPref(prefName);
+ }
+ throw e;
+ }
+ }
+
+ removeAccount(aKeyOrAccount) {
+ let key = this._ensureKey(aKeyOrAccount);
+ let type = Services.prefs.getCharPref(ACCOUNT_ROOT + key + ".type");
+
+ this._accounts.delete(key);
+ for (let prefName of Services.prefs.getChildList(
+ `${ACCOUNT_ROOT}${key}.`
+ )) {
+ Services.prefs.clearUserPref(prefName);
+ }
+
+ this.emit("accountDeleted", key, type);
+ }
+
+ get accounts() {
+ let arr = [];
+ for (let key of this._accountKeys) {
+ let account = this.getAccount(key);
+ if (account) {
+ arr.push(account);
+ }
+ }
+ return arr;
+ }
+
+ get configuredAccounts() {
+ return this.accounts.filter(account => account.configured);
+ }
+
+ getAccount(aKey) {
+ if (this._accounts.has(aKey)) {
+ return this._accounts.get(aKey);
+ }
+
+ let type = Services.prefs.getCharPref(ACCOUNT_ROOT + aKey + ".type", "");
+ if (type) {
+ let provider = this.getProviderForType(type);
+ if (provider) {
+ let account = provider.initAccount(aKey);
+ this._accounts.set(aKey, account);
+ return account;
+ }
+ }
+ return null;
+ }
+
+ getAccountsForType(aType) {
+ let result = [];
+
+ for (let accountKey of this._accountKeys) {
+ let type = Services.prefs.getCharPref(
+ ACCOUNT_ROOT + accountKey + ".type"
+ );
+ if (type === aType) {
+ result.push(this.getAccount(accountKey));
+ }
+ }
+
+ return result;
+ }
+
+ getDisplayName(aKeyOrAccount) {
+ // If no display name has been set, we return the empty string.
+ let key = this._ensureKey(aKeyOrAccount);
+ return Services.prefs.getCharPref(ACCOUNT_ROOT + key + ".displayName", "");
+ }
+
+ setDisplayName(aKeyOrAccount, aDisplayName) {
+ let key = this._ensureKey(aKeyOrAccount);
+ Services.prefs.setCharPref(
+ ACCOUNT_ROOT + key + ".displayName",
+ aDisplayName
+ );
+ }
+})();
diff --git a/comm/mail/components/cloudfile/content/selectDialog.js b/comm/mail/components/cloudfile/content/selectDialog.js
new file mode 100644
index 0000000000..4c49d11aa3
--- /dev/null
+++ b/comm/mail/components/cloudfile/content/selectDialog.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/components/prompts/content/selectDialog.js */
+
+function cloudfileDialogOnLoad() {
+ let icons = propBag.getProperty("icons");
+ let listItems = listBox.itemChildren;
+ for (let i = 0; i < listItems.length; i++) {
+ listItems[i].setAttribute("align", "center");
+ let image = document.createElement("img");
+ image.setAttribute("src", icons[i]);
+ image.setAttribute("alt", "");
+ listItems[i].insertBefore(image, listItems[i].firstElementChild);
+ }
+}
diff --git a/comm/mail/components/cloudfile/content/selectDialog.xhtml b/comm/mail/components/cloudfile/content/selectDialog.xhtml
new file mode 100644
index 0000000000..3f99fea350
--- /dev/null
+++ b/comm/mail/components/cloudfile/content/selectDialog.xhtml
@@ -0,0 +1,32 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, you can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/cloudfileSelectDialog.css" type="text/css"?>
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="cloudfileDialogOnLoad();"
+>
+ <dialog>
+ <script
+ type="application/javascript"
+ src="chrome://messenger/content/cloudfile/selectDialog.js"
+ />
+ <script
+ type="application/javascript"
+ src="chrome://global/content/selectDialog.js"
+ />
+ <keyset id="dialogKeys" />
+ <vbox style="width: 24em; margin: 5px">
+ <label id="info.txt" />
+ <vbox>
+ <richlistbox id="list" class="theme-listbox" style="height: 8em" />
+ </vbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/cloudfile/jar.mn b/comm/mail/components/cloudfile/jar.mn
new file mode 100644
index 0000000000..ad4eeb3065
--- /dev/null
+++ b/comm/mail/components/cloudfile/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/cloudfile/selectDialog.js (content/selectDialog.js)
+ content/messenger/cloudfile/selectDialog.xhtml (content/selectDialog.xhtml)
diff --git a/comm/mail/components/cloudfile/moz.build b/comm/mail/components/cloudfile/moz.build
new file mode 100644
index 0000000000..f456f7ad85
--- /dev/null
+++ b/comm/mail/components/cloudfile/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "cloudFileAccounts.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/cloudfile/test/browser/browser.ini b/comm/mail/components/cloudfile/test/browser/browser.ini
new file mode 100644
index 0000000000..8f4b1a954a
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/icon.svg files/management.html
+
+[browser_repeat_upload.js]
+support-files = files/green_eggs.txt
diff --git a/comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js b/comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js
new file mode 100644
index 0000000000..7390354a8c
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/browser_repeat_upload.js
@@ -0,0 +1,246 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../base/content/mailWindowOverlay.js */
+
+let { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+const ICON_URL = getRootDirectory(gTestPath) + "files/icon.svg";
+const MANAGEMENT_URL = getRootDirectory(gTestPath) + "files/management.html";
+
+function getFileFromChromeURL(leafName) {
+ let ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ let url = Services.io.newURI(
+ getRootDirectory(gTestPath) + "files/" + leafName
+ );
+ let fileURL = ChromeRegistry.convertChromeURL(url).QueryInterface(
+ Ci.nsIFileURL
+ );
+ return fileURL.file;
+}
+
+add_task(async () => {
+ let uploadedFiles = [];
+ let provider = {
+ type: "Mochitest",
+ displayName: "Mochitest",
+ iconURL: ICON_URL,
+ initAccount(accountKey) {
+ return {
+ accountKey,
+ type: "Mochitest",
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ "Mochitest Account"
+ );
+ },
+ getPreviousUploads() {
+ return uploadedFiles;
+ },
+ urlForFile(file) {
+ return "https://mochi.test/" + file.leafName;
+ },
+ iconURL: ICON_URL,
+ configured: true,
+ managementURL: MANAGEMENT_URL,
+ reuseUploads: true,
+ };
+ },
+ };
+
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 0,
+ "Should have no cloudfile accounts starting off."
+ );
+
+ cloudFileAccounts.registerProvider(provider.type, provider);
+ let account = cloudFileAccounts.createAccount(provider.type);
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 1,
+ "Should have only the one account we created."
+ );
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MsgNewMessage();
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == composeWindow
+ );
+ let composeDocument = composeWindow.document;
+
+ // Compose window loaded.
+ // Check the attach dropdown has our account as a <menuitem>.
+
+ let toolbarButton = composeDocument.getElementById("button-attach");
+ let rect = toolbarButton.getBoundingClientRect();
+ EventUtils.synthesizeMouse(
+ toolbarButton,
+ rect.width - 5,
+ 5,
+ { clickCount: 1 },
+ composeWindow
+ );
+ await promiseAnimationFrame(composeWindow);
+
+ let menu = composeDocument.getElementById(
+ "button-attachPopup_attachCloudMenu"
+ );
+ ok(!BrowserTestUtils.is_hidden(menu));
+
+ let popupshown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(menu, { clickCount: 1 }, composeWindow);
+ await popupshown;
+
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 1,
+ "Should still have one registered account."
+ );
+
+ let menuitems = menu.menupopup.children;
+ is(menuitems.length, 1);
+ is(menuitems[0].getAttribute("image"), ICON_URL);
+ is(menuitems[0].getAttribute("label"), "Mochitest Account\u2026");
+
+ composeDocument.getElementById("button-attachPopup").hidePopup();
+
+ // Pretend we uploaded some files before.
+
+ uploadedFiles = [
+ {
+ id: 1,
+ name: "green_eggs.txt",
+ path: getFileFromChromeURL("green_eggs.txt").path,
+ size: 30,
+ url: "https://mochi.test/green_eggs.txt",
+ serviceName: "MyCloud",
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ },
+ {
+ id: 2,
+ name: "ham.zip",
+ path: getFileFromChromeURL("ham.zip").path,
+ size: 1234,
+ url: "https://mochi.test/ham.zip",
+ },
+ ];
+ is(account.getPreviousUploads().length, 2);
+
+ // Check the attach dropdown has our account as a <menu>.
+
+ await new Promise(resolve => {
+ toolbarButton.addEventListener("popupshown", resolve, { once: true });
+ EventUtils.synthesizeMouse(
+ toolbarButton,
+ rect.width - 5,
+ 5,
+ { clickCount: 1 },
+ composeWindow
+ );
+ });
+ info("toolbar button menu opened");
+ await promiseAnimationFrame(composeWindow);
+
+ await new Promise(resolve => {
+ menu.menupopup.addEventListener("popupshown", resolve, { once: true });
+ EventUtils.synthesizeMouseAtCenter(menu, { clickCount: 1 }, composeWindow);
+ });
+ info("file link menu opened");
+ await promiseAnimationFrame(composeWindow);
+
+ menuitems = menu.menupopup.children;
+ is(menuitems.length, 2);
+ is(menuitems[0].getAttribute("image"), ICON_URL);
+ is(menuitems[0].getAttribute("label"), "Mochitest Account\u2026");
+ is(menuitems[1].localName, "menuitem");
+ is(menuitems[1].getAttribute("image"), "moz-icon://green_eggs.txt");
+ is(menuitems[1].getAttribute("label"), "green_eggs.txt");
+ // TODO: Enable this when we handle files that no longer exist on the filesystem.
+ // is(menuitems[2].localName, "menuitem");
+ // is(menuitems[2].getAttribute("image"), "moz-icon://ham.zip");
+ // is(menuitems[2].getAttribute("label"), "ham.zip");
+
+ // Select one of the previously-uploaded items and check the attachment is added.
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ await new Promise(resolve => {
+ bucket.addEventListener("attachments-added", resolve, { once: true });
+ menu.menupopup.activateItem(menuitems[1]);
+ });
+ info("attachment added");
+ await promiseAnimationFrame(composeWindow);
+ ok(toolbarButton.open === false);
+
+ is(bucket.itemCount, 1);
+ let attachment = bucket.itemChildren[0];
+ is(attachment.getAttribute("name"), "green_eggs.txt");
+ ok(attachment.attachment.sendViaCloud);
+ is(attachment.attachment.cloudFileAccountKey, account.accountKey);
+ is(
+ attachment.attachment.contentLocation,
+ "https://mochi.test/green_eggs.txt"
+ );
+
+ is(
+ attachment.querySelector("img.attachmentcell-icon").src,
+ uploadedFiles[0].serviceIcon,
+ "CloudFile icon should be correct."
+ );
+
+ // Check the content of the editor for the added template.
+ let editor = composeWindow.GetCurrentEditor();
+ let urls = editor.document.querySelectorAll(
+ "body > #cloudAttachmentListRoot > #cloudAttachmentList"
+ );
+ Assert.equal(urls.length, 1, "Found 1 FileLink template in the document.");
+
+ // Template is added asynchronously.
+ await TestUtils.waitForCondition(() => urls[0].querySelector("li"));
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-name").textContent,
+ "green_eggs.txt",
+ "The name of the cloud file in the template should be correct."
+ );
+
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-name").href,
+ "https://mochi.test/green_eggs.txt",
+ "The URL attached to the name of the cloud file in the template should be correct."
+ );
+
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-service-name").textContent,
+ "MyCloud",
+ "The used service name in the template should be correct."
+ );
+
+ Assert.equal(
+ urls[0].querySelector(".cloudfile-service-icon").src,
+ "data:image/svg+xml;filename=globe.svg;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGZpbGw9ImNvbnRleHQtZmlsbCIgZD0iTTggMGE4IDggMCAxIDAgOCA4IDguMDA5IDguMDA5IDAgMCAwLTgtOHptNS4xNjMgNC45NThoLTEuNTUyYTcuNyA3LjcgMCAwIDAtMS4wNTEtMi4zNzYgNi4wMyA2LjAzIDAgMCAxIDIuNjAzIDIuMzc2ek0xNCA4YTUuOTYzIDUuOTYzIDAgMCAxLS4zMzUgMS45NThoLTEuODIxQTEyLjMyNyAxMi4zMjcgMCAwIDAgMTIgOGExMi4zMjcgMTIuMzI3IDAgMCAwLS4xNTYtMS45NThoMS44MjFBNS45NjMgNS45NjMgMCAwIDEgMTQgOHptLTYgNmMtMS4wNzUgMC0yLjAzNy0xLjItMi41NjctMi45NThoNS4xMzVDMTAuMDM3IDEyLjggOS4wNzUgMTQgOCAxNHpNNS4xNzQgOS45NThhMTEuMDg0IDExLjA4NCAwIDAgMSAwLTMuOTE2aDUuNjUxQTExLjExNCAxMS4xMTQgMCAwIDEgMTEgOGExMS4xMTQgMTEuMTE0IDAgMCAxLS4xNzQgMS45NTh6TTIgOGE1Ljk2MyA1Ljk2MyAwIDAgMSAuMzM1LTEuOTU4aDEuODIxYTEyLjM2MSAxMi4zNjEgMCAwIDAgMCAzLjkxNkgyLjMzNUE1Ljk2MyA1Ljk2MyAwIDAgMSAyIDh6bTYtNmMxLjA3NSAwIDIuMDM3IDEuMiAyLjU2NyAyLjk1OEg1LjQzM0M1Ljk2MyAzLjIgNi45MjUgMiA4IDJ6bS0yLjU2LjU4MmE3LjcgNy43IDAgMCAwLTEuMDUxIDIuMzc2SDIuODM3QTYuMDMgNi4wMyAwIDAgMSA1LjQ0IDIuNTgyem0tMi42IDguNDZoMS41NDlhNy43IDcuNyAwIDAgMCAxLjA1MSAyLjM3NiA2LjAzIDYuMDMgMCAwIDEtMi42MDMtMi4zNzZ6bTcuNzIzIDIuMzc2YTcuNyA3LjcgMCAwIDAgMS4wNTEtMi4zNzZoMS41NTJhNi4wMyA2LjAzIDAgMCAxLTIuNjA2IDIuMzc2eiI+PC9wYXRoPgo8L3N2Zz4K",
+ "The used service icon should be correct."
+ );
+
+ // clean up
+ cloudFileAccounts.removeAccount(account);
+ cloudFileAccounts.unregisterProvider(provider.type);
+ Assert.equal(
+ cloudFileAccounts.configuredAccounts.length,
+ 0,
+ "Should leave no cloudfile accounts when done"
+ );
+ composeWindow.close();
+
+ // Request focus on something in the main window so the test doesn't time
+ // out waiting for focus.
+ document.getElementById("button-appmenu").focus();
+});
diff --git a/comm/mail/components/cloudfile/test/browser/files/green_eggs.txt b/comm/mail/components/cloudfile/test/browser/files/green_eggs.txt
new file mode 100644
index 0000000000..058318befb
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/files/green_eggs.txt
@@ -0,0 +1 @@
+I do not like them, Sam I Am!
diff --git a/comm/mail/components/cloudfile/test/browser/files/icon.svg b/comm/mail/components/cloudfile/test/browser/files/icon.svg
new file mode 100644
index 0000000000..6c1a552445
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/files/icon.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+ <circle cx="8" cy="8" r="7.5" fill="#ffffff" stroke="#00aa00" stroke-width="1.5"/>
+ <circle cx="5" cy="6" r="1.5" fill="#00aa00"/>
+ <circle cx="11" cy="6" r="1.5" fill="#00aa00"/>
+ <path d="M 12.83,9.30 C 12.24,11.48 10.26,13 8,13 5.75,13 3.74,11.48 3.17,9.29" fill="none" stroke="#00aa00" stroke-width="1.5"/>
+</svg>
diff --git a/comm/mail/components/cloudfile/test/browser/files/management.html b/comm/mail/components/cloudfile/test/browser/files/management.html
new file mode 100644
index 0000000000..5a51891fd7
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/files/management.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title></title>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/comm/mail/components/cloudfile/test/browser/head.js b/comm/mail/components/cloudfile/test/browser/head.js
new file mode 100644
index 0000000000..3dd17de883
--- /dev/null
+++ b/comm/mail/components/cloudfile/test/browser/head.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(async function () {
+ let gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(rootFolder.URI);
+ await new Promise(resolve => executeSoon(resolve));
+});
+
+function createAccount() {
+ registerCleanupFunction(() => {
+ MailServices.accounts.accounts.forEach(cleanUpAccount);
+ });
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ info(`Created account ${account.toString()}`);
+
+ return account;
+}
+
+function cleanUpAccount(account) {
+ info(`Cleaning up account ${account.toString()}`);
+ MailServices.accounts.removeAccount(account, true);
+}
+
+function addIdentity(account) {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "mochitest@localhost";
+ account.addIdentity(identity);
+ account.defaultIdentity = identity;
+ info(`Created identity ${identity.toString()}`);
+}
+
+async function promiseAnimationFrame(win = window) {
+ await new Promise(win.requestAnimationFrame);
+ // dispatchToMainThread throws if used as the first argument of Promise.
+ return new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+}
diff --git a/comm/mail/components/components.conf b/comm/mail/components/components.conf
new file mode 100644
index 0000000000..e68428af4d
--- /dev/null
+++ b/comm/mail/components/components.conf
@@ -0,0 +1,76 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ "cid": "{8cc51368-6aa0-43e8-b762-bde9b9fd828c}",
+ "contract_ids": [
+ "@mozilla.org/network/protocol/about;1?what=newserror",
+ "@mozilla.org/network/protocol/about;1?what=rights",
+ "@mozilla.org/network/protocol/about;1?what=preferences",
+ "@mozilla.org/network/protocol/about;1?what=downloads",
+ "@mozilla.org/network/protocol/about;1?what=policies",
+ "@mozilla.org/network/protocol/about;1?what=accountsettings",
+ "@mozilla.org/network/protocol/about;1?what=accountsetup",
+ "@mozilla.org/network/protocol/about;1?what=accountprovisioner",
+ "@mozilla.org/network/protocol/about;1?what=addressbook",
+ "@mozilla.org/network/protocol/about;1?what=3pane",
+ "@mozilla.org/network/protocol/about;1?what=message",
+ "@mozilla.org/network/protocol/about;1?what=import",
+ "@mozilla.org/network/protocol/about;1?what=profiling",
+ ],
+ "jsm": "resource:///modules/AboutRedirector.jsm",
+ "constructor": "AboutRedirector",
+ },
+ {
+ "cid": "{eb239c82-fac9-431e-98d7-11cacd0f71b8}",
+ "contract_ids": ["@mozilla.org/mail/mailglue;1"],
+ "jsm": "resource:///modules/MailGlue.jsm",
+ "constructor": "MailGlue",
+ },
+ {
+ "cid": "{44346520-c5d2-44e5-a1ec-034e04d7fac4}",
+ "contract_ids": [
+ "@mozilla.org/uriloader/content-handler;1?type=text/html",
+ "@mozilla.org/uriloader/content-handler;1?type=text/plain",
+ "@mozilla.org/mail/default-mail-clh;1",
+ "@mozilla.org/mail/clh;1",
+ ],
+ "jsm": "resource:///modules/MessengerContentHandler.jsm",
+ "constructor": "MessengerContentHandler",
+ "categories": {
+ "command-line-handler": "x-default",
+ "command-line-validator": "b-default",
+ },
+ },
+ {
+ "cid": "{048227f7-852a-473c-b9b5-7748684b57e2}",
+ "contract_ids": [
+ "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display",
+ ],
+ "jsm": "resource:///modules/MessengerContentHandler.jsm",
+ "constructor": "MessageDisplayContentHandler",
+ },
+]
+
+if buildconfig.substs.get("MOZ_DEBUG") or buildconfig.substs.get("NIGHTLY_BUILD"):
+ Categories = {
+ "app-startup": {
+ "startupRecorder": (
+ "@mozilla.org/test/startuprecorder;1",
+ ProcessSelector.MAIN_PROCESS_ONLY,
+ ),
+ },
+ }
+
+ Classes += [
+ {
+ "cid": "{11c095b2-e42e-4bdf-9dd0-aed87595f6a4}",
+ "contract_ids": ["@mozilla.org/test/startuprecorder;1"],
+ "jsm": "resource:///modules/StartupRecorder.jsm",
+ "constructor": "StartupRecorder",
+ },
+ ]
diff --git a/comm/mail/components/compose/composer.js b/comm/mail/components/compose/composer.js
new file mode 100644
index 0000000000..68e94cdc55
--- /dev/null
+++ b/comm/mail/components/compose/composer.js
@@ -0,0 +1,65 @@
+#filter dumbComments emptyLines substitution
+
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+pref("editor.author", "");
+
+pref("editor.text_color", "#000000");
+pref("editor.link_color", "#0000FF");
+pref("editor.active_link_color", "#000088");
+pref("editor.followed_link_color", "#FF0000");
+pref("editor.background_color", "#FFFFFF");
+pref("editor.use_background_image", false);
+pref("editor.default_background_image", "");
+pref("editor.use_custom_default_colors", 1);
+
+pref("editor.hrule.height", 2);
+pref("editor.hrule.width", 100);
+pref("editor.hrule.width_percent", true);
+pref("editor.hrule.shading", true);
+// center
+pref("editor.hrule.align", 1);
+
+pref("editor.table.maintain_structure", true);
+
+pref("editor.prettyprint", true);
+
+pref("editor.history.url_maximum", 10);
+
+pref("editor.publish.", "");
+pref("editor.lastFileLocation.image", "");
+pref("editor.lastFileLocation.html", "");
+pref("editor.save_associated_files", true);
+pref("editor.always_show_publish_dialog", false);
+
+//
+// What are the entities that you want Mozilla to save using mnemonic
+// names rather than numeric codes? E.g. If set, we'll output &nbsp;
+// otherwise, we may output 0xa0 depending on the charset.
+//
+// "none" : don't use any entity names; only use numeric codes.
+// "basic" : use entity names just for &nbsp; &amp; &lt; &gt; &quot; for
+// interoperability/exchange with products that don't support more
+// than that.
+// "latin1" : use entity names for 8bit accented letters and other special
+// symbols between 128 and 255.
+// "html" : use entity names for 8bit accented letters, greek letters, and
+// other special markup symbols as defined in HTML4.
+//
+
+//pref("editor.encode_entity", "html");
+
+#ifndef XP_MACOSX
+#ifdef XP_UNIX
+pref("editor.disable_spell_checker", false);
+pref("editor.dont_lock_spell_files", true);
+#endif
+#endif
+
+pref("editor.CR_creates_new_p", false);
+
+// Pasting images from the clipboard, order of encoding preference:
+// JPEG-PNG-GIF=0, PNG-JPEG-GIF=1, GIF-JPEG-PNG=2
+pref("clipboard.paste_image_type", 1);
diff --git a/comm/mail/components/compose/content/ComposerCommands.js b/comm/mail/components/compose/content/ComposerCommands.js
new file mode 100644
index 0000000000..7e9d7a992d
--- /dev/null
+++ b/comm/mail/components/compose/content/ComposerCommands.js
@@ -0,0 +1,2261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Implementations of nsIControllerCommand for composer commands. These commands
+ * are related to editing. You can fire these commands with following functions:
+ * goDoCommand and goDoCommandParams(If command requires any parameters).
+ *
+ * Sometimes, we want to reflect the changes in the UI also. We have two functions
+ * for that: pokeStyleUI and pokeMultiStateUI. The pokeStyleUI function is for those
+ * commands which are boolean in nature for example "cmd_bold" command, text can
+ * be bold or not. The pokeMultiStateUI function is for the commands which can have
+ * multiple values for example "cmd_fontFace" can have different values like
+ * arial, variable width etc.
+ *
+ * Here, some of the commands are getting executed by document.execCommand.
+ * Those are listed in the gCommandMap Map object. In that also, some commands
+ * are of type boolean and some are of multiple state. We have two functions to
+ * execute them: doStatefulCommand and doStyleUICommand.
+ *
+ * All commands are not executable through document.execCommand.
+ * In all those cases, we will use goDoCommand or goDoCommandParams.
+ * The goDoCommandParams function is implemented in this file.
+ * The goDoCOmmand function is from globalOverlay.js. For the Commands
+ * which can be executed by document.execCommand, we will use doStatefulCommand
+ * and doStyleUICommand.
+ */
+
+/* import-globals-from ../../../../../toolkit/components/printing/content/printUtils.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from ../../../base/content/utilityOverlay.js */
+/* import-globals-from editor.js */
+/* import-globals-from editorUtilities.js */
+/* import-globals-from MsgComposeCommands.js */
+
+var gComposerJSCommandControllerID = 0;
+
+/**
+ * Used to register commands we have created manually.
+ */
+function SetupHTMLEditorCommands() {
+ var commandTable = GetComposerCommandTable();
+ if (!commandTable) {
+ return;
+ }
+
+ // Include everything a text editor does
+ SetupTextEditorCommands();
+
+ // dump("Registering HTML editor commands\n");
+
+ commandTable.registerCommand("cmd_renderedHTMLEnabler", nsDummyHTMLCommand);
+
+ commandTable.registerCommand("cmd_listProperties", nsListPropertiesCommand);
+ commandTable.registerCommand("cmd_colorProperties", nsColorPropertiesCommand);
+ commandTable.registerCommand("cmd_increaseFontStep", nsIncreaseFontCommand);
+ commandTable.registerCommand("cmd_decreaseFontStep", nsDecreaseFontCommand);
+ commandTable.registerCommand(
+ "cmd_objectProperties",
+ nsObjectPropertiesCommand
+ );
+ commandTable.registerCommand(
+ "cmd_removeNamedAnchors",
+ nsRemoveNamedAnchorsCommand
+ );
+
+ commandTable.registerCommand("cmd_image", nsImageCommand);
+ commandTable.registerCommand("cmd_hline", nsHLineCommand);
+ commandTable.registerCommand("cmd_link", nsLinkCommand);
+ commandTable.registerCommand("cmd_anchor", nsAnchorCommand);
+ commandTable.registerCommand(
+ "cmd_insertHTMLWithDialog",
+ nsInsertHTMLWithDialogCommand
+ );
+ commandTable.registerCommand(
+ "cmd_insertMathWithDialog",
+ nsInsertMathWithDialogCommand
+ );
+ commandTable.registerCommand("cmd_insertBreakAll", nsInsertBreakAllCommand);
+
+ commandTable.registerCommand("cmd_table", nsInsertOrEditTableCommand);
+ commandTable.registerCommand("cmd_editTable", nsEditTableCommand);
+ commandTable.registerCommand("cmd_SelectTable", nsSelectTableCommand);
+ commandTable.registerCommand("cmd_SelectRow", nsSelectTableRowCommand);
+ commandTable.registerCommand("cmd_SelectColumn", nsSelectTableColumnCommand);
+ commandTable.registerCommand("cmd_SelectCell", nsSelectTableCellCommand);
+ commandTable.registerCommand(
+ "cmd_SelectAllCells",
+ nsSelectAllTableCellsCommand
+ );
+ commandTable.registerCommand("cmd_InsertTable", nsInsertTableCommand);
+ commandTable.registerCommand(
+ "cmd_InsertRowAbove",
+ nsInsertTableRowAboveCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertRowBelow",
+ nsInsertTableRowBelowCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertColumnBefore",
+ nsInsertTableColumnBeforeCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertColumnAfter",
+ nsInsertTableColumnAfterCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertCellBefore",
+ nsInsertTableCellBeforeCommand
+ );
+ commandTable.registerCommand(
+ "cmd_InsertCellAfter",
+ nsInsertTableCellAfterCommand
+ );
+ commandTable.registerCommand("cmd_DeleteTable", nsDeleteTableCommand);
+ commandTable.registerCommand("cmd_DeleteRow", nsDeleteTableRowCommand);
+ commandTable.registerCommand("cmd_DeleteColumn", nsDeleteTableColumnCommand);
+ commandTable.registerCommand("cmd_DeleteCell", nsDeleteTableCellCommand);
+ commandTable.registerCommand(
+ "cmd_DeleteCellContents",
+ nsDeleteTableCellContentsCommand
+ );
+ commandTable.registerCommand("cmd_JoinTableCells", nsJoinTableCellsCommand);
+ commandTable.registerCommand("cmd_SplitTableCell", nsSplitTableCellCommand);
+ commandTable.registerCommand(
+ "cmd_TableOrCellColor",
+ nsTableOrCellColorCommand
+ );
+ commandTable.registerCommand("cmd_smiley", nsSetSmiley);
+ commandTable.registerCommand("cmd_ConvertToTable", nsConvertToTable);
+}
+
+function SetupTextEditorCommands() {
+ var commandTable = GetComposerCommandTable();
+ if (!commandTable) {
+ return;
+ }
+ // dump("Registering plain text editor commands\n");
+
+ commandTable.registerCommand("cmd_findReplace", nsFindReplaceCommand);
+ commandTable.registerCommand("cmd_find", nsFindCommand);
+ commandTable.registerCommand("cmd_findNext", nsFindAgainCommand);
+ commandTable.registerCommand("cmd_findPrev", nsFindAgainCommand);
+ commandTable.registerCommand("cmd_rewrap", nsRewrapCommand);
+ commandTable.registerCommand("cmd_spelling", nsSpellingCommand);
+ commandTable.registerCommand("cmd_insertChars", nsInsertCharsCommand);
+}
+
+/**
+ * Used to register the command controller in the editor document.
+ *
+ * @returns {nsIControllerCommandTable|null} - A controller used to
+ * register the manually created commands.
+ */
+function GetComposerCommandTable() {
+ var controller;
+ if (gComposerJSCommandControllerID) {
+ try {
+ controller = window.content.controllers.getControllerById(
+ gComposerJSCommandControllerID
+ );
+ } catch (e) {}
+ }
+ if (!controller) {
+ // create it
+ controller =
+ Cc["@mozilla.org/embedcomp/base-command-controller;1"].createInstance();
+
+ var editorController = controller.QueryInterface(Ci.nsIControllerContext);
+ editorController.setCommandContext(GetCurrentEditorElement());
+ window.content.controllers.insertControllerAt(0, controller);
+
+ // Store the controller ID so we can be sure to get the right one later
+ gComposerJSCommandControllerID =
+ window.content.controllers.getControllerId(controller);
+ }
+
+ if (controller) {
+ var interfaceRequestor = controller.QueryInterface(
+ Ci.nsIInterfaceRequestor
+ );
+ return interfaceRequestor.getInterface(Ci.nsIControllerCommandTable);
+ }
+ return null;
+}
+
+/* eslint-disable complexity */
+
+/**
+ * Get the state of the given command and call the pokeStyleUI or pokeMultiStateUI
+ * according to the type of the command to reflect the UI changes in the editor.
+ *
+ * @param {string} command - The id of the command.
+ */
+function goUpdateCommandState(command) {
+ try {
+ var controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ if (!(controller instanceof Ci.nsICommandController)) {
+ return;
+ }
+
+ var params = newCommandParams();
+ if (!params) {
+ return;
+ }
+
+ controller.getCommandStateWithParams(command, params);
+
+ switch (command) {
+ case "cmd_bold":
+ case "cmd_italic":
+ case "cmd_underline":
+ case "cmd_var":
+ case "cmd_samp":
+ case "cmd_code":
+ case "cmd_acronym":
+ case "cmd_abbr":
+ case "cmd_cite":
+ case "cmd_strong":
+ case "cmd_em":
+ case "cmd_superscript":
+ case "cmd_subscript":
+ case "cmd_strikethrough":
+ case "cmd_tt":
+ case "cmd_nobreak":
+ case "cmd_ul":
+ case "cmd_ol":
+ pokeStyleUI(command, params.getBooleanValue("state_all"));
+ break;
+
+ case "cmd_paragraphState":
+ case "cmd_align":
+ case "cmd_highlight":
+ case "cmd_backgroundColor":
+ case "cmd_fontColor":
+ case "cmd_fontFace":
+ pokeMultiStateUI(command, params);
+ break;
+
+ case "cmd_indent":
+ case "cmd_outdent":
+ case "cmd_increaseFont":
+ case "cmd_decreaseFont":
+ case "cmd_increaseFontStep":
+ case "cmd_decreaseFontStep":
+ case "cmd_removeStyles":
+ case "cmd_smiley":
+ break;
+
+ default:
+ dump("no update for command: " + command + "\n");
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
+/* eslint-enable complexity */
+
+/**
+ * Used in the oncommandupdate attribute of the goUpdateComposerMenuItems.
+ * For any commandset events fired, this function will be called.
+ * Used to update the UI state of the editor buttons and menulist.
+ * Whenever you change your selection in the editor part, i.e. if you move
+ * your cursor, you will find this functions getting called and
+ * updating the editor UI of toolbarbuttons and menulists. This is mainly
+ * to update the UI according to your selection in the editor part.
+ *
+ * @param {XULElement} commandset - The <xul:commandset> element to update for.
+ */
+function goUpdateComposerMenuItems(commandset) {
+ // dump("Updating commands for " + commandset.id + "\n");
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandNode = commandset.children[i];
+ var commandID = commandNode.id;
+ if (commandID) {
+ goUpdateCommand(commandID); // enable or disable
+ if (commandNode.hasAttribute("state")) {
+ goUpdateCommandState(commandID);
+ }
+ }
+ }
+}
+
+/**
+ * Execute the command with the provided parameters.
+ * This is directly calling commands with multiple state attributes, which
+ * are not supported by document.execCommand()
+ *
+ * @param {string} command - The command ID.
+ * @param {string} paramValue - The parameter value.
+ */
+function goDoCommandParams(command, paramValue) {
+ try {
+ let params = newCommandParams();
+ params.setStringValue("state_attribute", paramValue);
+ let controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ if (controller && controller.isCommandEnabled(command)) {
+ if (controller instanceof Ci.nsICommandController) {
+ controller.doCommandWithParams(command, params);
+ } else {
+ controller.doCommand(command);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+/**
+ * Update the UI to reflect setting a given state for a command. This
+ * is used for boolean type of commands.
+ *
+ * @param {string} uiID - The id of the command.
+ * @param {boolean} desiredState - State to set for the command.
+ */
+function pokeStyleUI(uiID, desiredState) {
+ let commandNode = document.getElementById(uiID);
+ let uiState = commandNode.getAttribute("state") == "true";
+ if (desiredState != uiState) {
+ commandNode.setAttribute("state", desiredState ? "true" : "false");
+ let buttonId;
+ switch (uiID) {
+ case "cmd_bold":
+ buttonId = "boldButton";
+ break;
+ case "cmd_italic":
+ buttonId = "italicButton";
+ break;
+ case "cmd_underline":
+ buttonId = "underlineButton";
+ break;
+ case "cmd_ul":
+ buttonId = "ulButton";
+ break;
+ case "cmd_ol":
+ buttonId = "olButton";
+ break;
+ }
+ if (buttonId) {
+ document.getElementById(buttonId).checked = desiredState;
+ }
+ }
+}
+
+/**
+ * Maps internal command names to their document.execCommand() command string.
+ */
+let gCommandMap = new Map([
+ ["cmd_bold", "bold"],
+ ["cmd_italic", "italic"],
+ ["cmd_underline", "underline"],
+ ["cmd_strikethrough", "strikethrough"],
+ ["cmd_superscript", "superscript"],
+ ["cmd_subscript", "subscript"],
+ ["cmd_ul", "InsertUnorderedList"],
+ ["cmd_ol", "InsertOrderedList"],
+ ["cmd_fontFace", "fontName"],
+
+ // This are currently implemented with the help of
+ // color selection dialog box in the editor.js.
+ // ["cmd_highlight", "backColor"],
+ // ["cmd_fontColor", "foreColor"],
+]);
+
+/**
+ * Used for the boolean type commands available through
+ * document.execCommand(). We will also call pokeStyleUI to update
+ * the UI.
+ *
+ * @param {string} cmdStr - The id of the command.
+ */
+function doStyleUICommand(cmdStr) {
+ GetCurrentEditorElement().contentDocument.execCommand(
+ gCommandMap.get(cmdStr),
+ false,
+ null
+ );
+ let commandNode = document.getElementById(cmdStr);
+ let newState = commandNode.getAttribute("state") != "true";
+ pokeStyleUI(cmdStr, newState);
+}
+
+// Copied from jsmime.js.
+function stringToTypedArray(buffer) {
+ var typedarray = new Uint8Array(buffer.length);
+ for (var i = 0; i < buffer.length; i++) {
+ typedarray[i] = buffer.charCodeAt(i);
+ }
+ return typedarray;
+}
+
+/**
+ * Update the UI to reflect setting a given state for a command. This is used
+ * when the command state has a string value i.e. multiple state type commands.
+ *
+ * @param {string} uiID - The id of the command.
+ * @param {nsICommandParams} cmdParams - Command parameters object.
+ */
+function pokeMultiStateUI(uiID, cmdParams) {
+ let desiredAttrib;
+ if (cmdParams.getBooleanValue("state_mixed")) {
+ desiredAttrib = "mixed";
+ } else if (
+ cmdParams.getValueType("state_attribute") == Ci.nsICommandParams.eStringType
+ ) {
+ desiredAttrib = cmdParams.getCStringValue("state_attribute");
+ // Decode UTF-8, for example for font names in Japanese.
+ desiredAttrib = new TextDecoder("UTF-8").decode(
+ stringToTypedArray(desiredAttrib)
+ );
+ } else {
+ desiredAttrib = cmdParams.getStringValue("state_attribute");
+ }
+
+ let commandNode = document.getElementById(uiID);
+ let uiState = commandNode.getAttribute("state");
+ if (desiredAttrib != uiState) {
+ commandNode.setAttribute("state", desiredAttrib);
+ switch (uiID) {
+ case "cmd_paragraphState": {
+ onParagraphFormatChange();
+ break;
+ }
+ case "cmd_fontFace": {
+ onFontFaceChange();
+ break;
+ }
+ case "cmd_fontColor": {
+ onFontColorChange();
+ break;
+ }
+ case "cmd_backgroundColor": {
+ onBackgroundColorChange();
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * Perform the action of the multiple states type commands available through
+ * document.execCommand().
+ *
+ * @param {string} commandID - The id of the command.
+ * @param {string} newState - The parameter value.
+ * @param {boolean} updateUI - updates the UI if true. Used when
+ * function is called in another JavaScript function.
+ */
+function doStatefulCommand(commandID, newState, updateUI) {
+ if (commandID == "cmd_align") {
+ let command;
+ switch (newState) {
+ case "left":
+ command = "justifyLeft";
+ break;
+ case "center":
+ command = "justifyCenter";
+ break;
+ case "right":
+ command = "justifyRight";
+ break;
+ case "justify":
+ command = "justifyFull";
+ break;
+ }
+ GetCurrentEditorElement().contentDocument.execCommand(command, false, null);
+ } else if (commandID == "cmd_fontFace" && newState == "") {
+ goDoCommandParams(commandID, newState);
+ } else {
+ GetCurrentEditorElement().contentDocument.execCommand(
+ gCommandMap.get(commandID),
+ false,
+ newState
+ );
+ }
+
+ if (updateUI) {
+ let commandNode = document.getElementById(commandID);
+ commandNode.setAttribute("state", newState);
+ switch (commandID) {
+ case "cmd_fontFace": {
+ onFontFaceChange();
+ break;
+ }
+ }
+ } else {
+ let commandNode = document.getElementById(commandID);
+ if (commandNode) {
+ commandNode.setAttribute("state", newState);
+ }
+ }
+}
+
+var nsDummyHTMLCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // do nothing
+ dump("Hey, who's calling the dummy command?\n");
+ },
+};
+
+// ------- output utilities ----- //
+
+// returns a fileExtension string
+function GetExtensionBasedOnMimeType(aMIMEType) {
+ try {
+ var mimeService = null;
+ mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ var fileExtension = mimeService.getPrimaryExtension(aMIMEType, null);
+
+ // the MIME service likes to give back ".htm" for text/html files,
+ // so do a special-case fix here.
+ if (fileExtension == "htm") {
+ fileExtension = "html";
+ }
+
+ return fileExtension;
+ } catch (e) {}
+ return "";
+}
+
+function GetSuggestedFileName(aDocumentURLString, aMIMEType) {
+ var extension = GetExtensionBasedOnMimeType(aMIMEType);
+ if (extension) {
+ extension = "." + extension;
+ }
+
+ // check for existing file name we can use
+ if (aDocumentURLString && !IsUrlAboutBlank(aDocumentURLString)) {
+ try {
+ let docURI = Services.io.newURI(
+ aDocumentURLString,
+ GetCurrentEditor().documentCharacterSet
+ );
+ docURI = docURI.QueryInterface(Ci.nsIURL);
+
+ // grab the file name
+ let url = validateFileName(decodeURIComponent(docURI.fileBaseName));
+ if (url) {
+ return url + extension;
+ }
+ } catch (e) {}
+ }
+
+ // Check if there is a title we can use to generate a valid filename,
+ // if we can't, use the default filename.
+ var title =
+ validateFileName(GetDocumentTitle()) ||
+ GetString("untitledDefaultFilename");
+ return title + extension;
+}
+
+/**
+ * @returns {Promise} dialogResult
+ */
+function PromptForSaveLocation(
+ aDoSaveAsText,
+ aEditorType,
+ aMIMEType,
+ aDocumentURLString
+) {
+ var dialogResult = {};
+ dialogResult.filepickerClick = Ci.nsIFilePicker.returnCancel;
+ dialogResult.resultingURI = "";
+ dialogResult.resultingLocalFile = null;
+
+ var fp = null;
+ try {
+ fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ } catch (e) {}
+ if (!fp) {
+ return dialogResult;
+ }
+
+ // determine prompt string based on type of saving we'll do
+ var promptString;
+ if (aDoSaveAsText || aEditorType == "text") {
+ promptString = GetString("SaveTextAs");
+ } else {
+ promptString = GetString("SaveDocumentAs");
+ }
+
+ fp.init(window, promptString, Ci.nsIFilePicker.modeSave);
+
+ // Set filters according to the type of output
+ if (aDoSaveAsText) {
+ fp.appendFilters(Ci.nsIFilePicker.filterText);
+ } else {
+ fp.appendFilters(Ci.nsIFilePicker.filterHTML);
+ }
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ // now let's actually set the filepicker's suggested filename
+ var suggestedFileName = GetSuggestedFileName(aDocumentURLString, aMIMEType);
+ if (suggestedFileName) {
+ fp.defaultString = suggestedFileName;
+ }
+
+ // set the file picker's current directory
+ // assuming we have information needed (like prior saved location)
+ try {
+ var fileHandler = GetFileProtocolHandler();
+
+ var isLocalFile = true;
+ try {
+ let docURI = Services.io.newURI(
+ aDocumentURLString,
+ GetCurrentEditor().documentCharacterSet
+ );
+ isLocalFile = docURI.schemeIs("file");
+ } catch (e) {}
+
+ var parentLocation = null;
+ if (isLocalFile) {
+ var fileLocation = fileHandler.getFileFromURLSpec(aDocumentURLString); // this asserts if url is not local
+ parentLocation = fileLocation.parent;
+ }
+ if (parentLocation) {
+ // Save current filepicker's default location
+ if ("gFilePickerDirectory" in window) {
+ gFilePickerDirectory = fp.displayDirectory;
+ }
+
+ fp.displayDirectory = parentLocation;
+ } else {
+ // Initialize to the last-used directory for the particular type (saved in prefs)
+ SetFilePickerDirectory(fp, aEditorType);
+ }
+ } catch (e) {}
+
+ return new Promise(resolve => {
+ fp.open(rv => {
+ dialogResult.filepickerClick = rv;
+ if (rv != Ci.nsIFilePicker.returnCancel && fp.file) {
+ // Allow OK and replace.
+ // reset urlstring to new save location
+ dialogResult.resultingURIString = fileHandler.getURLSpecFromActualFile(
+ fp.file
+ );
+ dialogResult.resultingLocalFile = fp.file;
+ SaveFilePickerDirectory(fp, aEditorType);
+ resolve(dialogResult);
+ } else if ("gFilePickerDirectory" in window && gFilePickerDirectory) {
+ fp.displayDirectory = gFilePickerDirectory;
+ resolve(null);
+ }
+ });
+ });
+}
+
+/**
+ * If needed, prompt for document title and set the document title to the
+ * preferred value.
+ *
+ * @returns true if the title was set up successfully;
+ * false if the user cancelled the title prompt
+ */
+function PromptAndSetTitleIfNone() {
+ if (GetDocumentTitle()) {
+ // we have a title; no need to prompt!
+ return true;
+ }
+
+ let result = { value: null };
+ let captionStr = GetString("DocumentTitle");
+ let msgStr = GetString("NeedDocTitle") + "\n" + GetString("DocTitleHelp");
+ let confirmed = Services.prompt.prompt(
+ window,
+ captionStr,
+ msgStr,
+ result,
+ null,
+ { value: 0 }
+ );
+ if (confirmed) {
+ SetDocumentTitle(TrimString(result.value));
+ }
+
+ return confirmed;
+}
+
+var gPersistObj;
+
+// Don't forget to do these things after calling OutputFileWithPersistAPI:
+// we need to update the uri before notifying listeners
+// UpdateWindowTitle();
+// if (!aSaveCopy)
+// editor.resetModificationCount();
+// this should cause notification to listeners that document has changed
+
+function OutputFileWithPersistAPI(
+ editorDoc,
+ aDestinationLocation,
+ aRelatedFilesParentDir,
+ aMimeType
+) {
+ gPersistObj = null;
+ var editor = GetCurrentEditor();
+ try {
+ editor.forceCompositionEnd();
+ } catch (e) {}
+
+ var isLocalFile = false;
+ try {
+ aDestinationLocation.QueryInterface(Ci.nsIFile);
+ isLocalFile = true;
+ } catch (e) {
+ try {
+ var tmp = aDestinationLocation.QueryInterface(Ci.nsIURI);
+ isLocalFile = tmp.schemeIs("file");
+ } catch (e) {}
+ }
+
+ try {
+ // we should supply a parent directory if/when we turn on functionality to save related documents
+ var persistObj = Cc[
+ "@mozilla.org/embedding/browser/nsWebBrowserPersist;1"
+ ].createInstance(Ci.nsIWebBrowserPersist);
+ persistObj.progressListener = gEditorOutputProgressListener;
+
+ var wrapColumn = GetWrapColumn();
+ var outputFlags = GetOutputFlags(aMimeType, wrapColumn);
+
+ // for 4.x parity as well as improving readability of file locally on server
+ // this will always send crlf for upload (http/ftp)
+ if (!isLocalFile) {
+ // if we aren't saving locally then send both cr and lf
+ outputFlags |=
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_CR_LINEBREAKS |
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_LF_LINEBREAKS;
+
+ // we want to serialize the output for all remote publishing
+ // some servers can handle only one connection at a time
+ // some day perhaps we can make this user-configurable per site?
+ persistObj.persistFlags =
+ persistObj.persistFlags |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_SERIALIZE_OUTPUT;
+ }
+
+ // note: we always want to set the replace existing files flag since we have
+ // already given user the chance to not replace an existing file (file picker)
+ // or the user picked an option where the file is implicitly being replaced (save)
+ persistObj.persistFlags =
+ persistObj.persistFlags |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_BASE_TAG_MODIFICATIONS |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_DONT_FIXUP_LINKS |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_DONT_CHANGE_FILENAMES |
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_FIXUP_ORIGINAL_DOM;
+ persistObj.saveDocument(
+ editorDoc,
+ aDestinationLocation,
+ aRelatedFilesParentDir,
+ aMimeType,
+ outputFlags,
+ wrapColumn
+ );
+ gPersistObj = persistObj;
+ } catch (e) {
+ dump("caught an error, bail\n");
+ return false;
+ }
+
+ return true;
+}
+
+// returns output flags based on mimetype, wrapCol and prefs
+function GetOutputFlags(aMimeType, aWrapColumn) {
+ var outputFlags = 0;
+ var editor = GetCurrentEditor();
+ var outputEntity =
+ editor && editor.documentCharacterSet == "ISO-8859-1"
+ ? Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_LATIN1_ENTITIES
+ : Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES;
+ if (aMimeType == "text/plain") {
+ // When saving in "text/plain" format, always do formatting
+ outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_FORMATTED;
+ } else {
+ // Should we prettyprint? Check the pref
+ if (Services.prefs.getBoolPref("editor.prettyprint")) {
+ outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_FORMATTED;
+ }
+
+ try {
+ // How much entity names should we output? Check the pref
+ switch (Services.prefs.getCharPref("editor.encode_entity")) {
+ case "basic":
+ outputEntity =
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES;
+ break;
+ case "latin1":
+ outputEntity =
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_LATIN1_ENTITIES;
+ break;
+ case "html":
+ outputEntity =
+ Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_HTML_ENTITIES;
+ break;
+ case "none":
+ outputEntity = 0;
+ break;
+ }
+ } catch (e) {}
+ }
+ outputFlags |= outputEntity;
+
+ if (aWrapColumn > 0) {
+ outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_WRAP;
+ }
+
+ return outputFlags;
+}
+
+// returns number of column where to wrap
+function GetWrapColumn() {
+ try {
+ return GetCurrentEditor().wrapWidth;
+ } catch (e) {}
+ return 0;
+}
+
+const gShowDebugOutputStateChange = false;
+const gShowDebugOutputProgress = false;
+const gShowDebugOutputStatusChange = false;
+
+const gShowDebugOutputLocationChange = false;
+const gShowDebugOutputSecurityChange = false;
+
+const kErrorBindingAborted = 2152398850;
+const kErrorBindingRedirected = 2152398851;
+const kFileNotFound = 2152857618;
+
+var gEditorOutputProgressListener = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ // Use this to access onStateChange flags
+ var requestSpec;
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ requestSpec = StripUsernamePasswordFromURI(channel.URI);
+ } catch (e) {
+ if (gShowDebugOutputStateChange) {
+ dump("***** onStateChange; NO REQUEST CHANNEL\n");
+ }
+ }
+
+ if (gShowDebugOutputStateChange) {
+ dump("\n***** onStateChange request: " + requestSpec + "\n");
+ dump(" state flags: ");
+
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ dump(" STATE_START, ");
+ }
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ dump(" STATE_STOP, ");
+ }
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) {
+ dump(" STATE_IS_NETWORK ");
+ }
+
+ dump(`\n * requestSpec=${requestSpec}, aStatus=${aStatus}\n`);
+
+ DumpDebugStatus(aStatus);
+ }
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ if (!gPersistObj) {
+ return;
+ }
+
+ if (gShowDebugOutputProgress) {
+ dump(
+ "\n onProgressChange: gPersistObj.result=" + gPersistObj.result + "\n"
+ );
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** onProgressChange request: " + channel.URI.spec + "\n");
+ } catch (e) {}
+ dump(
+ "***** self: " +
+ aCurSelfProgress +
+ " / " +
+ aMaxSelfProgress +
+ "\n"
+ );
+ dump(
+ "***** total: " +
+ aCurTotalProgress +
+ " / " +
+ aMaxTotalProgress +
+ "\n\n"
+ );
+
+ if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_READY) {
+ dump(" Persister is ready to save data\n\n");
+ } else if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_SAVING) {
+ dump(" Persister is saving data.\n\n");
+ } else if (
+ gPersistObj.currentState == gPersistObj.PERSIST_STATE_FINISHED
+ ) {
+ dump(" PERSISTER HAS FINISHED SAVING DATA\n\n\n");
+ }
+ }
+ },
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (gShowDebugOutputLocationChange) {
+ dump("***** onLocationChange: " + aLocation.spec + "\n");
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** request: " + channel.URI.spec + "\n");
+ } catch (e) {}
+ }
+ },
+
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (gShowDebugOutputStatusChange) {
+ dump("***** onStatusChange: " + aMessage + "\n");
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** request: " + channel.URI.spec + "\n");
+ } catch (e) {
+ dump(" couldn't get request\n");
+ }
+
+ DumpDebugStatus(aStatus);
+
+ if (gPersistObj) {
+ if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_READY) {
+ dump(" Persister is ready to save data\n\n");
+ } else if (
+ gPersistObj.currentState == gPersistObj.PERSIST_STATE_SAVING
+ ) {
+ dump(" Persister is saving data.\n\n");
+ } else if (
+ gPersistObj.currentState == gPersistObj.PERSIST_STATE_FINISHED
+ ) {
+ dump(" PERSISTER HAS FINISHED SAVING DATA\n\n\n");
+ }
+ }
+ }
+ },
+
+ onSecurityChange(aWebProgress, aRequest, state) {
+ if (gShowDebugOutputSecurityChange) {
+ try {
+ var channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("***** onSecurityChange request: " + channel.URI.spec + "\n");
+ } catch (e) {}
+ }
+ },
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {},
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+/* eslint-disable complexity */
+function DumpDebugStatus(aStatus) {
+ // see nsError.h and netCore.h and ftpCore.h
+
+ if (aStatus == kErrorBindingAborted) {
+ dump("***** status is NS_BINDING_ABORTED\n");
+ } else if (aStatus == kErrorBindingRedirected) {
+ dump("***** status is NS_BINDING_REDIRECTED\n");
+ } else if (aStatus == 2152398859) {
+ // in netCore.h 11
+ dump("***** status is ALREADY_CONNECTED\n");
+ } else if (aStatus == 2152398860) {
+ // in netCore.h 12
+ dump("***** status is NOT_CONNECTED\n");
+ } else if (aStatus == 2152398861) {
+ // in nsISocketTransportService.idl 13
+ dump("***** status is CONNECTION_REFUSED\n");
+ } else if (aStatus == 2152398862) {
+ // in nsISocketTransportService.idl 14
+ dump("***** status is NET_TIMEOUT\n");
+ } else if (aStatus == 2152398863) {
+ // in netCore.h 15
+ dump("***** status is IN_PROGRESS\n");
+ } else if (aStatus == 2152398864) {
+ // 0x804b0010 in netCore.h 16
+ dump("***** status is OFFLINE\n");
+ } else if (aStatus == 2152398865) {
+ // in netCore.h 17
+ dump("***** status is NO_CONTENT\n");
+ } else if (aStatus == 2152398866) {
+ // in netCore.h 18
+ dump("***** status is UNKNOWN_PROTOCOL\n");
+ } else if (aStatus == 2152398867) {
+ // in netCore.h 19
+ dump("***** status is PORT_ACCESS_NOT_ALLOWED\n");
+ } else if (aStatus == 2152398868) {
+ // in nsISocketTransportService.idl 20
+ dump("***** status is NET_RESET\n");
+ } else if (aStatus == 2152398869) {
+ // in ftpCore.h 21
+ dump("***** status is FTP_LOGIN\n");
+ } else if (aStatus == 2152398870) {
+ // in ftpCore.h 22
+ dump("***** status is FTP_CWD\n");
+ } else if (aStatus == 2152398871) {
+ // in ftpCore.h 23
+ dump("***** status is FTP_PASV\n");
+ } else if (aStatus == 2152398872) {
+ // in ftpCore.h 24
+ dump("***** status is FTP_PWD\n");
+ } else if (aStatus == 2152857601) {
+ dump("***** status is UNRECOGNIZED_PATH\n");
+ } else if (aStatus == 2152857602) {
+ dump("***** status is UNRESOLABLE SYMLINK\n");
+ } else if (aStatus == 2152857604) {
+ dump("***** status is UNKNOWN_TYPE\n");
+ } else if (aStatus == 2152857605) {
+ dump("***** status is DESTINATION_NOT_DIR\n");
+ } else if (aStatus == 2152857606) {
+ dump("***** status is TARGET_DOES_NOT_EXIST\n");
+ } else if (aStatus == 2152857608) {
+ dump("***** status is ALREADY_EXISTS\n");
+ } else if (aStatus == 2152857609) {
+ dump("***** status is INVALID_PATH\n");
+ } else if (aStatus == 2152857610) {
+ dump("***** status is DISK_FULL\n");
+ } else if (aStatus == 2152857612) {
+ dump("***** status is NOT_DIRECTORY\n");
+ } else if (aStatus == 2152857613) {
+ dump("***** status is IS_DIRECTORY\n");
+ } else if (aStatus == 2152857614) {
+ dump("***** status is IS_LOCKED\n");
+ } else if (aStatus == 2152857615) {
+ dump("***** status is TOO_BIG\n");
+ } else if (aStatus == 2152857616) {
+ dump("***** status is NO_DEVICE_SPACE\n");
+ } else if (aStatus == 2152857617) {
+ dump("***** status is NAME_TOO_LONG\n");
+ } else if (aStatus == 2152857618) {
+ // 80520012
+ dump("***** status is FILE_NOT_FOUND\n");
+ } else if (aStatus == 2152857619) {
+ dump("***** status is READ_ONLY\n");
+ } else if (aStatus == 2152857620) {
+ dump("***** status is DIR_NOT_EMPTY\n");
+ } else if (aStatus == 2152857621) {
+ dump("***** status is ACCESS_DENIED\n");
+ } else if (aStatus == 2152398878) {
+ dump("***** status is ? (No connection or time out?)\n");
+ } else {
+ dump("***** status is " + aStatus + "\n");
+ }
+}
+/* eslint-enable complexity */
+
+const kSupportedTextMimeTypes = [
+ "text/plain",
+ "text/css",
+ "text/rdf",
+ "text/xsl",
+ "text/javascript", // obsolete type
+ "text/ecmascript", // obsolete type
+ "application/javascript",
+ "application/ecmascript",
+ "application/x-javascript", // obsolete type
+ "application/xhtml+xml",
+];
+
+function IsSupportedTextMimeType(aMimeType) {
+ for (var i = 0; i < kSupportedTextMimeTypes.length; i++) {
+ if (kSupportedTextMimeTypes[i] == aMimeType) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/* eslint-disable complexity */
+// throws an error or returns true if user attempted save; false if user canceled save
+async function SaveDocument(aSaveAs, aSaveCopy, aMimeType) {
+ var editor = GetCurrentEditor();
+ if (!aMimeType || !editor) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ var editorDoc = editor.document;
+ if (!editorDoc) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ // if we don't have the right editor type bail (we handle text and html)
+ var editorType = GetCurrentEditorType();
+ if (!["text", "html", "htmlmail", "textmail"].includes(editorType)) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ var saveAsTextFile = IsSupportedTextMimeType(aMimeType);
+
+ // check if the file is to be saved is a format we don't understand; if so, bail
+ if (
+ aMimeType != kHTMLMimeType &&
+ aMimeType != kXHTMLMimeType &&
+ !saveAsTextFile
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ if (saveAsTextFile) {
+ aMimeType = "text/plain";
+ }
+
+ var urlstring = GetDocumentUrl();
+ var mustShowFileDialog =
+ aSaveAs || IsUrlAboutBlank(urlstring) || urlstring == "";
+
+ // If editing a remote URL, force SaveAs dialog
+ if (!mustShowFileDialog && GetScheme(urlstring) != "file") {
+ mustShowFileDialog = true;
+ }
+
+ var doUpdateURI = false;
+ var tempLocalFile = null;
+
+ if (mustShowFileDialog) {
+ try {
+ // Prompt for title if we are saving to HTML
+ if (!saveAsTextFile && editorType == "html") {
+ var userContinuing = PromptAndSetTitleIfNone(); // not cancel
+ if (!userContinuing) {
+ return false;
+ }
+ }
+
+ var dialogResult = await PromptForSaveLocation(
+ saveAsTextFile,
+ editorType,
+ aMimeType,
+ urlstring
+ );
+ if (!dialogResult) {
+ return false;
+ }
+
+ // What is this unused 'replacing' var supposed to be doing?
+ /* eslint-disable-next-line no-unused-vars */
+ var replacing =
+ dialogResult.filepickerClick == Ci.nsIFilePicker.returnReplace;
+
+ urlstring = dialogResult.resultingURIString;
+ tempLocalFile = dialogResult.resultingLocalFile;
+
+ // update the new URL for the webshell unless we are saving a copy
+ if (!aSaveCopy) {
+ doUpdateURI = true;
+ }
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ } // mustShowFileDialog
+
+ var success = true;
+ try {
+ // if somehow we didn't get a local file but we did get a uri,
+ // attempt to create the localfile if it's a "file" url
+ var docURI;
+ if (!tempLocalFile) {
+ docURI = Services.io.newURI(urlstring, editor.documentCharacterSet);
+
+ if (docURI.schemeIs("file")) {
+ var fileHandler = GetFileProtocolHandler();
+ tempLocalFile = fileHandler
+ .getFileFromURLSpec(urlstring)
+ .QueryInterface(Ci.nsIFile);
+ }
+ }
+
+ // this is the location where the related files will go
+ var relatedFilesDir = null;
+
+ // Only change links or move files if pref is set
+ // and we are saving to a new location
+ if (Services.prefs.getBoolPref("editor.save_associated_files") && aSaveAs) {
+ try {
+ if (tempLocalFile) {
+ // if we are saving to the same parent directory, don't set relatedFilesDir
+ // grab old location, chop off file
+ // grab new location, chop off file, compare
+ var oldLocation = GetDocumentUrl();
+ var oldLocationLastSlash = oldLocation.lastIndexOf("/");
+ if (oldLocationLastSlash != -1) {
+ oldLocation = oldLocation.slice(0, oldLocationLastSlash);
+ }
+
+ var relatedFilesDirStr = urlstring;
+ var newLocationLastSlash = relatedFilesDirStr.lastIndexOf("/");
+ if (newLocationLastSlash != -1) {
+ relatedFilesDirStr = relatedFilesDirStr.slice(
+ 0,
+ newLocationLastSlash
+ );
+ }
+ if (
+ oldLocation == relatedFilesDirStr ||
+ IsUrlAboutBlank(oldLocation)
+ ) {
+ relatedFilesDir = null;
+ } else {
+ relatedFilesDir = tempLocalFile.parent;
+ }
+ } else {
+ var lastSlash = urlstring.lastIndexOf("/");
+ if (lastSlash != -1) {
+ var relatedFilesDirString = urlstring.slice(0, lastSlash + 1); // include last slash
+ relatedFilesDir = Services.io.newURI(
+ relatedFilesDirString,
+ editor.documentCharacterSet
+ );
+ }
+ }
+ } catch (e) {
+ relatedFilesDir = null;
+ }
+ }
+
+ let destinationLocation = tempLocalFile ? tempLocalFile : docURI;
+
+ success = OutputFileWithPersistAPI(
+ editorDoc,
+ destinationLocation,
+ relatedFilesDir,
+ aMimeType
+ );
+ } catch (e) {
+ success = false;
+ }
+
+ if (success) {
+ try {
+ if (doUpdateURI) {
+ // If a local file, we must create a new uri from nsIFile
+ if (tempLocalFile) {
+ docURI = GetFileProtocolHandler().newFileURI(tempLocalFile);
+ }
+ }
+
+ // Update window title to show possibly different filename
+ // This also covers problem that after undoing a title change,
+ // window title loses the extra [filename] part that this adds
+ UpdateWindowTitle();
+
+ if (!aSaveCopy) {
+ editor.resetModificationCount();
+ }
+ // this should cause notification to listeners that document has changed
+
+ // Set UI based on whether we're editing a remote or local url
+ goUpdateCommand("cmd_save");
+ } catch (e) {}
+ } else {
+ Services.prompt.alert(
+ window,
+ GetString("SaveDocument"),
+ GetString("SaveFileFailed")
+ );
+ }
+ return success;
+}
+/* eslint-enable complexity */
+
+var nsFindReplaceCommand = {
+ isCommandEnabled(aCommand, editorElement) {
+ return editorElement.getEditor(editorElement.contentWindow) != null;
+ },
+
+ getCommandStateParams(aCommand, aParams, editorElement) {},
+ doCommandParams(aCommand, aParams, editorElement) {},
+
+ doCommand(aCommand, editorElement) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdReplace.xhtml",
+ "_blank",
+ "chrome,modal,titlebar",
+ editorElement
+ );
+ },
+};
+
+var nsFindCommand = {
+ isCommandEnabled(aCommand, editorElement) {
+ return editorElement.getEditor(editorElement.contentWindow) != null;
+ },
+
+ getCommandStateParams(aCommand, aParams, editorElement) {},
+ doCommandParams(aCommand, aParams, editorElement) {},
+
+ doCommand(aCommand, editorElement) {
+ document.getElementById("FindToolbar").onFindCommand();
+ },
+};
+
+var nsFindAgainCommand = {
+ isCommandEnabled(aCommand, editorElement) {
+ // we can only do this if the search pattern is non-empty. Not sure how
+ // to get that from here
+ return editorElement.getEditor(editorElement.contentWindow) != null;
+ },
+
+ getCommandStateParams(aCommand, aParams, editorElement) {},
+ doCommandParams(aCommand, aParams, editorElement) {},
+
+ doCommand(aCommand, editorElement) {
+ let findPrev = aCommand == "cmd_findPrev";
+ document.getElementById("FindToolbar").onFindAgainCommand(findPrev);
+ },
+};
+
+var nsRewrapCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return (
+ IsDocumentEditable() &&
+ !IsInHTMLSourceMode() &&
+ GetCurrentEditor() instanceof Ci.nsIEditorMailSupport
+ );
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ GetCurrentEditor().QueryInterface(Ci.nsIEditorMailSupport).rewrap(false);
+ },
+};
+
+var nsSpellingCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return (
+ IsDocumentEditable() && !IsInHTMLSourceMode() && IsSpellCheckerInstalled()
+ );
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.cancelSendMessage = false;
+ try {
+ var skipBlockQuotes =
+ window.document.documentElement.getAttribute("windowtype") ==
+ "msgcompose";
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml",
+ "_blank",
+ "dialog,close,titlebar,modal,resizable",
+ false,
+ skipBlockQuotes,
+ true
+ );
+ } catch (ex) {}
+ },
+};
+
+var nsImageCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdImageProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ },
+};
+
+var nsHLineCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // Inserting an HLine is different in that we don't use properties dialog
+ // unless we are editing an existing line's attributes
+ // We get the last-used attributes from the prefs and insert immediately
+
+ var tagName = "hr";
+ var editor = GetCurrentEditor();
+
+ var hLine;
+ try {
+ hLine = editor.getSelectedElement(tagName);
+ } catch (e) {
+ return;
+ }
+
+ if (hLine) {
+ // We only open the dialog for an existing HRule
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdHLineProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ } else {
+ try {
+ hLine = editor.createElementWithDefaults(tagName);
+
+ // We change the default attributes to those saved in the user prefs
+ let align = Services.prefs.getIntPref("editor.hrule.align");
+ if (align == 0) {
+ editor.setAttributeOrEquivalent(hLine, "align", "left", true);
+ } else if (align == 2) {
+ editor.setAttributeOrEquivalent(hLine, "align", "right", true);
+ }
+
+ // Note: Default is center (don't write attribute)
+
+ let width = Services.prefs.getIntPref("editor.hrule.width");
+ if (Services.prefs.getBoolPref("editor.hrule.width_percent")) {
+ width = width + "%";
+ }
+
+ editor.setAttributeOrEquivalent(hLine, "width", width, true);
+
+ let height = Services.prefs.getIntPref("editor.hrule.height");
+ editor.setAttributeOrEquivalent(hLine, "size", String(height), true);
+
+ if (Services.prefs.getBoolPref("editor.hrule.shading")) {
+ hLine.removeAttribute("noshade");
+ } else {
+ hLine.setAttribute("noshade", "noshade");
+ }
+
+ editor.insertElementAtSelection(hLine, true);
+ } catch (e) {}
+ }
+ },
+};
+
+var nsLinkCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // If selected element is an image, launch that dialog instead
+ // since last tab panel handles link around an image
+ var element = GetObjectForProperties();
+ if (element && element.nodeName.toLowerCase() == "img") {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdImageProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ null,
+ true
+ );
+ } else {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdLinkProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ }
+ },
+};
+
+var nsAnchorCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdNamedAnchorProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ ""
+ );
+ },
+};
+
+var nsInsertHTMLWithDialogCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ gMsgCompose.allowRemoteContent = true;
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsSrc.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable",
+ ""
+ );
+ },
+};
+
+var nsInsertMathWithDialogCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertMath.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable",
+ ""
+ );
+ },
+};
+
+var nsInsertCharsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorFindOrCreateInsertCharWindow();
+ },
+};
+
+var nsInsertBreakAllCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentEditor().insertHTML("<br clear='all'>");
+ } catch (e) {}
+ },
+};
+
+var nsListPropertiesCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdListProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ },
+};
+
+var nsObjectPropertiesCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ var isEnabled = false;
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ isEnabled =
+ GetObjectForProperties() != null ||
+ GetCurrentEditor().getSelectedElement("href") != null;
+ }
+ return isEnabled;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // Launch Object properties for appropriate selected element
+ var element = GetObjectForProperties();
+ if (element) {
+ var name = element.nodeName.toLowerCase();
+ switch (name) {
+ case "img":
+ gMsgCompose.allowRemoteContent = true;
+ goDoCommand("cmd_image");
+ break;
+ case "hr":
+ goDoCommand("cmd_hline");
+ break;
+ case "table":
+ EditorInsertOrEditTable(false);
+ break;
+ case "td":
+ case "th":
+ EditorTableCellProperties();
+ break;
+ case "ol":
+ case "ul":
+ case "dl":
+ case "li":
+ goDoCommand("cmd_listProperties");
+ break;
+ case "a":
+ if (element.name) {
+ goDoCommand("cmd_anchor");
+ } else if (element.href) {
+ goDoCommand("cmd_link");
+ }
+ break;
+ case "math":
+ goDoCommand("cmd_insertMathWithDialog");
+ break;
+ default:
+ doAdvancedProperties(element);
+ break;
+ }
+ } else {
+ // We get a partially-selected link if asked for specifically
+ try {
+ element = GetCurrentEditor().getSelectedElement("href");
+ } catch (e) {}
+ if (element) {
+ goDoCommand("cmd_link");
+ }
+ }
+ },
+};
+
+var nsSetSmiley = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {
+ try {
+ let editor = GetCurrentEditor();
+ let smileyCode = aParams.getStringValue("state_attribute");
+ editor.insertHTML(smileyCode);
+ window.content.focus();
+ } catch (e) {
+ dump("Exception occurred in smiley InsertElementAtSelection\n");
+ }
+ },
+ // This is now deprecated in favor of "doCommandParams"
+ doCommand(aCommand) {},
+};
+
+function doAdvancedProperties(element) {
+ if (element) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable=yes",
+ "",
+ element
+ );
+ }
+}
+
+var nsColorPropertiesCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ ""
+ );
+ UpdateDefaultColors();
+ },
+};
+
+var nsIncreaseFontCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (!(IsDocumentEditable() && IsEditingRenderedHTML())) {
+ return false;
+ }
+ let setIndex = parseInt(getLegacyFontSize());
+ return setIndex < 6;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ let setIndex = parseInt(getLegacyFontSize());
+ EditorSetFontSize((setIndex + 1).toString());
+ },
+};
+
+var nsDecreaseFontCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (!(IsDocumentEditable() && IsEditingRenderedHTML())) {
+ return false;
+ }
+ let setIndex = parseInt(getLegacyFontSize());
+ return setIndex > 1;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ let setIndex = parseInt(getLegacyFontSize());
+ EditorSetFontSize((setIndex - 1).toString());
+ },
+};
+
+var nsRemoveNamedAnchorsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ // We could see if there's any link in selection, but it doesn't seem worth the work!
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorRemoveTextProperty("name", "");
+ window.content.focus();
+ },
+};
+
+var nsInsertOrEditTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ if (IsInTableCell()) {
+ EditorTableCellProperties();
+ } else {
+ EditorInsertOrEditTable(true);
+ }
+ },
+};
+
+var nsEditTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorInsertOrEditTable(false);
+ },
+};
+
+var nsSelectTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTable();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectTableRowCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTableRow();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectTableColumnCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTableColumn();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectTableCellCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectTableCell();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSelectAllTableCellsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().selectAllTableCells();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsDocumentEditable() && IsEditingRenderedHTML();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorInsertTable();
+ },
+};
+
+var nsInsertTableRowAboveCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableRow(1, false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableRowBelowCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableRow(1, true);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableColumnBeforeCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableColumn(1, false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableColumnAfterCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableColumn(1, true);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableCellBeforeCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableCell(1, false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsInsertTableCellAfterCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().insertTableCell(1, true);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().deleteTable();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableRowCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ var rows = GetNumberOfContiguousSelectedRows();
+ // Delete at least one row
+ if (rows == 0) {
+ rows = 1;
+ }
+
+ try {
+ var editor = GetCurrentTableEditor();
+ editor.beginTransaction();
+
+ // Loop to delete all blocks of contiguous, selected rows
+ while (rows) {
+ editor.deleteTableRow(rows);
+ rows = GetNumberOfContiguousSelectedRows();
+ }
+ } finally {
+ editor.endTransaction();
+ }
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableColumnCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ var columns = GetNumberOfContiguousSelectedColumns();
+ // Delete at least one column
+ if (columns == 0) {
+ columns = 1;
+ }
+
+ try {
+ var editor = GetCurrentTableEditor();
+ editor.beginTransaction();
+
+ // Loop to delete all blocks of contiguous, selected columns
+ while (columns) {
+ editor.deleteTableColumn(columns);
+ columns = GetNumberOfContiguousSelectedColumns();
+ }
+ } finally {
+ editor.endTransaction();
+ }
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableCellCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().deleteTableCell(1);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsDeleteTableCellContentsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTableCell();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().deleteTableCellContents();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsJoinTableCellsCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ try {
+ var editor = GetCurrentTableEditor();
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var cell = editor.getSelectedOrParentTableElement(tagNameObj, countObj);
+
+ // We need a cell and either > 1 selected cell or a cell to the right
+ // (this cell may originate in a row spanned from above current row)
+ // Note that editor returns "td" for "th" also.
+ // (this is a pain! Editor and gecko use lowercase tagNames, JS uses uppercase!)
+ if (cell && tagNameObj.value == "td") {
+ // Selected cells
+ if (countObj.value > 1) {
+ return true;
+ }
+
+ var colSpan = cell.getAttribute("colspan");
+
+ // getAttribute returns string, we need number
+ // no attribute means colspan = 1
+ if (!colSpan) {
+ colSpan = Number(1);
+ } else {
+ colSpan = Number(colSpan);
+ }
+
+ var rowObj = { value: 0 };
+ var colObj = { value: 0 };
+ editor.getCellIndexes(cell, rowObj, colObj);
+
+ // Test if cell exists to the right of current cell
+ // (cells with 0 span should never have cells to the right
+ // if there is, user can select the 2 cells to join them)
+ return (
+ colSpan &&
+ editor.getCellAt(null, rowObj.value, colObj.value + colSpan)
+ );
+ }
+ } catch (e) {}
+ }
+ return false;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ // Param: Don't merge non-contiguous cells
+ try {
+ GetCurrentTableEditor().joinTableCells(false);
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsSplitTableCellCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var cell;
+ try {
+ cell = GetCurrentTableEditor().getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ } catch (e) {}
+
+ // We need a cell parent and there's just 1 selected cell
+ // or selection is entirely inside 1 cell
+ if (
+ cell &&
+ tagNameObj.value == "td" &&
+ countObj.value <= 1 &&
+ IsSelectionInOneCell()
+ ) {
+ var colSpan = cell.getAttribute("colspan");
+ var rowSpan = cell.getAttribute("rowspan");
+ if (!colSpan) {
+ colSpan = 1;
+ }
+ if (!rowSpan) {
+ rowSpan = 1;
+ }
+ return colSpan > 1 || rowSpan > 1 || colSpan == 0 || rowSpan == 0;
+ }
+ }
+ return false;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ try {
+ GetCurrentTableEditor().splitTableCell();
+ } catch (e) {}
+ window.content.focus();
+ },
+};
+
+var nsTableOrCellColorCommand = {
+ isCommandEnabled(aCommand, dummy) {
+ return IsInTable();
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ EditorSelectColor("TableOrCell");
+ },
+};
+
+var nsConvertToTable = {
+ isCommandEnabled(aCommand, dummy) {
+ if (IsDocumentEditable() && IsEditingRenderedHTML()) {
+ var selection;
+ try {
+ selection = GetCurrentEditor().selection;
+ } catch (e) {}
+
+ if (selection && !selection.isCollapsed) {
+ // Don't allow if table or cell is the selection
+ var element;
+ try {
+ element = GetCurrentEditor().getSelectedElement("");
+ } catch (e) {}
+ if (element) {
+ var name = element.nodeName.toLowerCase();
+ if (
+ name == "td" ||
+ name == "th" ||
+ name == "caption" ||
+ name == "table"
+ ) {
+ return false;
+ }
+ }
+
+ // Selection start and end must be in the same cell
+ // in same cell or both are NOT in a cell
+ if (
+ GetParentTableCell(selection.focusNode) !=
+ GetParentTableCell(selection.anchorNode)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+ }
+ return false;
+ },
+
+ getCommandStateParams(aCommand, aParams, aRefCon) {},
+ doCommandParams(aCommand, aParams, aRefCon) {},
+
+ doCommand(aCommand) {
+ if (this.isCommandEnabled()) {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdConvertToTable.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal"
+ );
+ }
+ },
+};
diff --git a/comm/mail/components/compose/content/MsgComposeCommands.js b/comm/mail/components/compose/content/MsgComposeCommands.js
new file mode 100644
index 0000000000..6a0045b58d
--- /dev/null
+++ b/comm/mail/components/compose/content/MsgComposeCommands.js
@@ -0,0 +1,11654 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from ../../../../mailnews/addrbook/content/abDragDrop.js */
+/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */
+/* import-globals-from ../../../base/content/contentAreaClick.js */
+/* import-globals-from ../../../base/content/mailCore.js */
+/* import-globals-from ../../../base/content/messenger-customization.js */
+/* import-globals-from ../../../base/content/toolbarIconColor.js */
+/* import-globals-from ../../../base/content/utilityOverlay.js */
+/* import-globals-from ../../../base/content/viewZoomOverlay.js */
+/* import-globals-from ../../../base/content/widgets/browserPopups.js */
+/* import-globals-from ../../../extensions/openpgp/content/ui/keyAssistant.js */
+/* import-globals-from addressingWidgetOverlay.js */
+/* import-globals-from cloudAttachmentLinkManager.js */
+/* import-globals-from ComposerCommands.js */
+/* import-globals-from editor.js */
+/* import-globals-from editorUtilities.js */
+
+/**
+ * Commands for the message composition window.
+ */
+
+// Ensure the activity modules are loaded for this window.
+ChromeUtils.import("resource:///modules/activity/activityModules.jsm");
+var { AttachmentChecker } = ChromeUtils.import(
+ "resource:///modules/AttachmentChecker.jsm"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "l10nCompose",
+ () =>
+ new Localization([
+ "branding/brand.ftl",
+ "messenger/messengercompose/messengercompose.ftl",
+ ])
+);
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "l10nComposeSync",
+ () =>
+ new Localization(
+ ["branding/brand.ftl", "messenger/messengercompose/messengercompose.ftl"],
+ true
+ )
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+});
+
+/**
+ * Global message window object. This is used by mail-offline.js and therefore
+ * should not be renamed. We need to avoid doing this kind of cross file global
+ * stuff in the future and instead pass this object as parameter when needed by
+ * functions in the other js file.
+ */
+var msgWindow;
+
+var gMessenger;
+
+/**
+ * Global variables, need to be re-initialized every time mostly because
+ * we need to release them when the window closes.
+ */
+var gMsgCompose;
+var gOriginalMsgURI;
+var gWindowLocked;
+var gSendLocked;
+var gContentChanged;
+var gSubjectChanged;
+var gAutoSaving;
+var gCurrentIdentity;
+var defaultSaveOperation;
+var gSendOperationInProgress;
+var gSaveOperationInProgress;
+var gCloseWindowAfterSave;
+var gSavedSendNowKey;
+var gContextMenu;
+var gLastFocusElement = null;
+var gLoadingComplete = false;
+
+var gAttachmentBucket;
+var gAttachmentCounter;
+/**
+ * typedef {Object} FocusArea
+ *
+ * @property {Element} root - The root of a given area of the UI.
+ * @property {moveFocusWithin} focus - A method to move the focus within the
+ * root.
+ */
+/**
+ * @callback moveFocusWithin
+ *
+ * @param {Element} root - The element to move the focus within.
+ *
+ * @returns {boolean} - Whether the focus was successfully moved to within the
+ * given element.
+ */
+/**
+ * An ordered list of non-intersecting areas we want to jump focus between.
+ * Ordering should be in the same order as tab focus. See
+ * {@link moveFocusToNeighbouringArea}.
+ *
+ * @type {FocusArea[]}
+ */
+var gFocusAreas;
+// TODO: Maybe the following two variables can be combined.
+var gManualAttachmentReminder;
+var gDisableAttachmentReminder;
+var gComposeType;
+var gLanguageObserver;
+var gRecipientObserver;
+var gWantCannotEncryptBCCNotification = true;
+var gRecipientKeysObserver;
+var gCheckPublicRecipientsTimer;
+var gBodyFromArgs;
+
+// gSMFields is the nsIMsgComposeSecure instance for S/MIME.
+// gMsgCompose.compFields.composeSecure is set to this instance most of
+// the time. Because the S/MIME code has no knowledge of the OpenPGP
+// implementation, gMsgCompose.compFields.composeSecure is set to an
+// instance of PgpMimeEncrypt only temporarily. Keeping variable
+// gSMFields separate allows switching as needed.
+var gSMFields = null;
+
+var gSMPendingCertLookupSet = new Set();
+var gSMCertsAlreadyLookedUpInLDAP = new Set();
+
+var gSelectedTechnologyIsPGP = false;
+
+// The initial flags store the value we used at composer open time.
+// Some flags might be automatically changed as a consequence of other
+// changes. When reverting automatic actions, the initial flags help
+// us know what value we should use for restoring.
+
+var gSendSigned = false;
+
+var gAttachMyPublicPGPKey = false;
+
+var gSendEncrypted = false;
+
+// gEncryptSubject contains the preference for subject encryption,
+// considered only if encryption is enabled and the technology allows it.
+// In other words, gEncryptSubject might be set to true, but if
+// encryption is disabled, or if S/MIME is used,
+// gEncryptSubject==true is ignored.
+var gEncryptSubject = false;
+
+var gUserTouchedSendEncrypted = false;
+var gUserTouchedSendSigned = false;
+var gUserTouchedAttachMyPubKey = false;
+var gUserTouchedEncryptSubject = false;
+
+var gIsRelatedToEncryptedOriginal = false;
+
+var gOpened = Date.now();
+
+var gEncryptedURIService = Cc[
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1"
+].getService(Ci.nsIEncryptedSMIMEURIsService);
+
+try {
+ var gDragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+ );
+} catch (e) {}
+
+/**
+ * Boolean variable to keep track of the dragging action of files above the
+ * compose window.
+ *
+ * @type {boolean}
+ */
+var gIsDraggingAttachments;
+
+/**
+ * Boolean variable to allow showing the attach inline overlay when dragging
+ * links that otherwise would only trigger the add as attachment overlay.
+ *
+ * @type {boolean}
+ */
+var gIsValidInline;
+
+// i18n globals
+var _gComposeBundle;
+function getComposeBundle() {
+ // That one has to be lazy. Getting a reference to an element with a XBL
+ // binding attached will cause the XBL constructors to fire if they haven't
+ // already. If we get a reference to the compose bundle at script load-time,
+ // this will cause the XBL constructor that's responsible for the personas to
+ // fire up, thus executing the personas code while the DOM is not fully built.
+ // Since this <script> comes before the <statusbar>, the Personas code will
+ // fail.
+ if (!_gComposeBundle) {
+ _gComposeBundle = document.getElementById("bundle_composeMsgs");
+ }
+ return _gComposeBundle;
+}
+
+var gLastWindowToHaveFocus;
+var gLastKnownComposeStates;
+var gReceiptOptionChanged;
+var gDSNOptionChanged;
+var gAttachVCardOptionChanged;
+
+var gAutoSaveInterval;
+var gAutoSaveTimeout;
+var gAutoSaveKickedIn;
+var gEditingDraft;
+var gNumUploadingAttachments;
+
+// From the user's point-of-view, is spell checking enabled? This value only
+// changes if the user makes the change, it's not affected by the process of
+// sending or saving the message or any other reason the actual state of the
+// spellchecker might change.
+var gSpellCheckingEnabled;
+
+var kComposeAttachDirPrefName = "mail.compose.attach.dir";
+
+window.addEventListener("unload", event => {
+ ComposeUnload();
+});
+window.addEventListener("load", event => {
+ ComposeLoad();
+});
+window.addEventListener("close", event => {
+ if (!ComposeCanClose()) {
+ event.preventDefault();
+ }
+});
+window.addEventListener("focus", event => {
+ EditorOnFocus();
+});
+window.addEventListener("click", event => {
+ composeWindowOnClick(event);
+});
+
+document.addEventListener("focusin", event => {
+ // Listen for focusin event in composition. gLastFocusElement might well be
+ // null, e.g. when focusin enters a different document like contacts sidebar.
+ gLastFocusElement = event.relatedTarget;
+});
+
+// For WebExtensions.
+this.__defineGetter__("browser", GetCurrentEditorElement);
+
+/**
+ * @implements {nsIXULBrowserWindow}
+ */
+var XULBrowserWindow = {
+ // Used to show the link-being-hovered-over in the status bar. Do nothing here.
+ setOverLink(url, anchorElt) {},
+
+ // Called before links are navigated to to allow us to retarget them if needed.
+ onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) {
+ return originalTarget;
+ },
+
+ // Called by BrowserParent::RecvShowTooltip.
+ showTooltip(xDevPix, yDevPix, tooltip, direction, browser) {
+ if (
+ Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession()
+ ) {
+ return;
+ }
+
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.label = tooltip;
+ elt.style.direction = direction;
+ elt.openPopupAtScreen(
+ xDevPix / window.devicePixelRatio,
+ yDevPix / window.devicePixelRatio,
+ false,
+ null
+ );
+ },
+
+ // Called by BrowserParent::RecvHideTooltip.
+ hideTooltip() {
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.hidePopup();
+ },
+
+ getTabCount() {
+ return 1;
+ },
+};
+window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem)
+ .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow;
+
+// Observer for the autocomplete input.
+const inputObserver = {
+ observe: (subject, topic, data) => {
+ if (topic == "autocomplete-did-enter-text") {
+ let input = subject.QueryInterface(
+ Ci.nsIAutoCompleteInput
+ ).wrappedJSObject;
+
+ // Interrupt if there's no input proxy, or the input doesn't have an ID,
+ // the latter meaning that the autocomplete event was triggered within an
+ // already existing pill, so we don't want to create a new pill.
+ if (!input || !input.id) {
+ return;
+ }
+
+ // Trigger the pill creation.
+ recipientAddPills(document.getElementById(input.id));
+ }
+ },
+};
+
+const keyObserver = {
+ observe: async (subject, topic, data) => {
+ switch (topic) {
+ case "openpgp-key-change":
+ EnigmailKeyRing.clearCache();
+ // fall through
+ case "openpgp-acceptance-change":
+ checkEncryptionState(topic);
+ gKeyAssistant.onExternalKeyChange();
+ break;
+ default:
+ break;
+ }
+ },
+};
+
+// Non translatable international shortcuts.
+var SHOW_TO_KEY = "T";
+var SHOW_CC_KEY = "C";
+var SHOW_BCC_KEY = "B";
+
+function InitializeGlobalVariables() {
+ gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ gMsgCompose = null;
+ gOriginalMsgURI = null;
+ gWindowLocked = false;
+ gContentChanged = false;
+ gSubjectChanged = false;
+ gCurrentIdentity = null;
+ defaultSaveOperation = "draft";
+ gSendOperationInProgress = false;
+ gSaveOperationInProgress = false;
+ gAutoSaving = false;
+ gCloseWindowAfterSave = false;
+ gSavedSendNowKey = null;
+ gManualAttachmentReminder = false;
+ gDisableAttachmentReminder = false;
+ gLanguageObserver = null;
+ gRecipientObserver = null;
+
+ gLastWindowToHaveFocus = null;
+ gLastKnownComposeStates = {};
+ gReceiptOptionChanged = false;
+ gDSNOptionChanged = false;
+ gAttachVCardOptionChanged = false;
+ gNumUploadingAttachments = 0;
+ // eslint-disable-next-line no-global-assign
+ msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ MailServices.mailSession.AddMsgWindow(msgWindow);
+
+ // Add the observer.
+ Services.obs.addObserver(inputObserver, "autocomplete-did-enter-text");
+ Services.obs.addObserver(keyObserver, "openpgp-key-change");
+ Services.obs.addObserver(keyObserver, "openpgp-acceptance-change");
+}
+InitializeGlobalVariables();
+
+function ReleaseGlobalVariables() {
+ gCurrentIdentity = null;
+ gMsgCompose = null;
+ gOriginalMsgURI = null;
+ gMessenger = null;
+ gRecipientObserver = null;
+ gDisableAttachmentReminder = false;
+ _gComposeBundle = null;
+ MailServices.mailSession.RemoveMsgWindow(msgWindow);
+ // eslint-disable-next-line no-global-assign
+ msgWindow = null;
+
+ gLastKnownComposeStates = null;
+
+ // Remove the observers.
+ Services.obs.removeObserver(inputObserver, "autocomplete-did-enter-text");
+ Services.obs.removeObserver(keyObserver, "openpgp-key-change");
+ Services.obs.removeObserver(keyObserver, "openpgp-acceptance-change");
+}
+
+// Notification box shown at the bottom of the window.
+XPCOMUtils.defineLazyGetter(this, "gComposeNotification", () => {
+ return new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document.getElementById("compose-notification-bottom").append(element);
+ });
+});
+
+/**
+ * Get the first next sibling element matching the selector (if specified).
+ *
+ * @param {HTMLElement} element - The source element whose sibling to look for.
+ * @param {string} [selector] - The CSS query selector to match.
+ *
+ * @returns {(HTMLElement|null)} - The first matching sibling element, or null.
+ */
+function getNextSibling(element, selector) {
+ let sibling = element.nextElementSibling;
+ if (!selector) {
+ // If there's no selector, return the first next sibling.
+ return sibling;
+ }
+ while (sibling) {
+ if (sibling.matches(selector)) {
+ // Return the current sibling if it matches the selector.
+ return sibling;
+ }
+ // Otherwise, continue the loop with the following next sibling.
+ sibling = sibling.nextElementSibling;
+ }
+ return null;
+}
+
+/**
+ * Get the first previous sibling element matching the selector (if specified).
+ *
+ * @param {HTMLElement} element - The source element whose sibling to look for.
+ * @param {string} [selector] - The CSS query selector to match.
+ *
+ * @returns {(HTMLElement|null)} - The first matching sibling element, or null.
+ */
+function getPreviousSibling(element, selector) {
+ let sibling = element.previousElementSibling;
+ if (!selector) {
+ // If there's no selector, return the first previous sibling.
+ return sibling;
+ }
+ while (sibling) {
+ if (sibling.matches(selector)) {
+ // Return the current sibling if it matches the selector.
+ return sibling;
+ }
+ // Otherwise, continue the loop with the preceding previous sibling.
+ sibling = sibling.previousElementSibling;
+ }
+ return null;
+}
+
+/**
+ * Get a pretty, human-readable shortcut key string from a given <key> id.
+ *
+ * @param aKeyId the ID of a <key> element
+ * @returns string pretty, human-readable shortcut key string from the <key>
+ */
+function getPrettyKey(aKeyId) {
+ return ShortcutUtils.prettifyShortcut(document.getElementById(aKeyId));
+}
+
+/**
+ * Disables or enables editable elements in the window.
+ * The elements to operate on are marked with the "disableonsend" attribute.
+ * This includes elements like the address list, attachment list, subject
+ * and message body.
+ *
+ * @param aDisable true = disable items. false = enable items.
+ */
+function updateEditableFields(aDisable) {
+ if (!gMsgCompose) {
+ return;
+ }
+
+ if (aDisable) {
+ gMsgCompose.editor.flags |= Ci.nsIEditor.eEditorReadonlyMask;
+ } else {
+ gMsgCompose.editor.flags &= ~Ci.nsIEditor.eEditorReadonlyMask;
+
+ try {
+ let checker = GetCurrentEditor().getInlineSpellChecker(true);
+ checker.enableRealTimeSpell = gSpellCheckingEnabled;
+ } catch (ex) {
+ // An error will be thrown if there are no dictionaries. Just ignore it.
+ }
+ }
+
+ // Disable all the input fields and labels.
+ for (let element of document.querySelectorAll('[disableonsend="true"]')) {
+ element.disabled = aDisable;
+ }
+
+ // Update the UI of the addressing rows.
+ for (let row of document.querySelectorAll(".address-container")) {
+ row.classList.toggle("disable-container", aDisable);
+ }
+
+ // Prevent any interaction with the addressing pills.
+ for (let pill of document.querySelectorAll("mail-address-pill")) {
+ pill.toggleAttribute("disabled", aDisable);
+ }
+}
+
+/**
+ * Small helper function to check whether the node passed in is a signature.
+ * Note that a text node is not a DOM element, hence .localName can't be used.
+ */
+function isSignature(aNode) {
+ return (
+ ["DIV", "PRE"].includes(aNode.nodeName) &&
+ aNode.classList.contains("moz-signature")
+ );
+}
+
+var stateListener = {
+ NotifyComposeFieldsReady() {
+ ComposeFieldsReady();
+ updateSendCommands(true);
+ },
+
+ NotifyComposeBodyReady() {
+ // Look all the possible compose types (nsIMsgComposeParams.idl):
+ switch (gComposeType) {
+ case Ci.nsIMsgCompType.MailToUrl:
+ gBodyFromArgs = true;
+ // Falls through
+ case Ci.nsIMsgCompType.New:
+ case Ci.nsIMsgCompType.NewsPost:
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ this.NotifyComposeBodyReadyNew();
+ break;
+
+ case Ci.nsIMsgCompType.Reply:
+ case Ci.nsIMsgCompType.ReplyAll:
+ case Ci.nsIMsgCompType.ReplyToSender:
+ case Ci.nsIMsgCompType.ReplyToGroup:
+ case Ci.nsIMsgCompType.ReplyToSenderAndGroup:
+ case Ci.nsIMsgCompType.ReplyWithTemplate:
+ case Ci.nsIMsgCompType.ReplyToList:
+ this.NotifyComposeBodyReadyReply();
+ break;
+
+ case Ci.nsIMsgCompType.Redirect:
+ case Ci.nsIMsgCompType.ForwardInline:
+ this.NotifyComposeBodyReadyForwardInline();
+ break;
+
+ case Ci.nsIMsgCompType.EditTemplate:
+ defaultSaveOperation = "template";
+ break;
+ case Ci.nsIMsgCompType.Draft:
+ case Ci.nsIMsgCompType.Template:
+ case Ci.nsIMsgCompType.EditAsNew:
+ break;
+
+ default:
+ dump(
+ "Unexpected nsIMsgCompType in NotifyComposeBodyReady (" +
+ gComposeType +
+ ")\n"
+ );
+ }
+
+ // Setting the selected item in the identity list will cause an
+ // identity/signature switch. This can only be done once the message
+ // body has already been assembled with the signature we need to switch.
+ if (gMsgCompose.identity != gCurrentIdentity) {
+ let identityList = document.getElementById("msgIdentity");
+ identityList.selectedItem = identityList.getElementsByAttribute(
+ "identitykey",
+ gMsgCompose.identity.key
+ )[0];
+ LoadIdentity(false);
+ }
+ if (gMsgCompose.composeHTML) {
+ loadHTMLMsgPrefs();
+ }
+ AdjustFocus();
+ },
+
+ NotifyComposeBodyReadyNew() {
+ let useParagraph = Services.prefs.getBoolPref(
+ "mail.compose.default_to_paragraph"
+ );
+ let insertParagraph = gMsgCompose.composeHTML && useParagraph;
+
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+ if (insertParagraph && gBodyFromArgs) {
+ // Check for "empty" body before allowing paragraph to be inserted.
+ // Non-empty bodies in a new message can occur when clicking on a
+ // mailto link or when using the command line option -compose.
+ // An "empty" body can be one of these three cases:
+ // 1) <br> and nothing follows (no next sibling)
+ // 2) <div/pre class="moz-signature">
+ // 3) No elements, just text
+ // Note that <br><div/pre class="moz-signature"> doesn't happen in
+ // paragraph mode.
+ let firstChild = mailBody.firstChild;
+ let firstElementChild = mailBody.firstElementChild;
+ if (firstElementChild) {
+ if (
+ (firstElementChild.nodeName != "BR" ||
+ firstElementChild.nextElementSibling) &&
+ !isSignature(firstElementChild)
+ ) {
+ insertParagraph = false;
+ }
+ } else if (firstChild && firstChild.nodeType == Node.TEXT_NODE) {
+ insertParagraph = false;
+ }
+ }
+
+ // Control insertion of line breaks.
+ if (insertParagraph) {
+ let editor = GetCurrentEditor();
+ editor.enableUndo(false);
+
+ editor.selection.collapse(mailBody, 0);
+ let pElement = editor.createElementWithDefaults("p");
+ pElement.appendChild(editor.createElementWithDefaults("br"));
+ editor.insertElementAtSelection(pElement, false);
+
+ document.getElementById("cmd_paragraphState").setAttribute("state", "p");
+
+ editor.beginningOfDocument();
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ } else {
+ document.getElementById("cmd_paragraphState").setAttribute("state", "");
+ }
+ onParagraphFormatChange();
+ },
+
+ NotifyComposeBodyReadyReply() {
+ // Control insertion of line breaks.
+ let useParagraph = Services.prefs.getBoolPref(
+ "mail.compose.default_to_paragraph"
+ );
+ if (gMsgCompose.composeHTML && useParagraph) {
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+
+ // Make sure the selection isn't inside the signature.
+ if (isSignature(mailBody.firstElementChild)) {
+ selection.collapse(mailBody, 0);
+ }
+
+ let range = selection.getRangeAt(0);
+ let start = range.startOffset;
+
+ if (start != range.endOffset) {
+ // The selection is not collapsed, most likely due to the
+ // "select the quote" option. In this case we do nothing.
+ return;
+ }
+
+ if (range.startContainer != mailBody) {
+ dump("Unexpected selection in NotifyComposeBodyReadyReply\n");
+ return;
+ }
+
+ editor.enableUndo(false);
+
+ let pElement = editor.createElementWithDefaults("p");
+ pElement.appendChild(editor.createElementWithDefaults("br"));
+ editor.insertElementAtSelection(pElement, false);
+
+ // Position into the paragraph.
+ selection.collapse(pElement, 0);
+
+ document.getElementById("cmd_paragraphState").setAttribute("state", "p");
+
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ } else {
+ document.getElementById("cmd_paragraphState").setAttribute("state", "");
+ }
+ onParagraphFormatChange();
+ },
+
+ NotifyComposeBodyReadyForwardInline() {
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+
+ editor.enableUndo(false);
+
+ // Control insertion of line breaks.
+ selection.collapse(mailBody, 0);
+ let useParagraph = Services.prefs.getBoolPref(
+ "mail.compose.default_to_paragraph"
+ );
+ if (gMsgCompose.composeHTML && useParagraph) {
+ let pElement = editor.createElementWithDefaults("p");
+ let brElement = editor.createElementWithDefaults("br");
+ pElement.appendChild(brElement);
+ editor.insertElementAtSelection(pElement, false);
+ document.getElementById("cmd_paragraphState").setAttribute("state", "p");
+ } else {
+ // insertLineBreak() has been observed to insert two <br> elements
+ // instead of one before a <div>, so we'll do it ourselves here.
+ let brElement = editor.createElementWithDefaults("br");
+ editor.insertElementAtSelection(brElement, false);
+ document.getElementById("cmd_paragraphState").setAttribute("state", "");
+ }
+
+ onParagraphFormatChange();
+ editor.beginningOfDocument();
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ },
+
+ ComposeProcessDone(aResult) {
+ ToggleWindowLock(false);
+
+ if (aResult == Cr.NS_OK) {
+ if (!gAutoSaving) {
+ SetContentAndBodyAsUnmodified();
+ }
+
+ if (gCloseWindowAfterSave) {
+ // Notify the SendListener that Send has been aborted and Stopped
+ if (gMsgCompose) {
+ gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT);
+ }
+
+ MsgComposeCloseWindow();
+ }
+ } else if (gAutoSaving) {
+ // If we failed to save, and we're autosaving, need to re-mark the editor
+ // as changed, so that we won't lose the changes.
+ gMsgCompose.bodyModified = true;
+ gContentChanged = true;
+ }
+ gAutoSaving = false;
+ gCloseWindowAfterSave = false;
+ },
+
+ SaveInFolderDone(folderURI) {
+ DisplaySaveFolderDlg(folderURI);
+ },
+};
+
+var gSendListener = {
+ // nsIMsgSendListener
+ onStartSending(aMsgID, aMsgSize) {},
+ onProgress(aMsgID, aProgress, aProgressMax) {},
+ onStatus(aMsgID, aMsg) {},
+ onStopSending(aMsgID, aStatus, aMsg, aReturnFile) {
+ if (Components.isSuccessCode(aStatus)) {
+ Services.obs.notifyObservers(null, "mail:composeSendSucceeded", aMsgID);
+ }
+ },
+ onGetDraftFolderURI(aMsgID, aFolderURI) {},
+ onSendNotPerformed(aMsgID, aStatus) {},
+ onTransportSecurityError(msgID, status, secInfo, location) {
+ // We're only interested in Bad Cert errors here.
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let errorClass = nssErrorsService.getErrorClass(status);
+ if (errorClass != Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ return;
+ }
+
+ // Give the user the option of adding an exception for the bad cert.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // params.exceptionAdded will be set if the user added an exception.
+ },
+};
+
+// all progress notifications are done through the nsIWebProgressListener implementation...
+var progressListener = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ let progressMeter = document.getElementById("compose-progressmeter");
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ progressMeter.hidden = false;
+ progressMeter.removeAttribute("value");
+ }
+
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ gSendOperationInProgress = false;
+ gSaveOperationInProgress = false;
+ progressMeter.hidden = true;
+ progressMeter.value = 0;
+ document.getElementById("statusText").textContent = "";
+ Services.obs.notifyObservers(
+ { composeWindow: window },
+ "mail:composeSendProgressStop"
+ );
+ }
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ // Calculate percentage.
+ var percent;
+ if (aMaxTotalProgress > 0) {
+ percent = Math.round((aCurTotalProgress * 100) / aMaxTotalProgress);
+ if (percent > 100) {
+ percent = 100;
+ }
+
+ // Advance progress meter.
+ document.getElementById("compose-progressmeter").value = percent;
+ } else {
+ // Progress meter should be barber-pole in this case.
+ document.getElementById("compose-progressmeter").removeAttribute("value");
+ }
+ },
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ // we can ignore this notification
+ },
+
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ // Looks like it's possible that we get call while the document has been already delete!
+ // therefore we need to protect ourself by using try/catch
+ try {
+ let statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = aMessage;
+ }
+ } catch (ex) {}
+ },
+
+ onSecurityChange(aWebProgress, aRequest, state) {
+ // we can ignore this notification
+ },
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {
+ // we can ignore this notification
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+var defaultController = {
+ commands: {
+ cmd_attachFile: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ AttachFile();
+ },
+ },
+
+ cmd_attachCloud: {
+ isEnabled() {
+ // Hide the command entirely if there are no cloud accounts or
+ // the feature is disabled.
+ let cmd = document.getElementById("cmd_attachCloud");
+ cmd.hidden =
+ !Services.prefs.getBoolPref("mail.cloud_files.enabled") ||
+ cloudFileAccounts.configuredAccounts.length == 0 ||
+ Services.io.offline;
+ return !cmd.hidden && !gWindowLocked;
+ },
+ doCommand() {
+ // We should never actually call this, since the <command> node calls
+ // a different function.
+ },
+ },
+
+ cmd_attachPage: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ gMsgCompose.allowRemoteContent = true;
+ AttachPage();
+ },
+ },
+
+ cmd_attachVCard: {
+ isEnabled() {
+ let cmd = document.getElementById("cmd_attachVCard");
+ cmd.setAttribute("checked", gMsgCompose.compFields.attachVCard);
+ return !!gCurrentIdentity?.escapedVCard;
+ },
+ doCommand() {},
+ },
+
+ cmd_attachPublicKey: {
+ isEnabled() {
+ let cmd = document.getElementById("cmd_attachPublicKey");
+ cmd.setAttribute("checked", gAttachMyPublicPGPKey);
+ return isPgpConfigured();
+ },
+ doCommand() {},
+ },
+
+ cmd_toggleAttachmentPane: {
+ isEnabled() {
+ return !gWindowLocked && gAttachmentBucket.itemCount;
+ },
+ doCommand() {
+ toggleAttachmentPane("toggle");
+ },
+ },
+
+ cmd_reorderAttachments: {
+ isEnabled() {
+ if (!gAttachmentBucket.itemCount) {
+ let reorderAttachmentsPanel = document.getElementById(
+ "reorderAttachmentsPanel"
+ );
+ if (reorderAttachmentsPanel.state == "open") {
+ // When the panel is open and all attachments get deleted,
+ // we get notified here and want to close the panel.
+ reorderAttachmentsPanel.hidePopup();
+ }
+ }
+ return gAttachmentBucket.itemCount > 1;
+ },
+ doCommand() {
+ showReorderAttachmentsPanel();
+ },
+ },
+
+ cmd_removeAllAttachments: {
+ isEnabled() {
+ return !gWindowLocked && gAttachmentBucket.itemCount;
+ },
+ doCommand() {
+ RemoveAllAttachments();
+ },
+ },
+
+ cmd_close: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ if (ComposeCanClose()) {
+ window.close();
+ }
+ },
+ },
+
+ cmd_saveDefault: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ Save();
+ },
+ },
+
+ cmd_saveAsFile: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ SaveAsFile(true);
+ },
+ },
+
+ cmd_saveAsDraft: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ SaveAsDraft();
+ },
+ },
+
+ cmd_saveAsTemplate: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ SaveAsTemplate();
+ },
+ },
+
+ cmd_sendButton: {
+ isEnabled() {
+ return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked;
+ },
+ doCommand() {
+ if (Services.io.offline) {
+ SendMessageLater();
+ } else {
+ SendMessage();
+ }
+ },
+ },
+
+ cmd_sendNow: {
+ isEnabled() {
+ return (
+ !gWindowLocked &&
+ !Services.io.offline &&
+ !gSendLocked &&
+ !gNumUploadingAttachments
+ );
+ },
+ doCommand() {
+ SendMessage();
+ },
+ },
+
+ cmd_sendLater: {
+ isEnabled() {
+ return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked;
+ },
+ doCommand() {
+ SendMessageLater();
+ },
+ },
+
+ cmd_sendWithCheck: {
+ isEnabled() {
+ return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked;
+ },
+ doCommand() {
+ SendMessageWithCheck();
+ },
+ },
+
+ cmd_print: {
+ isEnabled() {
+ return !gWindowLocked;
+ },
+ doCommand() {
+ DoCommandPrint();
+ },
+ },
+
+ cmd_delete: {
+ isEnabled() {
+ let cmdDelete = document.getElementById("cmd_delete");
+ let textValue = cmdDelete.getAttribute("valueDefault");
+ let accesskeyValue = cmdDelete.getAttribute("valueDefaultAccessKey");
+
+ cmdDelete.setAttribute("label", textValue);
+ cmdDelete.setAttribute("accesskey", accesskeyValue);
+
+ return false;
+ },
+ doCommand() {},
+ },
+
+ cmd_account: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ let currentAccountKey = getCurrentAccountKey();
+ let account = MailServices.accounts.getAccount(currentAccountKey);
+ MsgAccountManager(null, account.incomingServer);
+ },
+ },
+
+ cmd_showFormatToolbar: {
+ isEnabled() {
+ return gMsgCompose && gMsgCompose.composeHTML;
+ },
+ doCommand() {
+ goToggleToolbar("FormatToolbar", "menu_showFormatToolbar");
+ },
+ },
+
+ cmd_quoteMessage: {
+ isEnabled() {
+ let selectedURIs = GetSelectedMessages();
+ return selectedURIs && selectedURIs.length > 0;
+ },
+ doCommand() {
+ QuoteSelectedMessage();
+ },
+ },
+
+ cmd_toggleReturnReceipt: {
+ isEnabled() {
+ if (!gMsgCompose) {
+ return false;
+ }
+ return !gWindowLocked;
+ },
+ doCommand() {
+ ToggleReturnReceipt();
+ },
+ },
+
+ cmd_fullZoomReduce: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.reduce();
+ },
+ },
+
+ cmd_fullZoomEnlarge: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.enlarge();
+ },
+ },
+
+ cmd_fullZoomReset: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.reset();
+ },
+ },
+
+ cmd_spelling: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ window.cancelSendMessage = false;
+ var skipBlockQuotes =
+ window.document.documentElement.getAttribute("windowtype") ==
+ "msgcompose";
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml",
+ "_blank",
+ "dialog,close,titlebar,modal,resizable",
+ false,
+ skipBlockQuotes,
+ true
+ );
+ },
+ },
+
+ cmd_fullZoomToggle: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ ZoomManager.toggleZoom();
+ },
+ },
+ },
+
+ supportsCommand(aCommand) {
+ return aCommand in this.commands;
+ },
+
+ isCommandEnabled(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return false;
+ }
+ return this.commands[aCommand].isEnabled();
+ },
+
+ doCommand(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return;
+ }
+ var cmd = this.commands[aCommand];
+ if (!cmd.isEnabled()) {
+ return;
+ }
+ cmd.doCommand();
+ },
+
+ onEvent(event) {},
+};
+
+var attachmentBucketController = {
+ commands: {
+ cmd_selectAll: {
+ isEnabled() {
+ return true;
+ },
+ doCommand() {
+ gAttachmentBucket.selectAll();
+ },
+ },
+
+ cmd_delete: {
+ isEnabled() {
+ let cmdDelete = document.getElementById("cmd_delete");
+ let textValue = getComposeBundle().getString("removeAttachmentMsgs");
+ textValue = PluralForm.get(gAttachmentBucket.selectedCount, textValue);
+ let accesskeyValue = cmdDelete.getAttribute(
+ "valueRemoveAttachmentAccessKey"
+ );
+ cmdDelete.setAttribute("label", textValue);
+ cmdDelete.setAttribute("accesskey", accesskeyValue);
+
+ return gAttachmentBucket.selectedCount;
+ },
+ doCommand() {
+ RemoveSelectedAttachment();
+ },
+ },
+
+ cmd_openAttachment: {
+ isEnabled() {
+ return gAttachmentBucket.selectedCount == 1;
+ },
+ doCommand() {
+ OpenSelectedAttachment();
+ },
+ },
+
+ cmd_renameAttachment: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount == 1 &&
+ !gAttachmentBucket.selectedItem.uploading
+ );
+ },
+ doCommand() {
+ RenameSelectedAttachment();
+ },
+ },
+
+ cmd_moveAttachmentLeft: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("left");
+ },
+ },
+
+ cmd_moveAttachmentRight: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount &&
+ !attachmentsSelectionIsBlock("bottom")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("right");
+ },
+ },
+
+ cmd_moveAttachmentBundleUp: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock()
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("bundleUp");
+ },
+ },
+
+ cmd_moveAttachmentBundleDown: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock()
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("bundleDown");
+ },
+ },
+
+ cmd_moveAttachmentTop: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("top");
+ },
+ },
+
+ cmd_moveAttachmentBottom: {
+ isEnabled() {
+ return (
+ gAttachmentBucket.selectedCount &&
+ !attachmentsSelectionIsBlock("bottom")
+ );
+ },
+ doCommand() {
+ moveSelectedAttachments("bottom");
+ },
+ },
+
+ cmd_sortAttachmentsToggle: {
+ isEnabled() {
+ let sortSelection;
+ let currSortOrder;
+ let isBlock;
+ let btnAscending;
+ let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle");
+ let toggleBtn = document.getElementById("btn_sortAttachmentsToggle");
+ let sortDirection;
+ let btnLabelAttr;
+
+ if (
+ gAttachmentBucket.selectedCount > 1 &&
+ gAttachmentBucket.selectedCount < gAttachmentBucket.itemCount
+ ) {
+ // Sort selected attachments only, which needs at least 2 of them,
+ // but not all.
+ sortSelection = true;
+ currSortOrder = attachmentsSelectionGetSortOrder();
+ isBlock = attachmentsSelectionIsBlock();
+ // If current sorting is ascending AND it's a block; OR
+ // if current sorting is descending AND it's NOT a block yet:
+ // Offer toggle button face to sort descending.
+ // In all other cases, offer toggle button face to sort ascending.
+ btnAscending = !(
+ (currSortOrder == "ascending" && isBlock) ||
+ (currSortOrder == "descending" && !isBlock)
+ );
+ // Set sortDirection for toggleCmd, and respective button face.
+ if (btnAscending) {
+ sortDirection = "ascending";
+ btnLabelAttr = "label-selection-AZ";
+ } else {
+ sortDirection = "descending";
+ btnLabelAttr = "label-selection-ZA";
+ }
+ } else {
+ // gAttachmentBucket.selectedCount <= 1 or all attachments are selected.
+ // Sort all attachments.
+ sortSelection = false;
+ currSortOrder = attachmentsGetSortOrder();
+ btnAscending = !(currSortOrder == "ascending");
+ // Set sortDirection for toggleCmd, and respective button face.
+ if (btnAscending) {
+ sortDirection = "ascending";
+ btnLabelAttr = "label-AZ";
+ } else {
+ sortDirection = "descending";
+ btnLabelAttr = "label-ZA";
+ }
+ }
+
+ // Set the sort direction for toggleCmd.
+ toggleCmd.setAttribute("sortdirection", sortDirection);
+ // The button's icon is set dynamically via CSS involving the button's
+ // sortdirection attribute, which is forwarded by the command.
+ toggleBtn.setAttribute("label", toggleBtn.getAttribute(btnLabelAttr));
+
+ return sortSelection
+ ? !(currSortOrder == "equivalent" && isBlock)
+ : !(currSortOrder == "equivalent");
+ },
+ doCommand() {
+ moveSelectedAttachments("toggleSort");
+ },
+ },
+
+ cmd_convertCloud: {
+ isEnabled() {
+ // Hide the command entirely if Filelink is disabled, or if there are
+ // no cloud accounts.
+ let cmd = document.getElementById("cmd_convertCloud");
+
+ cmd.hidden =
+ !Services.prefs.getBoolPref("mail.cloud_files.enabled") ||
+ cloudFileAccounts.configuredAccounts.length == 0 ||
+ Services.io.offline;
+ if (cmd.hidden) {
+ return false;
+ }
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item.uploading) {
+ return false;
+ }
+ }
+ return true;
+ },
+ doCommand() {
+ // We should never actually call this, since the <command> node calls
+ // a different function.
+ },
+ },
+
+ cmd_convertAttachment: {
+ isEnabled() {
+ if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) {
+ return false;
+ }
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item.uploading) {
+ return false;
+ }
+ }
+ return true;
+ },
+ doCommand() {
+ convertSelectedToRegularAttachment();
+ },
+ },
+
+ cmd_cancelUpload: {
+ isEnabled() {
+ let cmd = document.getElementById(
+ "composeAttachmentContext_cancelUploadItem"
+ );
+
+ // If Filelink is disabled, hide this menuitem and bailout.
+ if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) {
+ cmd.hidden = true;
+ return false;
+ }
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item && item.uploading) {
+ cmd.hidden = false;
+ return true;
+ }
+ }
+
+ // Hide the command entirely if the selected attachments aren't cloud
+ // files.
+ // For some reason, the hidden property isn't propagating from the cmd
+ // to the menuitem.
+ cmd.hidden = true;
+ return false;
+ },
+ doCommand() {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ for (let item of gAttachmentBucket.selectedItems) {
+ if (item && item.uploading) {
+ let file = fileHandler.getFileFromURLSpec(item.attachment.url);
+ item.uploading.cancelFileUpload(window, file);
+ }
+ }
+ },
+ },
+ },
+
+ supportsCommand(aCommand) {
+ return aCommand in this.commands;
+ },
+
+ isCommandEnabled(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return false;
+ }
+ return this.commands[aCommand].isEnabled();
+ },
+
+ doCommand(aCommand) {
+ if (!this.supportsCommand(aCommand)) {
+ return;
+ }
+ var cmd = this.commands[aCommand];
+ if (!cmd.isEnabled()) {
+ return;
+ }
+ cmd.doCommand();
+ },
+
+ onEvent(event) {},
+};
+
+/**
+ * Start composing a new message.
+ */
+function goOpenNewMessage(aEvent) {
+ // If aEvent is passed, check if Shift key was pressed for composition in
+ // non-default format (HTML vs. plaintext).
+ let msgCompFormat =
+ aEvent && aEvent.shiftKey
+ ? Ci.nsIMsgCompFormat.OppositeOfDefault
+ : Ci.nsIMsgCompFormat.Default;
+
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ Ci.nsIMsgCompType.New,
+ msgCompFormat,
+ gCurrentIdentity,
+ null,
+ null
+ );
+}
+
+function QuoteSelectedMessage() {
+ var selectedURIs = GetSelectedMessages();
+ if (selectedURIs) {
+ gMsgCompose.allowRemoteContent = false;
+ for (let i = 0; i < selectedURIs.length; i++) {
+ gMsgCompose.quoteMessage(selectedURIs[i]);
+ }
+ }
+}
+
+function GetSelectedMessages() {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (!mailWindow) {
+ return null;
+ }
+ let tab = mailWindow.document.getElementById("tabmail").currentTabInfo;
+ if (tab.mode.name == "mail3PaneTab" && tab.message) {
+ return tab.chromeBrowser.contentWindow?.gDBView?.getURIsForSelection();
+ } else if (tab.mode.name == "mailMessageTab") {
+ return [tab.messageURI];
+ }
+ return null;
+}
+
+function SetupCommandUpdateHandlers() {
+ top.controllers.appendController(defaultController);
+ gAttachmentBucket.controllers.appendController(attachmentBucketController);
+
+ document
+ .getElementById("optionsMenuPopup")
+ .addEventListener("popupshowing", updateOptionItems, true);
+}
+
+function UnloadCommandUpdateHandlers() {
+ document
+ .getElementById("optionsMenuPopup")
+ .removeEventListener("popupshowing", updateOptionItems, true);
+
+ gAttachmentBucket.controllers.removeController(attachmentBucketController);
+ top.controllers.removeController(defaultController);
+}
+
+function CommandUpdate_MsgCompose() {
+ var focusedWindow = top.document.commandDispatcher.focusedWindow;
+
+ // we're just setting focus to where it was before
+ if (focusedWindow == gLastWindowToHaveFocus) {
+ return;
+ }
+
+ gLastWindowToHaveFocus = focusedWindow;
+ updateComposeItems();
+}
+
+function findbarFindReplace() {
+ focusMsgBody();
+ let findbar = document.getElementById("FindToolbar");
+ findbar.close();
+ goDoCommand("cmd_findReplace");
+ findbar.open();
+}
+
+function updateComposeItems() {
+ try {
+ // Edit Menu
+ goUpdateCommand("cmd_rewrap");
+
+ // Insert Menu
+ if (gMsgCompose && gMsgCompose.composeHTML) {
+ goUpdateCommand("cmd_renderedHTMLEnabler");
+ goUpdateCommand("cmd_fontColor");
+ goUpdateCommand("cmd_backgroundColor");
+ goUpdateCommand("cmd_decreaseFontStep");
+ goUpdateCommand("cmd_increaseFontStep");
+ goUpdateCommand("cmd_bold");
+ goUpdateCommand("cmd_italic");
+ goUpdateCommand("cmd_underline");
+ goUpdateCommand("cmd_removeStyles");
+ goUpdateCommand("cmd_ul");
+ goUpdateCommand("cmd_ol");
+ goUpdateCommand("cmd_indent");
+ goUpdateCommand("cmd_outdent");
+ goUpdateCommand("cmd_align");
+ goUpdateCommand("cmd_smiley");
+ }
+
+ // Options Menu
+ goUpdateCommand("cmd_spelling");
+
+ // Workaround to update 'Quote' toolbar button. (See bug 609926.)
+ goUpdateCommand("cmd_quoteMessage");
+ goUpdateCommand("cmd_toggleReturnReceipt");
+ } catch (e) {}
+}
+
+/**
+ * Disables or restores all toolbar items (menus/buttons) in the window.
+ *
+ * @param {boolean} disable - Meaning true = disable all items, false = restore
+ * items to the state stored before disabling them.
+ */
+function updateAllItems(disable) {
+ for (let item of document.querySelectorAll(
+ "menu, toolbarbutton, [command], [oncommand]"
+ )) {
+ if (disable) {
+ // Disable all items
+ item.setAttribute("stateBeforeSend", item.getAttribute("disabled"));
+ item.setAttribute("disabled", "disabled");
+ } else {
+ // Restore initial state
+ let stateBeforeSend = item.getAttribute("stateBeforeSend");
+ if (stateBeforeSend == "disabled" || stateBeforeSend == "true") {
+ item.setAttribute("disabled", stateBeforeSend);
+ } else {
+ item.removeAttribute("disabled");
+ }
+ item.removeAttribute("stateBeforeSend");
+ }
+ }
+}
+
+function InitFileSaveAsMenu() {
+ document
+ .getElementById("cmd_saveAsFile")
+ .setAttribute("checked", defaultSaveOperation == "file");
+ document
+ .getElementById("cmd_saveAsDraft")
+ .setAttribute("checked", defaultSaveOperation == "draft");
+ document
+ .getElementById("cmd_saveAsTemplate")
+ .setAttribute("checked", defaultSaveOperation == "template");
+}
+
+function isSmimeSigningConfigured() {
+ return !!gCurrentIdentity?.getUnicharAttribute("signing_cert_name");
+}
+
+function isSmimeEncryptionConfigured() {
+ return !!gCurrentIdentity?.getUnicharAttribute("encryption_cert_name");
+}
+
+function isPgpConfigured() {
+ return !!gCurrentIdentity?.getUnicharAttribute("openpgp_key_id");
+}
+
+function toggleGlobalSignMessage() {
+ gSendSigned = !gSendSigned;
+ gUserTouchedSendSigned = true;
+
+ updateAttachMyPubKey();
+ showSendEncryptedAndSigned();
+}
+
+function updateAttachMyPubKey() {
+ if (!gUserTouchedAttachMyPubKey) {
+ if (gSendSigned) {
+ gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey;
+ } else {
+ gAttachMyPublicPGPKey = false;
+ }
+ }
+}
+
+function removeAutoDisableNotification() {
+ let notification = gComposeNotification.getNotificationWithValue(
+ "e2eeDisableNotification"
+ );
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+}
+
+function toggleEncryptMessage() {
+ gSendEncrypted = !gSendEncrypted;
+
+ if (gSendEncrypted) {
+ removeAutoDisableNotification();
+ }
+
+ gUserTouchedSendEncrypted = true;
+ checkEncryptionState();
+}
+
+function toggleAttachMyPublicKey(target) {
+ gAttachMyPublicPGPKey = target.getAttribute("checked") != "true";
+ target.setAttribute("checked", gAttachMyPublicPGPKey);
+ gUserTouchedAttachMyPubKey = true;
+}
+
+function updateEncryptedSubject() {
+ let warnSubjectUnencrypted =
+ (!gSelectedTechnologyIsPGP && gSendEncrypted) ||
+ (isPgpConfigured() &&
+ gSelectedTechnologyIsPGP &&
+ gSendEncrypted &&
+ !gEncryptSubject);
+
+ document
+ .getElementById("msgSubject")
+ .classList.toggle("with-icon", warnSubjectUnencrypted);
+ document.getElementById("msgEncryptedSubjectIcon").hidden =
+ !warnSubjectUnencrypted;
+}
+
+function toggleEncryptedSubject() {
+ gEncryptSubject = !gEncryptSubject;
+ gUserTouchedEncryptSubject = true;
+ updateEncryptedSubject();
+}
+
+/**
+ * Update user interface elements
+ *
+ * @param {string} menu_id - suffix of the menu ID of the menu to update
+ */
+function setSecuritySettings(menu_id) {
+ let encItem = document.getElementById("menu_securityEncrypt" + menu_id);
+ encItem.setAttribute("checked", gSendEncrypted);
+
+ let disableSig = false;
+ let disableEnc = false;
+
+ if (gSelectedTechnologyIsPGP) {
+ if (!isPgpConfigured()) {
+ disableSig = true;
+ disableEnc = true;
+ }
+ } else {
+ if (!isSmimeSigningConfigured()) {
+ disableSig = true;
+ }
+ if (!isSmimeEncryptionConfigured()) {
+ disableEnc = true;
+ }
+ }
+
+ let sigItem = document.getElementById("menu_securitySign" + menu_id);
+ sigItem.setAttribute("checked", gSendSigned && !disableSig);
+
+ // The radio button to disable encryption is always active.
+ // This is necessary, even if the current identity doesn't have
+ // e2ee configured. If the user switches the sender identity of an
+ // email, we might keep encryption enabled, to not surprise the user.
+ // This means, we must always allow the user to disable encryption.
+ encItem.disabled = disableEnc && !gSendEncrypted;
+
+ sigItem.disabled = disableSig;
+
+ let pgpItem = document.getElementById("encTech_OpenPGP" + menu_id);
+ let smimeItem = document.getElementById("encTech_SMIME" + menu_id);
+
+ smimeItem.disabled =
+ !isSmimeSigningConfigured() && !isSmimeEncryptionConfigured();
+
+ let encryptSubjectItem = document.getElementById(
+ `menu_securityEncryptSubject${menu_id}`
+ );
+
+ pgpItem.setAttribute("checked", gSelectedTechnologyIsPGP);
+ smimeItem.setAttribute("checked", !gSelectedTechnologyIsPGP);
+ encryptSubjectItem.setAttribute(
+ "checked",
+ !disableEnc && gSelectedTechnologyIsPGP && gSendEncrypted && gEncryptSubject
+ );
+ encryptSubjectItem.setAttribute(
+ "disabled",
+ disableEnc || !gSelectedTechnologyIsPGP || !gSendEncrypted
+ );
+
+ document.getElementById("menu_recipientStatus" + menu_id).disabled =
+ disableEnc;
+ let manager = document.getElementById("menu_openManager" + menu_id);
+ manager.disabled = disableEnc;
+ manager.hidden = !gSelectedTechnologyIsPGP;
+}
+
+/**
+ * Show the message security status based on the selected encryption technology.
+ *
+ * @param {boolean} [isSending=false] - If the key assistant was triggered
+ * during a sending attempt.
+ */
+function showMessageComposeSecurityStatus(isSending = false) {
+ if (gSelectedTechnologyIsPGP) {
+ if (
+ Services.prefs.getBoolPref("mail.openpgp.key_assistant.enable", false)
+ ) {
+ gKeyAssistant.show(getEncryptionCompatibleRecipients(), isSending);
+ } else {
+ Recipients2CompFields(gMsgCompose.compFields);
+ window.openDialog(
+ "chrome://openpgp/content/ui/composeKeyStatus.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ {
+ compFields: gMsgCompose.compFields,
+ currentIdentity: gCurrentIdentity,
+ }
+ );
+ checkEncryptionState();
+ }
+ } else {
+ Recipients2CompFields(gMsgCompose.compFields);
+ // Copy current flags to S/MIME composeSecure object.
+ gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted;
+ gMsgCompose.compFields.composeSecure.signMessage = gSendSigned;
+ window.openDialog(
+ "chrome://messenger-smime/content/msgCompSecurityInfo.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ {
+ compFields: gMsgCompose.compFields,
+ subject: document.getElementById("msgSubject").value,
+ isSigningCertAvailable:
+ gCurrentIdentity.getUnicharAttribute("signing_cert_name") != "",
+ isEncryptionCertAvailable:
+ gCurrentIdentity.getUnicharAttribute("encryption_cert_name") != "",
+ currentIdentity: gCurrentIdentity,
+ recipients: getEncryptionCompatibleRecipients(),
+ }
+ );
+ }
+}
+
+function msgComposeContextOnShowing(event) {
+ if (event.target.id != "msgComposeContext") {
+ return;
+ }
+
+ // gSpellChecker handles all spell checking related to the context menu,
+ // except whether or not spell checking is enabled. We need the editor's
+ // spell checker for that.
+ gSpellChecker.initFromRemote(
+ nsContextMenu.contentData.spellInfo,
+ nsContextMenu.contentData.actor.manager
+ );
+
+ let canSpell = gSpellChecker.canSpellCheck;
+ let showDictionaries = canSpell && gSpellChecker.enabled;
+ let onMisspelling = gSpellChecker.overMisspelling;
+ let showUndo = canSpell && gSpellChecker.canUndo();
+
+ document.getElementById("spellCheckSeparator").hidden = !canSpell;
+ document.getElementById("spellCheckEnable").hidden = !canSpell;
+ document
+ .getElementById("spellCheckEnable")
+ .setAttribute("checked", canSpell && gSpellCheckingEnabled);
+
+ document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling;
+ document.getElementById("spellCheckUndoAddToDictionary").hidden = !showUndo;
+ document.getElementById("spellCheckIgnoreWord").hidden = !onMisspelling;
+
+ // Suggestion list.
+ document.getElementById("spellCheckSuggestionsSeparator").hidden =
+ !onMisspelling && !showUndo;
+ let separator = document.getElementById("spellCheckAddSep");
+ separator.hidden = !onMisspelling;
+ if (onMisspelling) {
+ let addMenuItem = document.getElementById("spellCheckAddToDictionary");
+ let suggestionCount = gSpellChecker.addSuggestionsToMenu(
+ addMenuItem.parentNode,
+ separator,
+ nsContextMenu.contentData.spellInfo.spellSuggestions
+ );
+ document.getElementById("spellCheckNoSuggestions").hidden =
+ !suggestionCount == 0;
+ } else {
+ document.getElementById("spellCheckNoSuggestions").hidden = !false;
+ }
+
+ // Dictionary list.
+ document.getElementById("spellCheckDictionaries").hidden = !showDictionaries;
+ if (canSpell) {
+ let dictMenu = document.getElementById("spellCheckDictionariesMenu");
+ let dictSep = document.getElementById("spellCheckLanguageSeparator");
+ let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep);
+ dictSep.hidden = count == 0;
+ document.getElementById("spellCheckAddDictionariesMain").hidden = !false;
+ } else if (this.onSpellcheckable) {
+ // when there is no spellchecker but we might be able to spellcheck
+ // add the add to dictionaries item. This will ensure that people
+ // with no dictionaries will be able to download them
+ document.getElementById("spellCheckLanguageSeparator").hidden =
+ !showDictionaries;
+ document.getElementById("spellCheckAddDictionariesMain").hidden =
+ !showDictionaries;
+ } else {
+ document.getElementById("spellCheckAddDictionariesMain").hidden = !false;
+ }
+
+ updateEditItems();
+
+ // The rest of this block sends menu information to WebExtensions.
+
+ let editor = GetCurrentEditorElement();
+ let target = editor.contentDocument.elementFromPoint(
+ editor._contextX,
+ editor._contextY
+ );
+
+ let selectionInfo = SelectionUtils.getSelectionDetails(window);
+ let isContentSelected = !selectionInfo.docSelectionIsCollapsed;
+ let textSelected = selectionInfo.text;
+ let isTextSelected = !!textSelected.length;
+
+ // Set up early the right flags for editable / not editable.
+ let editFlags = SpellCheckHelper.isEditable(target, window);
+ let onTextInput = (editFlags & SpellCheckHelper.TEXTINPUT) !== 0;
+ let onEditable =
+ (editFlags &
+ (SpellCheckHelper.EDITABLE | SpellCheckHelper.CONTENTEDITABLE)) !==
+ 0;
+
+ let onImage = false;
+ let srcUrl = undefined;
+
+ if (target.nodeType == Node.ELEMENT_NODE) {
+ if (target instanceof Ci.nsIImageLoadingContent && target.currentURI) {
+ onImage = true;
+ srcUrl = target.currentURI.spec;
+ }
+ }
+
+ let onLink = false;
+ let linkText = undefined;
+ let linkUrl = undefined;
+
+ let link = target.closest("a");
+ if (link) {
+ onLink = true;
+ linkText =
+ link.textContent ||
+ link.getAttribute("title") ||
+ link.getAttribute("a") ||
+ link.href ||
+ "";
+ linkUrl = link.href;
+ }
+
+ let subject = {
+ menu: event.target,
+ tab: window,
+ isContentSelected,
+ isTextSelected,
+ onTextInput,
+ onLink,
+ onImage,
+ onEditable,
+ srcUrl,
+ linkText,
+ linkUrl,
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ pageUrl: target.ownerGlobal.top.location.href,
+ onComposeBody: true,
+ };
+ subject.context = subject;
+ subject.wrappedJSObject = subject;
+
+ Services.obs.notifyObservers(subject, "on-prepare-contextmenu");
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+}
+
+function msgComposeContextOnHiding(event) {
+ if (event.target.id != "msgComposeContext") {
+ return;
+ }
+
+ if (nsContextMenu.contentData.actor) {
+ nsContextMenu.contentData.actor.hiding();
+ }
+
+ nsContextMenu.contentData = null;
+ gSpellChecker.clearSuggestionsFromMenu();
+ gSpellChecker.clearDictionaryListFromMenu();
+ gSpellChecker.uninit();
+}
+
+function updateEditItems() {
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_pasteNoFormatting");
+ goUpdateCommand("cmd_pasteQuote");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_renameAttachment");
+ goUpdateCommand("cmd_reorderAttachments");
+ goUpdateCommand("cmd_selectAll");
+ goUpdateCommand("cmd_openAttachment");
+ goUpdateCommand("cmd_findReplace");
+ goUpdateCommand("cmd_find");
+ goUpdateCommand("cmd_findNext");
+ goUpdateCommand("cmd_findPrev");
+}
+
+function updateViewItems() {
+ goUpdateCommand("cmd_toggleAttachmentPane");
+}
+
+function updateOptionItems() {
+ goUpdateCommand("cmd_quoteMessage");
+ goUpdateCommand("cmd_toggleReturnReceipt");
+}
+
+function updateAttachmentItems() {
+ goUpdateCommand("cmd_toggleAttachmentPane");
+ goUpdateCommand("cmd_attachCloud");
+ goUpdateCommand("cmd_convertCloud");
+ goUpdateCommand("cmd_convertAttachment");
+ goUpdateCommand("cmd_cancelUpload");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_removeAllAttachments");
+ goUpdateCommand("cmd_renameAttachment");
+ updateReorderAttachmentsItems();
+ goUpdateCommand("cmd_selectAll");
+ goUpdateCommand("cmd_openAttachment");
+ goUpdateCommand("cmd_attachVCard");
+ goUpdateCommand("cmd_attachPublicKey");
+}
+
+function updateReorderAttachmentsItems() {
+ goUpdateCommand("cmd_reorderAttachments");
+ goUpdateCommand("cmd_moveAttachmentLeft");
+ goUpdateCommand("cmd_moveAttachmentRight");
+ goUpdateCommand("cmd_moveAttachmentBundleUp");
+ goUpdateCommand("cmd_moveAttachmentBundleDown");
+ goUpdateCommand("cmd_moveAttachmentTop");
+ goUpdateCommand("cmd_moveAttachmentBottom");
+ goUpdateCommand("cmd_sortAttachmentsToggle");
+}
+
+/**
+ * Update all the commands for sending a message to reflect their current state.
+ */
+function updateSendCommands(aHaveController) {
+ updateSendLock();
+ if (aHaveController) {
+ goUpdateCommand("cmd_sendButton");
+ goUpdateCommand("cmd_sendNow");
+ goUpdateCommand("cmd_sendLater");
+ goUpdateCommand("cmd_sendWithCheck");
+ } else {
+ goSetCommandEnabled(
+ "cmd_sendButton",
+ defaultController.isCommandEnabled("cmd_sendButton")
+ );
+ goSetCommandEnabled(
+ "cmd_sendNow",
+ defaultController.isCommandEnabled("cmd_sendNow")
+ );
+ goSetCommandEnabled(
+ "cmd_sendLater",
+ defaultController.isCommandEnabled("cmd_sendLater")
+ );
+ goSetCommandEnabled(
+ "cmd_sendWithCheck",
+ defaultController.isCommandEnabled("cmd_sendWithCheck")
+ );
+ }
+
+ let changed = false;
+ let currentStates = {};
+ let changedStates = {};
+ for (let state of ["cmd_sendNow", "cmd_sendLater"]) {
+ currentStates[state] = defaultController.isCommandEnabled(state);
+ if (
+ !gLastKnownComposeStates.hasOwnProperty(state) ||
+ gLastKnownComposeStates[state] != currentStates[state]
+ ) {
+ gLastKnownComposeStates[state] = currentStates[state];
+ changedStates[state] = currentStates[state];
+ changed = true;
+ }
+ }
+ if (changed) {
+ window.dispatchEvent(
+ new CustomEvent("compose-state-changed", { detail: changedStates })
+ );
+ }
+}
+
+function addAttachCloudMenuItems(aParentMenu) {
+ while (aParentMenu.hasChildNodes()) {
+ aParentMenu.lastChild.remove();
+ }
+
+ for (let account of cloudFileAccounts.configuredAccounts) {
+ if (
+ aParentMenu.lastElementChild &&
+ aParentMenu.lastElementChild.cloudFileUpload
+ ) {
+ aParentMenu.appendChild(document.createXULElement("menuseparator"));
+ }
+
+ let item = document.createXULElement("menuitem");
+ let iconURL = account.iconURL;
+ item.cloudFileAccount = account;
+ item.setAttribute(
+ "label",
+ cloudFileAccounts.getDisplayName(account) + "\u2026"
+ );
+ if (iconURL) {
+ item.setAttribute("class", `${item.localName}-iconic`);
+ item.setAttribute("image", iconURL);
+ }
+ aParentMenu.appendChild(item);
+
+ let previousUploads = account.getPreviousUploads();
+ let addedFiles = [];
+ for (let upload of previousUploads) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(upload.path);
+
+ // TODO: Figure out how to handle files that no longer exist on the filesystem.
+ if (!file.exists()) {
+ continue;
+ }
+ if (!addedFiles.find(f => f.name == upload.name || f.url == upload.url)) {
+ let fileItem = document.createXULElement("menuitem");
+ fileItem.cloudFileUpload = upload;
+ fileItem.cloudFileAccount = account;
+ fileItem.setAttribute("label", upload.name);
+ fileItem.setAttribute("class", "menuitem-iconic");
+ fileItem.setAttribute("image", "moz-icon://" + upload.name);
+ aParentMenu.appendChild(fileItem);
+ addedFiles.push({ name: upload.name, url: upload.url });
+ }
+ }
+ }
+}
+
+function addConvertCloudMenuItems(aParentMenu, aAfterNodeId, aRadioGroup) {
+ let afterNode = document.getElementById(aAfterNodeId);
+ while (afterNode.nextElementSibling) {
+ afterNode.nextElementSibling.remove();
+ }
+
+ if (!gAttachmentBucket.selectedItem.sendViaCloud) {
+ let item = document.getElementById(
+ "convertCloudMenuItems_popup_convertAttachment"
+ );
+ item.setAttribute("checked", "true");
+ }
+
+ for (let account of cloudFileAccounts.configuredAccounts) {
+ let item = document.createXULElement("menuitem");
+ let iconURL = account.iconURL;
+ item.cloudFileAccount = account;
+ item.setAttribute("label", cloudFileAccounts.getDisplayName(account));
+ item.setAttribute("type", "radio");
+ item.setAttribute("name", aRadioGroup);
+
+ if (
+ gAttachmentBucket.selectedItem.cloudFileAccount &&
+ gAttachmentBucket.selectedItem.cloudFileAccount.accountKey ==
+ account.accountKey
+ ) {
+ item.setAttribute("checked", "true");
+ } else if (iconURL) {
+ item.setAttribute("class", "menu-iconic");
+ item.setAttribute("image", iconURL);
+ }
+
+ aParentMenu.appendChild(item);
+ }
+
+ // Check if the cloudFile has an invalid account and deselect the default
+ // option, allowing to convert it back to a regular file.
+ if (
+ gAttachmentBucket.selectedItem.attachment.sendViaCloud &&
+ !gAttachmentBucket.selectedItem.cloudFileAccount
+ ) {
+ let regularItem = document.getElementById(
+ "convertCloudMenuItems_popup_convertAttachment"
+ );
+ regularItem.removeAttribute("checked");
+ }
+}
+
+async function updateAttachmentItemProperties(attachmentItem) {
+ // FIXME: The UI logic should be handled by the attachment list or item
+ // itself.
+ if (attachmentItem.uploading) {
+ // uploading/renaming
+ attachmentItem.setAttribute(
+ "tooltiptext",
+ getComposeBundle().getFormattedString("cloudFileUploadingTooltip", [
+ cloudFileAccounts.getDisplayName(attachmentItem.uploading),
+ ])
+ );
+ gAttachmentBucket.setCloudIcon(attachmentItem, "");
+ } else if (attachmentItem.attachment.sendViaCloud) {
+ let [tooltipUnknownAccountText, introText, titleText] =
+ await document.l10n.formatValues([
+ "cloud-file-unknown-account-tooltip",
+ {
+ id: "cloud-file-placeholder-intro",
+ args: { filename: attachmentItem.attachment.name },
+ },
+ {
+ id: "cloud-file-placeholder-title",
+ args: { filename: attachmentItem.attachment.name },
+ },
+ ]);
+
+ // uploaded
+ let tooltiptext;
+ if (attachmentItem.cloudFileAccount) {
+ tooltiptext = getComposeBundle().getFormattedString(
+ "cloudFileUploadedTooltip",
+ [cloudFileAccounts.getDisplayName(attachmentItem.cloudFileAccount)]
+ );
+ } else {
+ tooltiptext = tooltipUnknownAccountText;
+ }
+ attachmentItem.setAttribute("tooltiptext", tooltiptext);
+
+ gAttachmentBucket.setAttachmentName(
+ attachmentItem,
+ attachmentItem.attachment.name
+ );
+ gAttachmentBucket.setCloudIcon(
+ attachmentItem,
+ attachmentItem.cloudFileUpload.serviceIcon
+ );
+
+ // Update the CloudPartHeaderData, if there is a valid cloudFileUpload.
+ if (attachmentItem.cloudFileUpload) {
+ let json = JSON.stringify(attachmentItem.cloudFileUpload);
+ // Convert 16bit JavaScript string to a byteString, to make it work with
+ // btoa().
+ attachmentItem.attachment.cloudPartHeaderData = btoa(
+ MailStringUtils.stringToByteString(json)
+ );
+ }
+
+ // Update the cloudFile placeholder file.
+ attachmentItem.attachment.htmlAnnotation = `<!DOCTYPE html>
+<html>
+ <head>
+ <title>${titleText}</title>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div style="padding: 15px; font-family: Calibri, sans-serif;">
+ <div style="margin-bottom: 15px;" id="cloudAttachmentListHeader">${introText}</div>
+ <ul>${
+ (
+ await gCloudAttachmentLinkManager._createNode(
+ document,
+ attachmentItem.cloudFileUpload,
+ true
+ )
+ ).outerHTML
+ }</ul>
+ </div>
+ </body>
+</html>`;
+
+ // Calculate size of placeholder attachment.
+ attachmentItem.cloudHtmlFileSize = new TextEncoder().encode(
+ attachmentItem.attachment.htmlAnnotation
+ ).length;
+ } else {
+ // local
+ attachmentItem.setAttribute("tooltiptext", attachmentItem.attachment.url);
+ gAttachmentBucket.setAttachmentName(
+ attachmentItem,
+ attachmentItem.attachment.name
+ );
+ gAttachmentBucket.setCloudIcon(attachmentItem, "");
+
+ // Remove placeholder file size information.
+ delete attachmentItem.cloudHtmlFileSize;
+ }
+ updateAttachmentPane();
+}
+
+async function showLocalizedCloudFileAlert(
+ ex,
+ provider = ex.cloudProvider,
+ filename = ex.cloudFileName
+) {
+ let bundle = getComposeBundle();
+ let localizedTitle, localizedMessage;
+
+ switch (ex.result) {
+ case cloudFileAccounts.constants.uploadCancelled:
+ // No alerts for cancelled uploads.
+ return;
+ case cloudFileAccounts.constants.deleteErr:
+ localizedTitle = bundle.getString("errorCloudFileDeletion.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileDeletion.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.offlineErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-connection-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-connection-error",
+ {
+ provider,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.authErr:
+ localizedTitle = bundle.getString("errorCloudFileAuth.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileAuth.message",
+ [provider]
+ );
+ break;
+ case cloudFileAccounts.constants.uploadErrWithCustomMessage:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-upload-error-with-custom-message-title",
+ {
+ provider,
+ filename,
+ }
+ );
+ localizedMessage = ex.message;
+ break;
+ case cloudFileAccounts.constants.uploadErr:
+ localizedTitle = bundle.getString("errorCloudFileUpload.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileUpload.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.uploadWouldExceedQuota:
+ localizedTitle = bundle.getString("errorCloudFileQuota.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileQuota.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.uploadExceedsFileLimit:
+ localizedTitle = bundle.getString("errorCloudFileLimit.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileLimit.message",
+ [provider, filename]
+ );
+ break;
+ case cloudFileAccounts.constants.renameNotSupported:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-rename-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-rename-not-supported",
+ {
+ provider,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.renameErrWithCustomMessage:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-rename-error-with-custom-message-title",
+ {
+ provider,
+ filename,
+ }
+ );
+ localizedMessage = ex.message;
+ break;
+ case cloudFileAccounts.constants.renameErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-rename-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-rename-error",
+ {
+ provider,
+ filename,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.attachmentErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-attachment-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-attachment-error",
+ {
+ filename,
+ }
+ );
+ break;
+ case cloudFileAccounts.constants.accountErr:
+ localizedTitle = await l10nCompose.formatValue(
+ "cloud-file-account-error-title"
+ );
+ localizedMessage = await l10nCompose.formatValue(
+ "cloud-file-account-error",
+ {
+ filename,
+ }
+ );
+ break;
+ default:
+ localizedTitle = bundle.getString("errorCloudFileOther.title");
+ localizedMessage = bundle.getFormattedString(
+ "errorCloudFileOther.message",
+ [provider]
+ );
+ }
+
+ Services.prompt.alert(window, localizedTitle, localizedMessage);
+}
+
+/**
+ * @typedef UpdateSettings
+ * @property {CloudFileAccount} [cloudFileAccount] - cloud file account to store
+ * the attachment
+ * @property {CloudFileUpload} [relatedCloudFileUpload] - information about an
+ * already uploaded file this upload is related to, e.g. renaming a repeatedly
+ * used cloud file or updating the content of a cloud file
+ * @property {nsIFile} [file] - file to replace the current attachments content
+ * @property {string} [name] - name to replace the current attachments name
+ */
+
+/**
+ * Update the name and or the content of an attachment, as well as its local/cloud
+ * state.
+ *
+ * @param {DOMNode} attachmentItem - the existing attachmentItem
+ * @param {UpdateSettings} [updateSettings] - object defining how to update the
+ * attachment
+ */
+async function UpdateAttachment(attachmentItem, updateSettings = {}) {
+ if (!attachmentItem || !attachmentItem.attachment) {
+ throw new Error("Unexpected: Invalid attachment item.");
+ }
+
+ let originalAttachment = Object.assign({}, attachmentItem.attachment);
+ let eventOnDone = false;
+
+ // Ignore empty or falsy names.
+ let name = updateSettings.name || attachmentItem.attachment.name;
+
+ let destCloudFileAccount = updateSettings.hasOwnProperty("cloudFileAccount")
+ ? updateSettings.cloudFileAccount
+ : attachmentItem.cloudFileAccount;
+
+ try {
+ if (
+ // Bypass upload and set provided relatedCloudFileUpload.
+ updateSettings.relatedCloudFileUpload &&
+ updateSettings.cloudFileAccount &&
+ updateSettings.cloudFileAccount.reuseUploads &&
+ !updateSettings.file &&
+ !updateSettings.name
+ ) {
+ attachmentItem.attachment.sendViaCloud = true;
+ attachmentItem.attachment.contentLocation =
+ updateSettings.relatedCloudFileUpload.url;
+ attachmentItem.attachment.cloudFileAccountKey =
+ updateSettings.cloudFileAccount.accountKey;
+
+ attachmentItem.cloudFileAccount = updateSettings.cloudFileAccount;
+ attachmentItem.cloudFileUpload = updateSettings.relatedCloudFileUpload;
+ gAttachmentBucket.setCloudIcon(
+ attachmentItem,
+ updateSettings.relatedCloudFileUpload.serviceIcon
+ );
+
+ eventOnDone = new CustomEvent("attachment-uploaded", {
+ bubbles: true,
+ cancelable: true,
+ });
+ } else if (
+ // Handle a local -> local replace/rename.
+ !attachmentItem.attachment.sendViaCloud &&
+ !updateSettings.hasOwnProperty("cloudFileAccount")
+ ) {
+ // Both modes - rename and replace - require the same UI handling.
+ eventOnDone = new CustomEvent("attachment-renamed", {
+ bubbles: true,
+ cancelable: true,
+ detail: originalAttachment,
+ });
+ } else if (
+ // Handle a cloud -> local conversion.
+ attachmentItem.attachment.sendViaCloud &&
+ updateSettings.cloudFileAccount === null
+ ) {
+ // Throw if the linked local file does not exists (i.e. invalid draft).
+ if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) {
+ throw Components.Exception(
+ `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`,
+ cloudFileAccounts.constants.attachmentErr
+ );
+ }
+
+ if (attachmentItem.cloudFileAccount) {
+ // A cloud delete error is not considered to be a fatal error. It is
+ // not preventing the attachment from being removed from the composer.
+ attachmentItem.cloudFileAccount
+ .deleteFile(window, attachmentItem.cloudFileUpload.id)
+ .catch(ex => console.warn(ex.message));
+ }
+ // Clean up attachment from cloud bits.
+ attachmentItem.attachment.sendViaCloud = false;
+ attachmentItem.attachment.htmlAnnotation = "";
+ attachmentItem.attachment.contentLocation = "";
+ attachmentItem.attachment.cloudFileAccountKey = "";
+ attachmentItem.attachment.cloudPartHeaderData = "";
+ delete attachmentItem.cloudFileAccount;
+ delete attachmentItem.cloudFileUpload;
+
+ eventOnDone = new CustomEvent("attachment-converted-to-regular", {
+ bubbles: true,
+ cancelable: true,
+ detail: originalAttachment,
+ });
+ } else if (
+ // Exit early if offline.
+ Services.io.offline
+ ) {
+ throw Components.Exception(
+ "Connection error: Offline",
+ cloudFileAccounts.constants.offlineErr
+ );
+ } else {
+ // Handle a cloud -> cloud move/rename or a local -> cloud upload.
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+
+ let mode = "upload";
+ if (attachmentItem.attachment.sendViaCloud) {
+ // Throw if the used cloudFile account does not exists (invalid draft,
+ // disabled add-on, removed account).
+ if (
+ !destCloudFileAccount ||
+ !cloudFileAccounts.getAccount(destCloudFileAccount.accountKey)
+ ) {
+ throw Components.Exception(
+ `CloudFile Error: Account not found: ${destCloudFileAccount?.accountKey}`,
+ cloudFileAccounts.constants.accountErr
+ );
+ }
+
+ if (
+ attachmentItem.cloudFileUpload &&
+ attachmentItem.cloudFileAccount == destCloudFileAccount &&
+ !updateSettings.file &&
+ !destCloudFileAccount.isReusedUpload(attachmentItem.cloudFileUpload)
+ ) {
+ mode = "rename";
+ } else {
+ mode = "move";
+ // Throw if the linked local file does not exists (invalid draft, removed
+ // local file).
+ if (
+ !fileHandler
+ .getFileFromURLSpec(attachmentItem.attachment.url)
+ .exists()
+ ) {
+ throw Components.Exception(
+ `CloudFile Error: Attachment file not found: ${
+ fileHandler.getFileFromURLSpec(attachmentItem.attachment.url)
+ .path
+ }`,
+ cloudFileAccounts.constants.attachmentErr
+ );
+ }
+ if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) {
+ throw Components.Exception(
+ `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`,
+ cloudFileAccounts.constants.attachmentErr
+ );
+ }
+ }
+ }
+
+ // Notify the UI that we're starting the upload process: disable send commands
+ // and show a "connecting" icon for the attachment.
+ gNumUploadingAttachments++;
+ updateSendCommands(true);
+
+ attachmentItem.uploading = destCloudFileAccount;
+ await updateAttachmentItemProperties(attachmentItem);
+
+ const eventsOnStart = {
+ upload: "attachment-uploading",
+ move: "attachment-moving",
+ };
+ if (eventsOnStart[mode]) {
+ attachmentItem.dispatchEvent(
+ new CustomEvent(eventsOnStart[mode], {
+ bubbles: true,
+ cancelable: true,
+ detail: attachmentItem.attachment,
+ })
+ );
+ }
+
+ try {
+ let upload;
+ if (mode == "rename") {
+ upload = await destCloudFileAccount.renameFile(
+ window,
+ attachmentItem.cloudFileUpload.id,
+ name
+ );
+ } else {
+ let file =
+ updateSettings.file ||
+ fileHandler.getFileFromURLSpec(attachmentItem.attachment.url);
+
+ upload = await destCloudFileAccount.uploadFile(
+ window,
+ file,
+ name,
+ updateSettings.relatedCloudFileUpload
+ );
+
+ attachmentItem.cloudFileAccount = destCloudFileAccount;
+ attachmentItem.attachment.sendViaCloud = true;
+ attachmentItem.attachment.cloudFileAccountKey =
+ destCloudFileAccount.accountKey;
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.filelink.uploaded_size",
+ destCloudFileAccount.type,
+ file.fileSize
+ );
+ }
+
+ attachmentItem.cloudFileUpload = upload;
+ attachmentItem.attachment.contentLocation = upload.url;
+
+ const eventsOnSuccess = {
+ upload: "attachment-uploaded",
+ move: "attachment-moved",
+ rename: "attachment-renamed",
+ };
+ if (eventsOnSuccess[mode]) {
+ eventOnDone = new CustomEvent(eventsOnSuccess[mode], {
+ bubbles: true,
+ cancelable: true,
+ detail: originalAttachment,
+ });
+ }
+ } catch (ex) {
+ const eventsOnFailure = {
+ upload: "attachment-upload-failed",
+ move: "attachment-move-failed",
+ };
+ if (eventsOnFailure[mode]) {
+ eventOnDone = new CustomEvent(eventsOnFailure[mode], {
+ bubbles: true,
+ cancelable: true,
+ detail: ex.result,
+ });
+ }
+ throw ex;
+ } finally {
+ attachmentItem.uploading = false;
+ gNumUploadingAttachments--;
+ updateSendCommands(true);
+ }
+ }
+
+ // Update the local attachment.
+ if (updateSettings.file) {
+ let attachment = FileToAttachment(updateSettings.file);
+ attachmentItem.attachment.size = attachment.size;
+ attachmentItem.attachment.url = attachment.url;
+ }
+ attachmentItem.attachment.name = name;
+
+ AttachmentsChanged();
+ // Update cmd_sortAttachmentsToggle because replacing/renaming may change the
+ // current sort order.
+ goUpdateCommand("cmd_sortAttachmentsToggle");
+ } catch (ex) {
+ // Attach provider and fileName to the Exception, so showLocalizedCloudFileAlert()
+ // can display the proper alert message.
+ ex.cloudProvider = destCloudFileAccount
+ ? cloudFileAccounts.getDisplayName(destCloudFileAccount)
+ : "";
+ ex.cloudFileName = originalAttachment?.name || name;
+ throw ex;
+ } finally {
+ await updateAttachmentItemProperties(attachmentItem);
+ if (eventOnDone) {
+ attachmentItem.dispatchEvent(eventOnDone);
+ }
+ }
+}
+
+function attachToCloud(event) {
+ gMsgCompose.allowRemoteContent = true;
+ if (event.target.cloudFileUpload) {
+ attachToCloudRepeat(
+ event.target.cloudFileUpload,
+ event.target.cloudFileAccount
+ );
+ } else {
+ attachToCloudNew(event.target.cloudFileAccount);
+ }
+ event.stopPropagation();
+}
+
+/**
+ * Attach a file that has already been uploaded to a cloud provider.
+ *
+ * @param {object} upload - the cloudFileUpload of the already uploaded file
+ * @param {object} account - the cloudFileAccount of the already uploaded file
+ */
+async function attachToCloudRepeat(upload, account) {
+ gMsgCompose.allowRemoteContent = true;
+ let file = FileUtils.File(upload.path);
+ let attachment = FileToAttachment(file);
+ attachment.name = upload.name;
+
+ let addedAttachmentItems = await AddAttachments([attachment]);
+ if (addedAttachmentItems.length > 0) {
+ try {
+ await UpdateAttachment(addedAttachmentItems[0], {
+ cloudFileAccount: account,
+ relatedCloudFileUpload: upload,
+ });
+ } catch (ex) {
+ showLocalizedCloudFileAlert(ex);
+ }
+ }
+}
+
+/**
+ * Prompt the user for a list of files to attach via a cloud provider.
+ *
+ * @param aAccount the cloud provider to upload the files to
+ */
+async function attachToCloudNew(aAccount) {
+ // We need to let the user pick local file(s) to upload to the cloud and
+ // gather url(s) to those files.
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(
+ window,
+ getComposeBundle().getFormattedString("chooseFileToAttachViaCloud", [
+ cloudFileAccounts.getDisplayName(aAccount),
+ ]),
+ Ci.nsIFilePicker.modeOpenMultiple
+ );
+
+ var lastDirectory = GetLastAttachDirectory();
+ if (lastDirectory) {
+ fp.displayDirectory = lastDirectory;
+ }
+
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ let rv = await new Promise(resolve => fp.open(resolve));
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.files) {
+ return;
+ }
+
+ let files = [...fp.files];
+ let attachments = files.map(f => FileToAttachment(f));
+ let addedAttachmentItems = await AddAttachments(attachments);
+ SetLastAttachDirectory(files[files.length - 1]);
+
+ let promises = [];
+ for (let attachmentItem of addedAttachmentItems) {
+ promises.push(
+ UpdateAttachment(attachmentItem, { cloudFileAccount: aAccount }).catch(
+ ex => {
+ RemoveAttachments([attachmentItem]);
+ showLocalizedCloudFileAlert(ex);
+ }
+ )
+ );
+ }
+
+ await Promise.all(promises);
+}
+
+/**
+ * Convert an array of attachments to cloud attachments.
+ *
+ * @param aItems an array of <attachmentitem>s containing the attachments in
+ * question
+ * @param aAccount the cloud account to upload the files to
+ */
+async function convertListItemsToCloudAttachment(aItems, aAccount) {
+ gMsgCompose.allowRemoteContent = true;
+ let promises = [];
+ for (let item of aItems) {
+ // Bail out, if we would convert to the current account.
+ if (
+ item.attachment.sendViaCloud &&
+ item.cloudFileAccount &&
+ item.cloudFileAccount == aAccount
+ ) {
+ continue;
+ }
+ promises.push(
+ UpdateAttachment(item, { cloudFileAccount: aAccount }).catch(
+ showLocalizedCloudFileAlert
+ )
+ );
+ }
+ await Promise.all(promises);
+}
+
+/**
+ * Convert the selected attachments to cloud attachments.
+ *
+ * @param aAccount the cloud account to upload the files to
+ */
+function convertSelectedToCloudAttachment(aAccount) {
+ convertListItemsToCloudAttachment(
+ [...gAttachmentBucket.selectedItems],
+ aAccount
+ );
+}
+
+/**
+ * Convert an array of nsIMsgAttachments to cloud attachments.
+ *
+ * @param aAttachments an array of nsIMsgAttachments
+ * @param aAccount the cloud account to upload the files to
+ */
+function convertToCloudAttachment(aAttachments, aAccount) {
+ let items = [];
+ for (let attachment of aAttachments) {
+ let item = gAttachmentBucket.findItemForAttachment(attachment);
+ if (item) {
+ items.push(item);
+ }
+ }
+
+ convertListItemsToCloudAttachment(items, aAccount);
+}
+
+/**
+ * Convert an array of attachments to regular (non-cloud) attachments.
+ *
+ * @param aItems an array of <attachmentitem>s containing the attachments in
+ * question
+ */
+async function convertListItemsToRegularAttachment(aItems) {
+ let promises = [];
+ for (let item of aItems) {
+ if (!item.attachment.sendViaCloud) {
+ continue;
+ }
+ promises.push(
+ UpdateAttachment(item, { cloudFileAccount: null }).catch(
+ showLocalizedCloudFileAlert
+ )
+ );
+ }
+ await Promise.all(promises);
+}
+
+/**
+ * Convert the selected attachments to regular (non-cloud) attachments.
+ */
+function convertSelectedToRegularAttachment() {
+ return convertListItemsToRegularAttachment([
+ ...gAttachmentBucket.selectedItems,
+ ]);
+}
+
+/**
+ * Convert an array of nsIMsgAttachments to regular (non-cloud) attachments.
+ *
+ * @param aAttachments an array of nsIMsgAttachments
+ */
+function convertToRegularAttachment(aAttachments) {
+ let items = [];
+ for (let attachment of aAttachments) {
+ let item = gAttachmentBucket.findItemForAttachment(attachment);
+ if (item) {
+ items.push(item);
+ }
+ }
+
+ return convertListItemsToRegularAttachment(items);
+}
+
+/* messageComposeOfflineQuitObserver is notified whenever the network
+ * connection status has switched to offline, or when the application
+ * has received a request to quit.
+ */
+var messageComposeOfflineQuitObserver = {
+ observe(aSubject, aTopic, aData) {
+ // sanity checks
+ if (aTopic == "network:offline-status-changed") {
+ MessageComposeOfflineStateChanged(Services.io.offline);
+ } else if (
+ aTopic == "quit-application-requested" &&
+ aSubject instanceof Ci.nsISupportsPRBool &&
+ !aSubject.data
+ ) {
+ // Check whether to veto the quit request
+ // (unless another observer already did).
+ aSubject.data = !ComposeCanClose();
+ }
+ },
+};
+
+function AddMessageComposeOfflineQuitObserver() {
+ Services.obs.addObserver(
+ messageComposeOfflineQuitObserver,
+ "network:offline-status-changed"
+ );
+ Services.obs.addObserver(
+ messageComposeOfflineQuitObserver,
+ "quit-application-requested"
+ );
+
+ // set the initial state of the send button
+ MessageComposeOfflineStateChanged(Services.io.offline);
+}
+
+function RemoveMessageComposeOfflineQuitObserver() {
+ Services.obs.removeObserver(
+ messageComposeOfflineQuitObserver,
+ "network:offline-status-changed"
+ );
+ Services.obs.removeObserver(
+ messageComposeOfflineQuitObserver,
+ "quit-application-requested"
+ );
+}
+
+function MessageComposeOfflineStateChanged(goingOffline) {
+ try {
+ var sendButton = document.getElementById("button-send");
+ var sendNowMenuItem = document.getElementById("menu-item-send-now");
+
+ if (!gSavedSendNowKey) {
+ gSavedSendNowKey = sendNowMenuItem.getAttribute("key");
+ }
+
+ // don't use goUpdateCommand here ... the defaultController might not be installed yet
+ updateSendCommands(false);
+
+ if (goingOffline) {
+ sendButton.label = sendButton.getAttribute("later_label");
+ sendButton.setAttribute(
+ "tooltiptext",
+ sendButton.getAttribute("later_tooltiptext")
+ );
+ sendNowMenuItem.removeAttribute("key");
+ } else {
+ sendButton.label = sendButton.getAttribute("now_label");
+ sendButton.setAttribute(
+ "tooltiptext",
+ sendButton.getAttribute("now_tooltiptext")
+ );
+ if (gSavedSendNowKey) {
+ sendNowMenuItem.setAttribute("key", gSavedSendNowKey);
+ }
+ }
+ } catch (e) {}
+}
+
+function DoCommandPrint() {
+ let browser = GetCurrentEditorElement();
+ browser.contentDocument.title =
+ document.getElementById("msgSubject").value.trim() ||
+ getComposeBundle().getString("defaultSubject");
+ PrintUtils.startPrintWindow(browser.browsingContext, {});
+}
+
+/**
+ * Locks/Unlocks the window widgets while a message is being saved/sent.
+ * Locking means to disable all possible items in the window so that
+ * the user can't click/activate anything.
+ *
+ * @param aDisable true = lock the window. false = unlock the window.
+ */
+function ToggleWindowLock(aDisable) {
+ if (aDisable) {
+ // Save the active element so we can focus it again.
+ ToggleWindowLock.activeElement = document.activeElement;
+ }
+ gWindowLocked = aDisable;
+ updateAllItems(aDisable);
+ updateEditableFields(aDisable);
+ if (!aDisable) {
+ updateComposeItems();
+ // Refocus what had focus when the lock began.
+ ToggleWindowLock.activeElement?.focus();
+ }
+}
+
+/* This function will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string */
+function GetArgs(originalData) {
+ var args = {};
+
+ if (originalData == "") {
+ return null;
+ }
+
+ var data = "";
+ var separator = String.fromCharCode(1);
+
+ var quoteChar = "";
+ var prevChar = "";
+ var nextChar = "";
+ for (let i = 0; i < originalData.length; i++, prevChar = aChar) {
+ var aChar = originalData.charAt(i);
+ var aCharCode = originalData.charCodeAt(i);
+ if (i < originalData.length - 1) {
+ nextChar = originalData.charAt(i + 1);
+ } else {
+ nextChar = "";
+ }
+
+ if (aChar == quoteChar && (nextChar == "," || nextChar == "")) {
+ quoteChar = "";
+ data += aChar;
+ } else if ((aCharCode == 39 || aCharCode == 34) && prevChar == "=") {
+ // quote or double quote
+ if (quoteChar == "") {
+ quoteChar = aChar;
+ }
+ data += aChar;
+ } else if (aChar == ",") {
+ if (quoteChar == "") {
+ data += separator;
+ } else {
+ data += aChar;
+ }
+ } else {
+ data += aChar;
+ }
+ }
+
+ var pairs = data.split(separator);
+ // dump("Compose: argument: {" + data + "}\n");
+
+ for (let i = pairs.length - 1; i >= 0; i--) {
+ var pos = pairs[i].indexOf("=");
+ if (pos == -1) {
+ continue;
+ }
+ var argname = pairs[i].substring(0, pos);
+ var argvalue = pairs[i].substring(pos + 1);
+ if (argvalue.startsWith("'") && argvalue.endsWith("'")) {
+ args[argname] = argvalue.substring(1, argvalue.length - 1);
+ } else {
+ try {
+ args[argname] = decodeURIComponent(argvalue);
+ } catch (e) {
+ args[argname] = argvalue;
+ }
+ }
+ // dump("[" + argname + "=" + args[argname] + "]\n");
+ }
+ return args;
+}
+
+function ComposeFieldsReady() {
+ // If we are in plain text, we need to set the wrap column
+ if (!gMsgCompose.composeHTML) {
+ try {
+ gMsgCompose.editor.wrapWidth = gMsgCompose.wrapLength;
+ } catch (e) {
+ dump("### textEditor.wrapWidth exception text: " + e + " - failed\n");
+ }
+ }
+
+ CompFields2Recipients(gMsgCompose.compFields);
+ SetComposeWindowTitle();
+ updateEditableFields(false);
+ gLoadingComplete = true;
+
+ // Set up observers to recheck limit and encyption on recipients change.
+ observeRecipientsChange();
+
+ // Perform the initial checks.
+ checkPublicRecipientsLimit();
+ checkEncryptionState();
+}
+
+/**
+ * Set up observers to recheck limit and encyption on recipients change.
+ */
+function observeRecipientsChange() {
+ // Observe childList changes of `To` and `Cc` address rows to check if we need
+ // to show the public bulk recipients notification according to the threshold.
+ // So far we're only counting recipient pills, not plain text addresses.
+ gRecipientObserver = new MutationObserver(function (mutations) {
+ if (mutations.some(m => m.type == "childList")) {
+ checkPublicRecipientsLimit();
+ }
+ });
+ gRecipientObserver.observe(document.getElementById("toAddrContainer"), {
+ childList: true,
+ });
+ gRecipientObserver.observe(document.getElementById("ccAddrContainer"), {
+ childList: true,
+ });
+
+ function callCheckEncryptionState() {
+ // We must not pass the parameters that we get from observing.
+ checkEncryptionState();
+ }
+
+ gRecipientKeysObserver = new MutationObserver(callCheckEncryptionState);
+ gRecipientKeysObserver.observe(document.getElementById("toAddrContainer"), {
+ childList: true,
+ });
+ gRecipientKeysObserver.observe(document.getElementById("ccAddrContainer"), {
+ childList: true,
+ });
+ gRecipientKeysObserver.observe(document.getElementById("bccAddrContainer"), {
+ childList: true,
+ });
+}
+
+// checks if the passed in string is a mailto url, if it is, generates nsIMsgComposeParams
+// for the url and returns them.
+function handleMailtoArgs(mailtoUrl) {
+ // see if the string is a mailto url....do this by checking the first 7 characters of the string
+ if (mailtoUrl.toLowerCase().startsWith("mailto:")) {
+ // if it is a mailto url, turn the mailto url into a MsgComposeParams object....
+ let uri = Services.io.newURI(mailtoUrl);
+
+ if (uri) {
+ return MailServices.compose.getParamsForMailto(uri);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Handle ESC keypress from composition window for
+ * notifications with close button in the
+ * attachmentNotificationBox.
+ */
+function handleEsc() {
+ let activeElement = document.activeElement;
+
+ if (activeElement.id == "messageEditor") {
+ // Focus within the message body.
+ let findbar = document.getElementById("FindToolbar");
+ if (!findbar.hidden) {
+ // If findbar is visible hide it.
+ // Focus on the findbar is handled by findbar itself.
+ findbar.close();
+ } else {
+ // Close the most recently shown notification.
+ gComposeNotification.currentNotification?.close();
+ }
+ return;
+ }
+
+ // If focus is within a notification, close the corresponding notification.
+ for (let notification of gComposeNotification.allNotifications) {
+ if (notification.contains(activeElement)) {
+ notification.close();
+ return;
+ }
+ }
+}
+
+/**
+ * This state machine manages all showing and hiding of the attachment
+ * notification bar. It is only called if any change happened so that
+ * recalculating of the notification is needed:
+ * - keywords changed
+ * - manual reminder was toggled
+ * - attachments changed
+ * - manual reminder is disabled
+ *
+ * It does not track whether the notification is still up when it should be.
+ * That allows the user to close it any time without this function showing
+ * it again.
+ * We ensure notification is only shown on right events, e.g. only when we have
+ * keywords and attachments were removed (but not when we have keywords and
+ * manual reminder was just turned off). We always show the notification
+ * again if keywords change (if no attachments and no manual reminder).
+ *
+ * @param aForce If set to true, notification will be shown immediately if
+ * there are any keywords. If set to false, it is shown only when
+ * they have changed.
+ */
+function manageAttachmentNotification(aForce = false) {
+ let keywords;
+ let keywordsCount = 0;
+
+ // First see if the notification is to be hidden due to reasons other than
+ // not having keywords.
+ let removeNotification = attachmentNotificationSupressed();
+
+ // If that is not true, we need to look at the state of keywords.
+ if (!removeNotification) {
+ if (attachmentWorker.lastMessage) {
+ // We know the state of keywords, so process them.
+ if (attachmentWorker.lastMessage.length) {
+ keywords = attachmentWorker.lastMessage.join(", ");
+ keywordsCount = attachmentWorker.lastMessage.length;
+ }
+ removeNotification = keywordsCount == 0;
+ } else {
+ // We don't know keywords, so get them first.
+ // If aForce was true, and some keywords are found, we get to run again from
+ // attachmentWorker.onmessage().
+ gAttachmentNotifier.redetectKeywords(aForce);
+ return;
+ }
+ }
+
+ let notification =
+ gComposeNotification.getNotificationWithValue("attachmentReminder");
+ if (removeNotification) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ // We have some keywords, however only pop up the notification if requested
+ // to do so.
+ if (!aForce) {
+ return;
+ }
+
+ let textValue = getComposeBundle().getString(
+ "attachmentReminderKeywordsMsgs"
+ );
+ textValue = PluralForm.get(keywordsCount, textValue).replace(
+ "#1",
+ keywordsCount
+ );
+ // If the notification already exists, we simply add the new attachment
+ // specific keywords to the existing notification instead of creating it
+ // from scratch.
+ if (notification) {
+ let msgContainer = notification.messageText.querySelector(
+ "#attachmentReminderText"
+ );
+ msgContainer.textContent = textValue;
+ let keywordsContainer = notification.messageText.querySelector(
+ "#attachmentKeywords"
+ );
+ keywordsContainer.textContent = keywords;
+ return;
+ }
+
+ // Construct the notification as we don't have one.
+ let msg = document.createElement("div");
+ msg.onclick = function (event) {
+ openOptionsDialog("paneCompose", "compositionAttachmentsCategory", {
+ subdialog: "attachment_reminder_button",
+ });
+ };
+
+ let msgText = document.createElement("span");
+ msg.appendChild(msgText);
+ msgText.id = "attachmentReminderText";
+ msgText.textContent = textValue;
+ let msgKeywords = document.createElement("span");
+ msg.appendChild(msgKeywords);
+ msgKeywords.id = "attachmentKeywords";
+ msgKeywords.textContent = keywords;
+ let addButton = {
+ "l10n-id": "add-attachment-notification-reminder2",
+ callback(aNotificationBar, aButton) {
+ goDoCommand("cmd_attachFile");
+ return true; // keep notification open (the state machine will decide on it later)
+ },
+ };
+
+ let remindLaterMenuPopup = document.createXULElement("menupopup");
+ remindLaterMenuPopup.id = "reminderBarPopup";
+ let disableAttachmentReminder = document.createXULElement("menuitem");
+ disableAttachmentReminder.id = "disableReminder";
+ disableAttachmentReminder.setAttribute(
+ "label",
+ getComposeBundle().getString("disableAttachmentReminderButton")
+ );
+ disableAttachmentReminder.addEventListener("command", event => {
+ gDisableAttachmentReminder = true;
+ toggleAttachmentReminder(false);
+ event.stopPropagation();
+ });
+ remindLaterMenuPopup.appendChild(disableAttachmentReminder);
+
+ // The notification code only deals with buttons but we need a toolbarbutton,
+ // so we construct it and add it ourselves.
+ let remindButton = document.createXULElement("toolbarbutton", {
+ is: "toolbarbutton-menu-button",
+ });
+ remindButton.classList.add("notification-button", "small-button");
+ remindButton.setAttribute(
+ "accessKey",
+ getComposeBundle().getString("remindLaterButton.accesskey")
+ );
+ remindButton.setAttribute(
+ "label",
+ getComposeBundle().getString("remindLaterButton")
+ );
+ remindButton.addEventListener("command", function (event) {
+ toggleAttachmentReminder(true);
+ });
+ remindButton.appendChild(remindLaterMenuPopup);
+
+ notification = gComposeNotification.appendNotification(
+ "attachmentReminder",
+ {
+ label: "",
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ [addButton]
+ );
+ notification.setAttribute("id", "attachmentNotificationBox");
+
+ notification.messageText.appendChild(msg);
+ notification.buttonContainer.appendChild(remindButton);
+}
+
+function clearRecipPillKeyIssues() {
+ for (let pill of document.querySelectorAll("mail-address-pill.key-issue")) {
+ pill.classList.remove("key-issue");
+ }
+}
+
+/**
+ * @returns {string[]} - All current recipient email addresses, lowercase.
+ */
+function getEncryptionCompatibleRecipients() {
+ let recipientPills = [
+ ...document.querySelectorAll(
+ "#toAddrContainer > mail-address-pill, #ccAddrContainer > mail-address-pill, #bccAddrContainer > mail-address-pill"
+ ),
+ ];
+ let recipients = [
+ ...new Set(recipientPills.map(pill => pill.emailAddress.toLowerCase())),
+ ];
+ return recipients;
+}
+
+const PRErrorCodeSuccess = 0;
+const certificateUsageEmailRecipient = 0x0020;
+
+var gEmailsWithMissingKeys = null;
+var gEmailsWithMissingCerts = null;
+
+/**
+ * @returns {boolean} true if checking openpgp keys is necessary
+ */
+function mustCheckRecipientKeys() {
+ let remindOpenPGP = Services.prefs.getBoolPref(
+ "mail.openpgp.remind_encryption_possible"
+ );
+
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ return (
+ isPgpConfigured() && (gSendEncrypted || remindOpenPGP || autoEnablePref)
+ );
+}
+
+/**
+ * Check available OpenPGP public encryption keys for the given email
+ * addresses. (This function assumes the caller has already called
+ * mustCheckRecipientKeys() and the result was true.)
+ *
+ * gEmailsWithMissingKeys will be set to an array of email addresses
+ * (a subset of the input) that do NOT have a usable
+ * (valid + accepted) key.
+ *
+ * @param {string[]} recipients - The addresses to lookup.
+ */
+async function checkRecipientKeys(recipients) {
+ gEmailsWithMissingKeys = [];
+
+ for (let addr of recipients) {
+ let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(addr);
+
+ if (keyMetas.length == 1 && keyMetas[0].readiness == "alias") {
+ // Skip if this is an alias email.
+ continue;
+ }
+
+ if (!keyMetas.some(k => k.readiness == "accepted")) {
+ gEmailsWithMissingKeys.push(addr);
+ continue;
+ }
+ }
+}
+
+/**
+ * @returns {boolean} true if checking s/mime certificates is necessary
+ */
+function mustCheckRecipientCerts() {
+ let remindSMime = Services.prefs.getBoolPref(
+ "mail.smime.remind_encryption_possible"
+ );
+
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ return (
+ isSmimeEncryptionConfigured() &&
+ (gSendEncrypted || remindSMime || autoEnablePref)
+ );
+}
+
+/**
+ * Check available S/MIME encryption certificates for the given email
+ * addresses. (This function assumes the caller has already called
+ * mustCheckRecipientCerts() and the result was true.)
+ *
+ * gEmailsWithMissingCerts will be set to an array of email addresses
+ * (a subset of the input) that do NOT have a usable (valid) certificate.
+ *
+ * This function might take significant time to complete, because
+ * certificate verification involves OCSP, which runs on a background
+ * thread.
+ *
+ * @param {string[]} recipients - The addresses to lookup.
+ */
+function checkRecipientCerts(recipients) {
+ return new Promise((resolve, reject) => {
+ if (gSMPendingCertLookupSet.size) {
+ reject(
+ new Error(
+ "Must not be called while previous checks are still in progress"
+ )
+ );
+ }
+
+ gEmailsWithMissingCerts = [];
+
+ function continueCheckRecipientCerts() {
+ gEmailsWithMissingCerts = recipients.filter(
+ email => !gSMFields.haveValidCertForEmail(email)
+ );
+ resolve();
+ }
+
+ /** @implements {nsIDoneFindCertForEmailCallback} */
+ let doneFindCertForEmailCallback = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIDoneFindCertForEmailCallback",
+ ]),
+
+ findCertDone(email, cert) {
+ let isStaleResult = !gSMPendingCertLookupSet.has(email);
+ // isStaleResult true means, this recipient was removed by the
+ // user while we were looking for the cert in the background.
+ // Let's remember the result, but don't trigger any actions
+ // based on it.
+
+ if (cert) {
+ gSMFields.cacheValidCertForEmail(email, cert ? cert.dbKey : "");
+ }
+ if (isStaleResult) {
+ return;
+ }
+ gSMPendingCertLookupSet.delete(email);
+ if (!cert && !gSMCertsAlreadyLookedUpInLDAP.has(email)) {
+ let autocompleteLdap = Services.prefs.getBoolPref(
+ "ldap_2.autoComplete.useDirectory"
+ );
+
+ if (autocompleteLdap) {
+ gSMCertsAlreadyLookedUpInLDAP.add(email);
+
+ let autocompleteDirectory = null;
+ if (gCurrentIdentity.overrideGlobalPref) {
+ autocompleteDirectory = gCurrentIdentity.directoryServer;
+ } else {
+ autocompleteDirectory = Services.prefs.getCharPref(
+ "ldap_2.autoComplete.directoryServer"
+ );
+ }
+
+ if (autocompleteDirectory) {
+ window.openDialog(
+ "chrome://messenger-smime/content/certFetchingStatus.xhtml",
+ "",
+ "chrome,resizable=1,modal=1,dialog=1",
+ autocompleteDirectory,
+ [email]
+ );
+ }
+
+ gSMPendingCertLookupSet.add(email);
+ gSMFields.asyncFindCertByEmailAddr(
+ email,
+ doneFindCertForEmailCallback
+ );
+ }
+ }
+
+ if (gSMPendingCertLookupSet.size) {
+ // must continue to wait for more queued lookups to complete
+ return;
+ }
+
+ // No more lookups pending.
+ continueCheckRecipientCerts();
+ },
+ };
+
+ for (let email of recipients) {
+ if (gSMFields.haveValidCertForEmail(email)) {
+ continue;
+ }
+
+ if (gSMPendingCertLookupSet.has(email)) {
+ throw new Error(`cert lookup still pending for ${email}`);
+ }
+
+ gSMPendingCertLookupSet.add(email);
+ gSMFields.asyncFindCertByEmailAddr(email, doneFindCertForEmailCallback);
+ }
+
+ // If we haven't queued any lookups, we continue immediately
+ if (!gSMPendingCertLookupSet.size) {
+ continueCheckRecipientCerts();
+ }
+ });
+}
+
+/**
+ * gCheckEncryptionStateCompletionIsPending means that async work
+ * started by checkEncryptionState() has not yet completed.
+ */
+var gCheckEncryptionStateCompletionIsPending = false;
+
+/**
+ * gCheckEncryptionStateNeedsRestart means that checkEncryptionState()
+ * was called, while its async operations were still running.
+ * The additional to checkEncryptionState() was treated as a no-op,
+ * but gCheckEncryptionStateNeedsRestart was set to true, to remember
+ * that checkEncryptionState() must be immediately restarted after its
+ * previous execution is done. This will the restarted
+ * checkEncryptionState() execution to detect and handle changes that
+ * could result in a different state.
+ */
+var gCheckEncryptionStateNeedsRestart = false;
+
+/**
+ * gWasCESTriggeredByComposerChange is used to track whether an
+ * encryption-state-checked event should be sent after an ongoing
+ * execution of checkEncryptionState() is done.
+ * The purpose of the encryption-state-checked event is to allow our
+ * automated tests to be notified as soon as an automatic call to
+ * checkEncryptionState() (and all related async calls) is complete,
+ * which means all automatic adjustments to the global encryption state
+ * are done, and the automated test code may proceed to compare the
+ * state to our exptectations.
+ * We want that event to be sent after modifications were made to the
+ * composer window itself, such as sender identity and recipients.
+ * However, we want to ignore calls to checkEncryptionState() that
+ * were triggered indirectly after OpenPGP keys were changed.
+ * If an event was originally triggered by a change to OpenPGP keys,
+ * and the async processing of checkEncryptionState() was still running,
+ * and another direct change to the composer window was made, which
+ * shall result in sending a encryption-state-checked after completion,
+ * then the flag gWasCESTriggeredByComposerChange will be set,
+ * which will cause the event to be sent after the restarted call
+ * to checkEncryptionState() is complete.
+ */
+var gWasCESTriggeredByComposerChange = false;
+
+/**
+ * Perform all checks that are necessary to update the state of
+ * email encryption, based on the current recipients. This should be
+ * done whenever the recipient list or the status of available keys/certs
+ * has changed. All automatic actions for encryption related settings
+ * will be triggered accordingly.
+ * This function will trigger async activity, and the resulting actions
+ * (e.g. update of UI elements) may happen after a delay.
+ * It's safe to call this while processing hasn't completed yet, in this
+ * scenario the processing will be restarted, once pending
+ * activity has completed.
+ *
+ * @param {string} [trigger] - A string that gives information about
+ * the reason why this function is being called.
+ * This parameter is intended to help with automated testing.
+ * If the trigger string starts with "openpgp-" then no completition
+ * event will be dispatched. This allows the automated test code to
+ * wait for events that are directly related to properties of the
+ * composer window, only.
+ */
+async function checkEncryptionState(trigger) {
+ if (!gLoadingComplete) {
+ // Let's not do this while we're still loading the composer window,
+ // it can have side effects, see bug 1777683.
+ // Also, if multiple recipients are added to an email automatically
+ // e.g. during reply-all, it doesn't make sense to execute this
+ // function every time after one of them gets added.
+ return;
+ }
+
+ if (!/^openpgp-/.test(trigger)) {
+ gWasCESTriggeredByComposerChange = true;
+ }
+
+ if (gCheckEncryptionStateCompletionIsPending) {
+ // avoid concurrency
+ gCheckEncryptionStateNeedsRestart = true;
+ return;
+ }
+
+ let remindSMime = Services.prefs.getBoolPref(
+ "mail.smime.remind_encryption_possible"
+ );
+ let remindOpenPGP = Services.prefs.getBoolPref(
+ "mail.openpgp.remind_encryption_possible"
+ );
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ if (!gSendEncrypted && !autoEnablePref && !remindSMime && !remindOpenPGP) {
+ // No need to check.
+ updateEncryptionDependencies();
+ updateKeyCertNotifications([]);
+ updateEncryptionTechReminder(null);
+ if (gWasCESTriggeredByComposerChange) {
+ document.dispatchEvent(new CustomEvent("encryption-state-checked"));
+ gWasCESTriggeredByComposerChange = false;
+ }
+ return;
+ }
+
+ let recipients = getEncryptionCompatibleRecipients();
+ let checkingCerts = mustCheckRecipientCerts();
+ let checkingKeys = mustCheckRecipientKeys();
+
+ async function continueCheckEncryptionStateSub() {
+ let canEncryptSMIME =
+ recipients.length && checkingCerts && !gEmailsWithMissingCerts.length;
+ let canEncryptOpenPGP =
+ recipients.length && checkingKeys && !gEmailsWithMissingKeys.length;
+
+ let autoEnabledJustNow = false;
+
+ if (
+ gSendEncrypted &&
+ gUserTouchedSendEncrypted &&
+ !isPgpConfigured() &&
+ !isSmimeEncryptionConfigured()
+ ) {
+ notifyIdentityCannotEncrypt(true, gCurrentIdentity.email);
+ } else {
+ notifyIdentityCannotEncrypt(false, gCurrentIdentity.email);
+ }
+
+ if (
+ !gSendEncrypted &&
+ autoEnablePref &&
+ !gUserTouchedSendEncrypted &&
+ recipients.length &&
+ (canEncryptSMIME || canEncryptOpenPGP)
+ ) {
+ if (!canEncryptSMIME) {
+ gSelectedTechnologyIsPGP = true;
+ } else if (!canEncryptOpenPGP) {
+ gSelectedTechnologyIsPGP = false;
+ }
+ gSendEncrypted = true;
+ autoEnabledJustNow = true;
+ removeAutoDisableNotification();
+ }
+
+ if (
+ !gIsRelatedToEncryptedOriginal &&
+ !autoEnabledJustNow &&
+ !gUserTouchedSendEncrypted &&
+ gSendEncrypted &&
+ !canEncryptSMIME &&
+ !canEncryptOpenPGP
+ ) {
+ // The auto_disable pref is ignored if auto_enable is false
+ let autoDisablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_disable",
+ false
+ );
+ if (autoEnablePref && autoDisablePref && !gUserTouchedSendEncrypted) {
+ gSendEncrypted = false;
+ let notifyPref = Services.prefs.getBoolPref(
+ "mail.e2ee.notify_on_auto_disable",
+ true
+ );
+ if (notifyPref) {
+ // Most likely the notification is not showing yet, and we
+ // must append it. (We should have removed an existing
+ // notification at the time encryption was enabled.)
+ // However, double check to avoid that we'll show it twice.
+ const NOTIFICATION_NAME = "e2eeDisableNotification";
+ let notification =
+ gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME);
+ if (!notification) {
+ gComposeNotification.appendNotification(
+ NOTIFICATION_NAME,
+ {
+ label: { "l10n-id": "auto-disable-e2ee-warning" },
+ priority: gComposeNotification.PRIORITY_WARNING_LOW,
+ },
+ []
+ );
+ }
+ }
+ }
+ }
+
+ let techPref = gCurrentIdentity.getIntAttribute("e2etechpref");
+
+ if (gSendEncrypted && canEncryptSMIME && canEncryptOpenPGP) {
+ // No change if 0
+ if (techPref == 1) {
+ gSelectedTechnologyIsPGP = false;
+ } else if (techPref == 2) {
+ gSelectedTechnologyIsPGP = true;
+ }
+ }
+
+ if (
+ gSendEncrypted &&
+ canEncryptSMIME &&
+ !canEncryptOpenPGP &&
+ gSelectedTechnologyIsPGP
+ ) {
+ gSelectedTechnologyIsPGP = false;
+ }
+
+ if (
+ gSendEncrypted &&
+ !canEncryptSMIME &&
+ canEncryptOpenPGP &&
+ !gSelectedTechnologyIsPGP
+ ) {
+ gSelectedTechnologyIsPGP = true;
+ }
+
+ updateEncryptionDependencies();
+
+ if (!gSendEncrypted) {
+ updateKeyCertNotifications([]);
+ if (recipients.length && (canEncryptSMIME || canEncryptOpenPGP)) {
+ let useTech;
+ if (canEncryptSMIME && canEncryptOpenPGP) {
+ if (techPref == 1) {
+ useTech = "SMIME";
+ } else {
+ useTech = "OpenPGP";
+ }
+ } else {
+ useTech = canEncryptOpenPGP ? "OpenPGP" : "SMIME";
+ }
+ updateEncryptionTechReminder(useTech);
+ } else {
+ updateEncryptionTechReminder(null);
+ }
+ } else {
+ updateKeyCertNotifications(
+ gSelectedTechnologyIsPGP
+ ? gEmailsWithMissingKeys
+ : gEmailsWithMissingCerts
+ );
+ updateEncryptionTechReminder(null);
+ }
+
+ gCheckEncryptionStateCompletionIsPending = false;
+
+ if (gCheckEncryptionStateNeedsRestart) {
+ // Recursive call, which is acceptable (and not blocking),
+ // because necessary long actions will be triggered asynchronously.
+ gCheckEncryptionStateNeedsRestart = false;
+ await checkEncryptionState(trigger);
+ } else if (gWasCESTriggeredByComposerChange) {
+ document.dispatchEvent(new CustomEvent("encryption-state-checked"));
+ gWasCESTriggeredByComposerChange = false;
+ }
+ }
+
+ let pendingPromises = [];
+
+ if (checkingCerts) {
+ pendingPromises.push(checkRecipientCerts(recipients));
+ }
+
+ if (checkingKeys) {
+ pendingPromises.push(checkRecipientKeys(recipients));
+ }
+
+ gCheckEncryptionStateNeedsRestart = false;
+ gCheckEncryptionStateCompletionIsPending = true;
+
+ Promise.all(pendingPromises).then(continueCheckEncryptionStateSub);
+}
+
+/**
+ * Display (or hide) the notification that informs the user that
+ * encryption is possible (but currently not enabled).
+ *
+ * @param {string} technology - The technology that is possible,
+ * ("OpenPGP" or "SMIME"), or null if none is possible.
+ */
+function updateEncryptionTechReminder(technology) {
+ let enableNotification =
+ gComposeNotification.getNotificationWithValue("enableNotification");
+ if (enableNotification) {
+ gComposeNotification.removeNotification(enableNotification);
+ }
+
+ if (!technology || (technology != "OpenPGP" && technology != "SMIME")) {
+ return;
+ }
+
+ let labelId =
+ technology == "OpenPGP"
+ ? "can-encrypt-openpgp-notification"
+ : "can-encrypt-smime-notification";
+
+ gComposeNotification.appendNotification(
+ "enableNotification",
+ {
+ label: { "l10n-id": labelId },
+ priority: gComposeNotification.PRIORITY_INFO_LOW,
+ },
+ [
+ {
+ "l10n-id": "can-e2e-encrypt-button",
+ callback() {
+ gSelectedTechnologyIsPGP = technology == "OpenPGP";
+ gSendEncrypted = true;
+ gUserTouchedSendEncrypted = true;
+ checkEncryptionState();
+ return true;
+ },
+ },
+ ]
+ );
+}
+
+/**
+ * Display (or hide) the notification that informs the user that
+ * encryption isn't possible, because the currently selected Sender
+ * (From) identity isn't configured for end-to-end-encryption.
+ *
+ * @param {boolean} show - Show if true, hide if false.
+ * @param {string} addr - email address to show in notification
+ */
+async function notifyIdentityCannotEncrypt(show, addr) {
+ const NOTIFICATION_NAME = "IdentityCannotEncrypt";
+
+ let notification =
+ gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME);
+
+ if (show) {
+ if (!notification) {
+ gComposeNotification.appendNotification(
+ NOTIFICATION_NAME,
+ {
+ label: await document.l10n.formatValue(
+ "openpgp-key-issue-notification-from",
+ {
+ addr,
+ }
+ ),
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ []
+ );
+ }
+ } else if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+}
+
+/**
+ * Show an appropriate notification based on the given list of
+ * email addresses that cannot be used with email encryption
+ * (because of missing usable OpenPGP public keys or S/MIME certs).
+ * The list may be empty, which means no notification will be shown
+ * (or existing notifications will be removed).
+ *
+ * @param {string[]} emailsWithMissing - The email addresses that prevent
+ * using encryption, because certs/keys are missing.
+ */
+function updateKeyCertNotifications(emailsWithMissing) {
+ const NOTIFICATION_NAME = "keyNotification";
+
+ let notification =
+ gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME);
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+
+ // Always refresh the pills UI.
+ clearRecipPillKeyIssues();
+
+ // Interrupt if we don't have any issue.
+ if (!emailsWithMissing.length) {
+ return;
+ }
+
+ // Update recipient pills.
+ for (let pill of document.querySelectorAll("mail-address-pill")) {
+ if (
+ emailsWithMissing.includes(pill.emailAddress.toLowerCase()) &&
+ !pill.classList.contains("invalid-address")
+ ) {
+ pill.classList.add("key-issue");
+ }
+ }
+
+ /**
+ * Display the new key notification.
+ */
+ let buttons = [];
+ buttons.push({
+ "l10n-id": "key-notification-disable-encryption",
+ callback() {
+ gUserTouchedSendEncrypted = true;
+ gSendEncrypted = false;
+ checkEncryptionState();
+ return true;
+ },
+ });
+
+ if (gSelectedTechnologyIsPGP) {
+ buttons.push({
+ "l10n-id": "key-notification-resolve",
+ callback() {
+ showMessageComposeSecurityStatus();
+ return true;
+ },
+ });
+ }
+
+ let label;
+
+ if (emailsWithMissing.length == 1) {
+ let id = gSelectedTechnologyIsPGP
+ ? "openpgp-key-issue-notification-single"
+ : "smime-cert-issue-notification-single";
+ label = {
+ "l10n-id": id,
+ "l10n-args": { addr: emailsWithMissing[0] },
+ };
+ } else {
+ let id = gSelectedTechnologyIsPGP
+ ? "openpgp-key-issue-notification-multi"
+ : "smime-cert-issue-notification-multi";
+
+ label = {
+ "l10n-id": id,
+ "l10n-args": { count: emailsWithMissing.length },
+ };
+ }
+
+ gComposeNotification.appendNotification(
+ NOTIFICATION_NAME,
+ {
+ label,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+}
+
+/**
+ * Returns whether the attachment notification should be suppressed regardless
+ * of the state of keywords.
+ */
+function attachmentNotificationSupressed() {
+ return (
+ gDisableAttachmentReminder ||
+ gManualAttachmentReminder ||
+ gAttachmentBucket.getRowCount()
+ );
+}
+
+var attachmentWorker = new Worker("resource:///modules/AttachmentChecker.jsm");
+
+// The array of currently found keywords. Or null if keyword detection wasn't
+// run yet so we don't know.
+attachmentWorker.lastMessage = null;
+
+attachmentWorker.onerror = function (error) {
+ console.error("Attachment Notification Worker error!!! " + error.message);
+ throw error;
+};
+
+/**
+ * Called when attachmentWorker finishes checking of the message for keywords.
+ *
+ * @param event If defined, event.data contains an array of found keywords.
+ * @param aManage If set to true and we determine keywords have changed,
+ * manage the notification.
+ * If set to false, just store the new keyword list but do not
+ * touch the notification. That effectively eats the
+ * "keywords changed" event which usually shows the notification
+ * if it was hidden. See manageAttachmentNotification().
+ */
+attachmentWorker.onmessage = function (event, aManage = true) {
+ // Exit if keywords haven't changed.
+ if (
+ !event ||
+ (attachmentWorker.lastMessage &&
+ event.data.toString() == attachmentWorker.lastMessage.toString())
+ ) {
+ return;
+ }
+
+ let data = event ? event.data : [];
+ attachmentWorker.lastMessage = data.slice(0);
+ if (aManage) {
+ manageAttachmentNotification(true);
+ }
+};
+
+/**
+ * Update attachment-related internal flags, UI, and commands.
+ * Called when number of attachments changes.
+ *
+ * @param aShowPane {string} "show": show the attachment pane
+ * "hide": hide the attachment pane
+ * omitted: just update without changing pane visibility
+ * @param aContentChanged {Boolean} optional value to assign to gContentChanged;
+ * defaults to true.
+ */
+function AttachmentsChanged(aShowPane, aContentChanged = true) {
+ gContentChanged = aContentChanged;
+ updateAttachmentPane(aShowPane);
+ manageAttachmentNotification(true);
+ updateAttachmentItems();
+}
+
+/**
+ * This functions returns an array of valid spellcheck languages. It checks
+ * that a dictionary exists for the language passed in, if any. It also
+ * retrieves the corresponding preference and ensures that a dictionary exists.
+ * If not, it adjusts the preference accordingly.
+ * When the nominated dictionary does not exist, the effects are very confusing
+ * to the user: Inline spell checking does not work, although the option is
+ * selected and a spell check dictionary seems to be selected in the options
+ * dialog (the dropdown shows the first list member if the value is not in
+ * the list). It is not at all obvious that the preference value is wrong.
+ * This case can happen two scenarios:
+ * 1) The dictionary that was selected in the preference is removed.
+ * 2) The selected dictionary changes the way it announces itself to the system,
+ * so for example "it_IT" changes to "it-IT" and the previously stored
+ * preference value doesn't apply any more.
+ *
+ * @param {string[]|null} [draftLanguages] - Languages that the message was
+ * composed in.
+ * @returns {string[]}
+ */
+function getValidSpellcheckerDictionaries(draftLanguages) {
+ let prefValue = Services.prefs.getCharPref("spellchecker.dictionary");
+ let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+ let dictionaries = Array.from(new Set(prefValue?.split(",")));
+
+ let dictList = spellChecker.getDictionaryList();
+ let count = dictList.length;
+
+ if (count == 0) {
+ // If there are no dictionaries, we can't check the value, so return it.
+ return dictionaries;
+ }
+
+ // Make sure that the draft language contains a valid value.
+ if (
+ draftLanguages &&
+ draftLanguages.every(language => dictList.includes(language))
+ ) {
+ return draftLanguages;
+ }
+
+ // Make sure preference contains a valid value.
+ if (dictionaries.every(language => dictList.includes(language))) {
+ return dictionaries;
+ }
+
+ // Set a valid value, any value will do.
+ Services.prefs.setCharPref("spellchecker.dictionary", dictList[0]);
+ return [dictList[0]];
+}
+
+var dictionaryRemovalObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "spellcheck-dictionary-remove") {
+ return;
+ }
+ let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ let dictList = spellChecker.getDictionaryList();
+ let languages = Array.from(gActiveDictionaries);
+ languages = languages.filter(lang => dictList.includes(lang));
+ if (languages.length === 0) {
+ // Set a valid language from the preference.
+ let prefValue = Services.prefs.getCharPref("spellchecker.dictionary");
+ let prefLanguages = prefValue?.split(",") ?? [];
+ languages = prefLanguages.filter(lang => dictList.includes(lang));
+ if (prefLanguages.length != languages.length && languages.length > 0) {
+ // Fix the preference while we're here. We know it's invalid.
+ Services.prefs.setCharPref(
+ "spellchecker.dictionary",
+ languages.join(",")
+ );
+ }
+ }
+ // Only update the language if we will still be left with any active choice.
+ if (languages.length > 0) {
+ ComposeChangeLanguage(languages);
+ }
+ },
+
+ isAdded: false,
+
+ addObserver() {
+ Services.obs.addObserver(this, "spellcheck-dictionary-remove");
+ this.isAdded = true;
+ },
+
+ removeObserver() {
+ if (this.isAdded) {
+ Services.obs.removeObserver(this, "spellcheck-dictionary-remove");
+ this.isAdded = false;
+ }
+ },
+};
+
+function EditorClick(event) {
+ if (event.target.matches(".remove-card")) {
+ let card = event.target.closest(".moz-card");
+ let url = card.querySelector(".url").href;
+ if (card.matches(".url-replaced")) {
+ card.replaceWith(url);
+ } else {
+ card.remove();
+ }
+ } else if (event.target.matches(`.add-card[data-opened='${gOpened}']`)) {
+ let url = event.target.getAttribute("data-url");
+ let meRect = document.getElementById("messageEditor").getClientRects()[0];
+ let settings = document.getElementById("linkPreviewSettings");
+ let settingsW = 500;
+ settings.style.position = "fixed";
+ settings.style.left =
+ Math.max(settingsW + 20, event.clientX) - settingsW + "px";
+ settings.style.top = meRect.top + event.clientY + 20 + "px";
+ settings.hidden = false;
+ event.target.remove();
+ settings.querySelector(".close").onclick = event => {
+ settings.hidden = true;
+ };
+ settings.querySelector(".preview-replace").onclick = event => {
+ addLinkPreview(url, true);
+ settings.hidden = true;
+ };
+ settings.querySelector(".preview-autoadd").onclick = event => {
+ Services.prefs.setBoolPref(
+ "mail.compose.add_link_preview",
+ event.target.checked
+ );
+ };
+ settings.querySelector(".preview-replace").focus();
+ settings.onkeydown = event => {
+ if (event.key == "Escape") {
+ settings.hidden = true;
+ }
+ };
+ }
+}
+
+/**
+ * Grab Open Graph or Twitter card data from the URL and insert a link preview
+ * into the editor. If no proper data could be found, nothing is inserted.
+ *
+ * @param {string} url - The URL to add preview for.
+ */
+async function addLinkPreview(url) {
+ return fetch(url)
+ .then(response => response.text())
+ .then(text => {
+ let doc = new DOMParser().parseFromString(text, "text/html");
+
+ // If the url has an Open Graph or Twitter card, create a nicer
+ // representation and use that instead.
+ // @see https://ogp.me/
+ // @see https://developer.twitter.com/en/docs/twitter-for-websites/cards/
+ // Also look for standard meta information as a fallback.
+
+ let title =
+ doc
+ .querySelector("meta[property='og:title'],meta[name='twitter:title']")
+ ?.getAttribute("content") ||
+ doc.querySelector("title")?.textContent.trim();
+ let description = doc
+ .querySelector(
+ "meta[property='og:description'],meta[name='twitter:description'],meta[name='description']"
+ )
+ ?.getAttribute("content");
+
+ // Handle the case where we didn't get proper data.
+ if (!title && !description) {
+ console.debug(`No link preview data for url=${url}`);
+ return;
+ }
+
+ let image = doc
+ .querySelector("meta[property='og:image']")
+ ?.getAttribute("content");
+ let alt =
+ doc
+ .querySelector("meta[property='og:image:alt']")
+ ?.getAttribute("content") || "";
+ if (!image) {
+ image = doc
+ .querySelector("meta[name='twitter:image']")
+ ?.getAttribute("content");
+ alt =
+ doc
+ .querySelector("meta[name='twitter:image:alt']")
+ ?.getAttribute("content") || "";
+ }
+ let imgIsTouchIcon = false;
+ if (!image) {
+ image = doc
+ .querySelector(
+ `link[rel='icon']:is(
+ [sizes~='any'],
+ [sizes~='196x196' i],
+ [sizes~='192x192' i]
+ [sizes~='180x180' i],
+ [sizes~='128x128' i]
+ )`
+ )
+ ?.getAttribute("href");
+ alt = "";
+ imgIsTouchIcon = Boolean(image);
+ }
+
+ // Grab our template and fill in the variables.
+ let card = document
+ .getElementById("dataCardTemplate")
+ .content.cloneNode(true).firstElementChild;
+ card.id = "card-" + Date.now();
+ card.querySelector("img").src = image;
+ card.querySelector("img").alt = alt;
+ card.querySelector(".title").textContent = title;
+
+ card.querySelector(".description").textContent = description;
+ card.querySelector(".url").textContent = "🔗 " + url;
+ card.querySelector(".url").href = url;
+ card.querySelector(".url").title = new URL(url).hostname;
+ card.querySelector(".site").textContent = new URL(url).hostname;
+
+ // twitter:card "summary" = Summary Card
+ // twitter:card "summary_large_image" = Summary Card with Large Image
+ if (
+ !imgIsTouchIcon &&
+ (doc.querySelector(
+ "meta[name='twitter:card'][content='summary_large_image']"
+ ) ||
+ doc
+ .querySelector("meta[property='og:image:width']")
+ ?.getAttribute("content") >= 600)
+ ) {
+ card.querySelector("img").style.width = "600px";
+ }
+
+ if (!image) {
+ card.querySelector(".card-pic").remove();
+ }
+
+ // If subject is empty, set that as well.
+ let subject = document.getElementById("msgSubject");
+ if (!subject.value && title) {
+ subject.value = title;
+ }
+
+ // Select the inserted URL so that if the preview is found one can
+ // use undo to remove it and only use the URL instead.
+ // Only do it if there was no typing after the url.
+ let selection = getBrowser().contentDocument.getSelection();
+ let n = selection.focusNode;
+ if (n.textContent.endsWith(url)) {
+ selection.extend(n, n.textContent.lastIndexOf(url));
+ card.classList.add("url-replaced");
+ }
+
+ // Add a line after the card. Otherwise it's hard to continue writing.
+ let line = GetCurrentEditor().returnInParagraphCreatesNewParagraph
+ ? "<p>&#160;</p>"
+ : "<br />";
+ card.classList.add("loading"); // Used for fade-in effect.
+ getBrowser().contentDocument.execCommand(
+ "insertHTML",
+ false,
+ card.outerHTML + line
+ );
+ let cardInDoc = getBrowser().contentDocument.getElementById(card.id);
+ cardInDoc.classList.remove("loading");
+ });
+}
+
+/**
+ * On paste or drop, we may want to modify the content before inserting it into
+ * the editor, replacing file URLs with data URLs when appropriate.
+ */
+function onPasteOrDrop(e) {
+ if (!gMsgCompose.composeHTML) {
+ // We're in the plain text editor. Nothing to do here.
+ return;
+ }
+ gMsgCompose.allowRemoteContent = true;
+
+ // For paste use e.clipboardData, for drop use e.dataTransfer.
+ let dataTransfer = "clipboardData" in e ? e.clipboardData : e.dataTransfer;
+ if (
+ Services.prefs.getBoolPref("mail.compose.add_link_preview", false) &&
+ !Services.io.offline &&
+ !dataTransfer.types.includes("text/html")
+ ) {
+ let type = dataTransfer.types.find(t =>
+ ["text/uri-list", "text/x-moz-url", "text/plain"].includes(t)
+ );
+ if (type) {
+ let url = dataTransfer.getData(type).split("\n")[0].trim();
+ if (/^https?:\/\/\S+$/.test(url)) {
+ e.preventDefault(); // We'll handle the pasting manually.
+ getBrowser().contentDocument.execCommand("insertHTML", false, url);
+ addLinkPreview(url);
+ return;
+ }
+ }
+ }
+
+ if (!dataTransfer.types.includes("text/html")) {
+ return;
+ }
+
+ // Ok, we have html content to paste.
+ let html = dataTransfer.getData("text/html");
+ let doc = new DOMParser().parseFromString(html, "text/html");
+ let tmpD = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ let pendingConversions = 0;
+ let needToPreventDefault = true;
+ for (let img of doc.images) {
+ if (!/^file:/i.test(img.src)) {
+ // Doesn't start with file:. Nothing to do here.
+ continue;
+ }
+
+ // This may throw if the URL is invalid for the OS.
+ let nsFile;
+ try {
+ nsFile = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(img.src);
+ } catch (ex) {
+ continue;
+ }
+
+ if (!nsFile.exists()) {
+ continue;
+ }
+
+ if (!tmpD.contains(nsFile)) {
+ // Not anywhere under the temp dir.
+ continue;
+ }
+
+ let contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromFile(nsFile);
+ if (!contentType.startsWith("image/")) {
+ continue;
+ }
+
+ // If we ever get here, we need to prevent the default paste or drop since
+ // the code below will do its own insertion.
+ if (needToPreventDefault) {
+ e.preventDefault();
+ needToPreventDefault = false;
+ }
+
+ File.createFromNsIFile(nsFile).then(function (file) {
+ if (file.lastModified < Date.now() - 60000) {
+ // Not put in temp in the last minute. May be something other than
+ // a copy-paste. Let's not allow that.
+ return;
+ }
+
+ let doTheInsert = function () {
+ // Now run it through sanitation to make sure there wasn't any
+ // unwanted things in the content.
+ let ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ let html2 = ParserUtils.sanitize(
+ doc.documentElement.innerHTML,
+ ParserUtils.SanitizerAllowStyle
+ );
+ getBrowser().contentDocument.execCommand("insertHTML", false, html2);
+ };
+
+ // Everything checks out. Convert file to data URL.
+ let reader = new FileReader();
+ reader.addEventListener("load", function () {
+ let dataURL = reader.result;
+ pendingConversions--;
+ img.src = dataURL;
+ if (pendingConversions == 0) {
+ doTheInsert();
+ }
+ });
+ reader.addEventListener("error", function () {
+ pendingConversions--;
+ if (pendingConversions == 0) {
+ doTheInsert();
+ }
+ });
+
+ pendingConversions++;
+ reader.readAsDataURL(file);
+ });
+ }
+}
+
+/* eslint-disable complexity */
+async function ComposeStartup() {
+ // Findbar overlay
+ if (!document.getElementById("findbar-replaceButton")) {
+ let replaceButton = document.createXULElement("toolbarbutton");
+ replaceButton.setAttribute("id", "findbar-replaceButton");
+ replaceButton.setAttribute("class", "toolbarbutton-1 tabbable");
+ replaceButton.setAttribute(
+ "label",
+ getComposeBundle().getString("replaceButton.label")
+ );
+ replaceButton.setAttribute(
+ "accesskey",
+ getComposeBundle().getString("replaceButton.accesskey")
+ );
+ replaceButton.setAttribute(
+ "tooltiptext",
+ getComposeBundle().getString("replaceButton.tooltip")
+ );
+ replaceButton.setAttribute("oncommand", "findbarFindReplace();");
+
+ let findbar = document.getElementById("FindToolbar");
+ let lastButton = findbar.getElement("find-entire-word");
+ let tSeparator = document.createXULElement("toolbarseparator");
+ tSeparator.setAttribute("id", "findbar-beforeReplaceSeparator");
+ lastButton.parentNode.insertBefore(
+ replaceButton,
+ lastButton.nextElementSibling
+ );
+ lastButton.parentNode.insertBefore(
+ tSeparator,
+ lastButton.nextElementSibling
+ );
+ }
+
+ var params = null; // New way to pass parameters to the compose window as a nsIMsgComposeParameters object
+ var args = null; // old way, parameters are passed as a string
+ gBodyFromArgs = false;
+
+ if (window.arguments && window.arguments[0]) {
+ try {
+ if (window.arguments[0] instanceof Ci.nsIMsgComposeParams) {
+ params = window.arguments[0];
+ gBodyFromArgs = params.composeFields && params.composeFields.body;
+ } else {
+ params = handleMailtoArgs(window.arguments[0]);
+ }
+ } catch (ex) {
+ dump("ERROR with parameters: " + ex + "\n");
+ }
+
+ // if still no dice, try and see if the params is an old fashioned list of string attributes
+ // XXX can we get rid of this yet?
+ if (!params) {
+ args = GetArgs(window.arguments[0]);
+ }
+ }
+
+ // Set a sane starting width/height for all resolutions on new profiles.
+ // Do this before the window loads.
+ if (!document.documentElement.hasAttribute("width")) {
+ // Prefer 860x800.
+ let defaultHeight = Math.min(screen.availHeight, 800);
+ let defaultWidth = Math.min(screen.availWidth, 860);
+
+ // On small screens, default to maximized state.
+ if (defaultHeight <= 600) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+
+ document.documentElement.setAttribute("width", defaultWidth);
+ document.documentElement.setAttribute("height", defaultHeight);
+ // Make sure we're safe at the left/top edge of screen
+ document.documentElement.setAttribute("screenX", screen.availLeft);
+ document.documentElement.setAttribute("screenY", screen.availTop);
+ }
+
+ // Observe dictionary removals.
+ dictionaryRemovalObserver.addObserver();
+
+ let messageEditor = document.getElementById("messageEditor");
+ messageEditor.addEventListener("paste", onPasteOrDrop);
+ messageEditor.addEventListener("drop", onPasteOrDrop);
+
+ let identityList = document.getElementById("msgIdentity");
+ if (identityList) {
+ FillIdentityList(identityList);
+ }
+
+ if (!params) {
+ // This code will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string
+
+ params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (args) {
+ // Convert old fashion arguments into params
+ var composeFields = params.composeFields;
+ if (args.bodyislink == "true") {
+ params.bodyIsLink = true;
+ }
+ if (args.type) {
+ params.type = args.type;
+ }
+ if (args.format) {
+ // Only use valid values.
+ if (
+ args.format == Ci.nsIMsgCompFormat.PlainText ||
+ args.format == Ci.nsIMsgCompFormat.HTML ||
+ args.format == Ci.nsIMsgCompFormat.OppositeOfDefault
+ ) {
+ params.format = args.format;
+ } else if (args.format.toLowerCase().trim() == "html") {
+ params.format = Ci.nsIMsgCompFormat.HTML;
+ } else if (args.format.toLowerCase().trim() == "text") {
+ params.format = Ci.nsIMsgCompFormat.PlainText;
+ }
+ }
+ if (args.originalMsgURI) {
+ params.originalMsgURI = args.originalMsgURI;
+ }
+ if (args.preselectid) {
+ params.identity = MailServices.accounts.getIdentity(args.preselectid);
+ }
+ if (args.from) {
+ composeFields.from = args.from;
+ }
+ if (args.to) {
+ composeFields.to = args.to;
+ }
+ if (args.cc) {
+ composeFields.cc = args.cc;
+ }
+ if (args.bcc) {
+ composeFields.bcc = args.bcc;
+ }
+ if (args.newsgroups) {
+ composeFields.newsgroups = args.newsgroups;
+ }
+ if (args.subject) {
+ composeFields.subject = args.subject;
+ }
+ if (args.attachment && window.arguments[1] instanceof Ci.nsICommandLine) {
+ let attachmentList = args.attachment.split(",");
+ for (let attachmentName of attachmentList) {
+ // resolveURI does all the magic around working out what the
+ // attachment is, including web pages, and generating the correct uri.
+ let uri = window.arguments[1].resolveURI(attachmentName);
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ // If uri is for a file and it exists set the attachment size.
+ if (uri instanceof Ci.nsIFileURL) {
+ if (uri.file.exists()) {
+ attachment.size = uri.file.fileSize;
+ } else {
+ attachment = null;
+ }
+ }
+
+ // Only want to attach if a file that exists or it is not a file.
+ if (attachment) {
+ attachment.url = uri.spec;
+ composeFields.addAttachment(attachment);
+ } else {
+ let title = getComposeBundle().getString("errorFileAttachTitle");
+ let msg = getComposeBundle().getFormattedString(
+ "errorFileAttachMessage",
+ [attachmentName]
+ );
+ Services.prompt.alert(null, title, msg);
+ }
+ }
+ }
+ if (args.newshost) {
+ composeFields.newshost = args.newshost;
+ }
+ if (args.message) {
+ let msgFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ if (PathUtils.parent(args.message) == ".") {
+ let workingDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ args.message = PathUtils.join(
+ workingDir.path,
+ PathUtils.filename(args.message)
+ );
+ }
+ msgFile.initWithPath(args.message);
+
+ if (!msgFile.exists()) {
+ let title = getComposeBundle().getString("errorFileMessageTitle");
+ let msg = getComposeBundle().getFormattedString(
+ "errorFileMessageMessage",
+ [args.message]
+ );
+ Services.prompt.alert(null, title, msg);
+ } else {
+ let data = "";
+ let fstream = null;
+ let cstream = null;
+
+ try {
+ fstream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ cstream = Cc[
+ "@mozilla.org/intl/converter-input-stream;1"
+ ].createInstance(Ci.nsIConverterInputStream);
+ fstream.init(msgFile, -1, 0, 0); // Open file in default/read-only mode.
+ cstream.init(fstream, "UTF-8", 0, 0);
+
+ let str = {};
+ let read = 0;
+
+ do {
+ // Read as much as we can and put it in str.value.
+ read = cstream.readString(0xffffffff, str);
+ data += str.value;
+ } while (read != 0);
+ } catch (e) {
+ let title = getComposeBundle().getString("errorFileMessageTitle");
+ let msg = getComposeBundle().getFormattedString(
+ "errorLoadFileMessageMessage",
+ [args.message]
+ );
+ Services.prompt.alert(null, title, msg);
+ } finally {
+ if (cstream) {
+ cstream.close();
+ }
+ if (fstream) {
+ fstream.close();
+ }
+ }
+
+ if (data) {
+ let pos = data.search(/\S/); // Find first non-whitespace character.
+
+ if (
+ params.format != Ci.nsIMsgCompFormat.PlainText &&
+ (args.message.endsWith(".htm") ||
+ args.message.endsWith(".html") ||
+ data.substr(pos, 14).toLowerCase() == "<!doctype html" ||
+ data.substr(pos, 5).toLowerCase() == "<html")
+ ) {
+ // We replace line breaks because otherwise they'll be converted to
+ // <br> in nsMsgCompose::BuildBodyMessageAndSignature().
+ // Don't do the conversion if the user asked explicitly for plain text.
+ data = data.replace(/\r?\n/g, " ");
+ }
+ gBodyFromArgs = true;
+ composeFields.body = data;
+ }
+ }
+ } else if (args.body) {
+ gBodyFromArgs = true;
+ composeFields.body = args.body;
+ }
+ }
+ }
+
+ gComposeType = params.type;
+
+ // Detect correct identity when missing or mismatched. An identity with no
+ // email is likely not valid.
+ // When editing a draft, 'params.identity' is pre-populated with the identity
+ // that created the draft or the identity owning the draft folder for a
+ // "foreign" draft, see ComposeMessage() in mailCommands.js. We don't want the
+ // latter so use the creator identity which could be null.
+ // Only do this detection for drafts and templates.
+ // Redirect will have from set as the original sender and we don't want to
+ // warn about that.
+ if (
+ gComposeType == Ci.nsIMsgCompType.Draft ||
+ gComposeType == Ci.nsIMsgCompType.Template
+ ) {
+ let creatorKey = params.composeFields.creatorIdentityKey;
+ params.identity = creatorKey
+ ? MailServices.accounts.getIdentity(creatorKey)
+ : null;
+ }
+
+ let from = null;
+ // Get the from address from the headers. For Redirect, from is set to
+ // the original author, so don't look at it here.
+ if (params.composeFields.from && gComposeType != Ci.nsIMsgCompType.Redirect) {
+ let fromAddrs = MailServices.headerParser.parseEncodedHeader(
+ params.composeFields.from,
+ null
+ );
+ if (fromAddrs.length) {
+ from = fromAddrs[0].email.toLowerCase();
+ }
+ }
+
+ if (
+ !params.identity ||
+ !params.identity.email ||
+ (from && !emailSimilar(from, params.identity.email))
+ ) {
+ let identities = MailServices.accounts.allIdentities;
+ let suitableCount = 0;
+
+ // Search for a matching identity.
+ if (from) {
+ for (let ident of identities) {
+ if (ident.email && from == ident.email.toLowerCase()) {
+ if (suitableCount == 0) {
+ params.identity = ident;
+ }
+ suitableCount++;
+ if (suitableCount > 1) {
+ // No need to find more, it's already not unique.
+ break;
+ }
+ }
+ }
+ }
+
+ if (!params.identity || !params.identity.email) {
+ let identity = null;
+ // No preset identity and no match, so use the default account.
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ identity = defaultAccount.defaultIdentity;
+ }
+ if (!identity) {
+ // Get the first identity we have in the list.
+ let identitykey = identityList
+ .getItemAtIndex(0)
+ .getAttribute("identitykey");
+ identity = MailServices.accounts.getIdentity(identitykey);
+ }
+ params.identity = identity;
+ }
+
+ // Warn if no or more than one match was found.
+ // But don't warn for +suffix additions (a+b@c.com).
+ if (
+ from &&
+ (suitableCount > 1 ||
+ (suitableCount == 0 && !emailSimilar(from, params.identity.email)))
+ ) {
+ gComposeNotificationBar.setIdentityWarning(params.identity.identityName);
+ }
+ }
+
+ if (params.identity) {
+ identityList.selectedItem = identityList.getElementsByAttribute(
+ "identitykey",
+ params.identity.key
+ )[0];
+ }
+
+ // Here we set the From from the original message, be it a draft or another
+ // message, for example a template, we want to "edit as new".
+ // Only do this if the message is our own draft or template or any type of reply.
+ if (
+ params.composeFields.from &&
+ (params.composeFields.creatorIdentityKey ||
+ gComposeType == Ci.nsIMsgCompType.Reply ||
+ gComposeType == Ci.nsIMsgCompType.ReplyAll ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToSender ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToGroup ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToSenderAndGroup ||
+ gComposeType == Ci.nsIMsgCompType.ReplyToList)
+ ) {
+ let from = MailServices.headerParser
+ .parseEncodedHeader(params.composeFields.from, null)
+ .join(", ");
+ if (from != identityList.value) {
+ MakeFromFieldEditable(true);
+ identityList.value = from;
+ }
+ }
+ LoadIdentity(true);
+
+ // Get the <editor> element to startup an editor
+ var editorElement = GetCurrentEditorElement();
+
+ // Remember the original message URI. When editing a draft which is a reply
+ // or forwarded message, this gets overwritten by the ancestor's message URI so
+ // the disposition flags ("replied" or "forwarded") can be set on the ancestor.
+ // For our purposes we need the URI of the message being processed, not its
+ // original ancestor.
+ gOriginalMsgURI = params.originalMsgURI;
+ gMsgCompose = MailServices.compose.initCompose(
+ params,
+ window,
+ editorElement.docShell
+ );
+
+ // If a message is a draft, we rely on draft status flags to decide
+ // about encryption setting. Don't set gIsRelatedToEncryptedOriginal
+ // simply because a message was saved as an encrypted draft, because
+ // we save draft messages encrypted as soon as the account is able
+ // to encrypt, regardless of the user's desire for encryption for
+ // this message.
+
+ if (
+ gComposeType != Ci.nsIMsgCompType.Draft &&
+ gComposeType != Ci.nsIMsgCompType.Template &&
+ gEncryptedURIService &&
+ gEncryptedURIService.isEncrypted(gMsgCompose.originalMsgURI)
+ ) {
+ gIsRelatedToEncryptedOriginal = true;
+ }
+
+ gMsgCompose.addMsgSendListener(gSendListener);
+
+ document
+ .getElementById("dsnMenu")
+ .setAttribute("checked", gMsgCompose.compFields.DSN);
+ document
+ .getElementById("cmd_attachVCard")
+ .setAttribute("checked", gMsgCompose.compFields.attachVCard);
+ document
+ .getElementById("cmd_attachPublicKey")
+ .setAttribute("checked", gAttachMyPublicPGPKey);
+ toggleAttachmentReminder(gMsgCompose.compFields.attachmentReminder);
+ initSendFormatMenu();
+
+ let editortype = gMsgCompose.composeHTML ? "htmlmail" : "textmail";
+ editorElement.makeEditable(editortype, true);
+
+ // setEditorType MUST be called before setContentWindow
+ if (gMsgCompose.composeHTML) {
+ initLocalFontFaceMenu(document.getElementById("FontFacePopup"));
+ } else {
+ // We are editing in plain text mode, so hide the formatting menus and the
+ // output format selector.
+ document.getElementById("FormatToolbar").hidden = true;
+ document.getElementById("formatMenu").hidden = true;
+ document.getElementById("insertMenu").hidden = true;
+ document.getElementById("menu_showFormatToolbar").hidden = true;
+ document.getElementById("outputFormatMenu").hidden = true;
+ }
+
+ // Do setup common to Message Composer and Web Composer.
+ EditorSharedStartup();
+ ToggleReturnReceipt(gMsgCompose.compFields.returnReceipt);
+
+ if (params.bodyIsLink) {
+ let body = gMsgCompose.compFields.body;
+ if (gMsgCompose.composeHTML) {
+ let cleanBody;
+ try {
+ cleanBody = decodeURI(body);
+ } catch (e) {
+ cleanBody = body;
+ }
+
+ body = body.replace(/&/g, "&amp;");
+ gMsgCompose.compFields.body =
+ '<br /><a href="' + body + '">' + cleanBody + "</a><br />";
+ } else {
+ gMsgCompose.compFields.body = "\n<" + body + ">\n";
+ }
+ }
+
+ document.getElementById("msgSubject").value = gMsgCompose.compFields.subject;
+
+ // Do not await async calls before registering the stateListener, otherwise it
+ // will miss states.
+ gMsgCompose.RegisterStateListener(stateListener);
+
+ let addedAttachmentItems = await AddAttachments(
+ gMsgCompose.compFields.attachments,
+ false
+ );
+ // If any of the pre-loaded attachments is a cloudFile, this is most probably a
+ // re-opened draft. Restore the cloudFile information.
+ for (let attachmentItem of addedAttachmentItems) {
+ if (
+ attachmentItem.attachment.sendViaCloud &&
+ attachmentItem.attachment.contentLocation &&
+ attachmentItem.attachment.cloudFileAccountKey &&
+ attachmentItem.attachment.cloudPartHeaderData
+ ) {
+ let byteString = atob(attachmentItem.attachment.cloudPartHeaderData);
+ let uploadFromDraft = JSON.parse(
+ MailStringUtils.byteStringToString(byteString)
+ );
+ if (uploadFromDraft && uploadFromDraft.path && uploadFromDraft.name) {
+ let cloudFileUpload;
+ let cloudFileAccount = cloudFileAccounts.getAccount(
+ attachmentItem.attachment.cloudFileAccountKey
+ );
+ let bigFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ bigFile.initWithPath(uploadFromDraft.path);
+
+ if (cloudFileAccount) {
+ // Try to find the upload for the draft attachment in the already known
+ // uploads.
+ cloudFileUpload = cloudFileAccount
+ .getPreviousUploads()
+ .find(
+ upload =>
+ upload.url == attachmentItem.attachment.contentLocation &&
+ upload.url == uploadFromDraft.url &&
+ upload.id == uploadFromDraft.id &&
+ upload.name == uploadFromDraft.name &&
+ upload.size == uploadFromDraft.size &&
+ upload.path == uploadFromDraft.path &&
+ upload.serviceName == uploadFromDraft.serviceName &&
+ upload.serviceIcon == uploadFromDraft.serviceIcon &&
+ upload.serviceUrl == uploadFromDraft.serviceUrl &&
+ upload.downloadPasswordProtected ==
+ uploadFromDraft.downloadPasswordProtected &&
+ upload.downloadLimit == uploadFromDraft.downloadLimit &&
+ upload.downloadExpiryDate == uploadFromDraft.downloadExpiryDate
+ );
+ if (!cloudFileUpload) {
+ // Create a new upload from the data stored in the draft.
+ cloudFileUpload = cloudFileAccount.newUploadForFile(
+ bigFile,
+ uploadFromDraft
+ );
+ }
+ // A restored cloudFile may have been send/used already in a previous
+ // session, or may be changed and reverted again by not saving a draft.
+ // Mark it as immutable.
+ cloudFileAccount.markAsImmutable(cloudFileUpload.id);
+ attachmentItem.cloudFileAccount = cloudFileAccount;
+ attachmentItem.cloudFileUpload = cloudFileUpload;
+ } else {
+ attachmentItem.cloudFileUpload = uploadFromDraft;
+ delete attachmentItem.cloudFileUpload.id;
+ }
+
+ // Restore file information from the linked real file.
+ attachmentItem.attachment.name = uploadFromDraft.name;
+ attachmentItem.attachment.size = uploadFromDraft.size;
+ let bigAttachment;
+ if (bigFile.exists()) {
+ bigAttachment = FileToAttachment(bigFile);
+ }
+ if (bigAttachment && bigAttachment.size == uploadFromDraft.size) {
+ // Remove the temporary html placeholder file.
+ let uri = Services.io
+ .newURI(attachmentItem.attachment.url)
+ .QueryInterface(Ci.nsIFileURL);
+ await IOUtils.remove(uri.file.path);
+
+ attachmentItem.attachment.url = bigAttachment.url;
+ attachmentItem.attachment.contentType = "";
+ attachmentItem.attachment.temporary = false;
+ }
+
+ await updateAttachmentItemProperties(attachmentItem);
+ continue;
+ }
+ }
+ // Did not find the required data in the draft to reconstruct the cloudFile
+ // information. Fall back to no-draft-restore-support.
+ attachmentItem.attachment.sendViaCloud = false;
+ }
+
+ if (Services.prefs.getBoolPref("mail.compose.show_attachment_pane")) {
+ toggleAttachmentPane("show");
+ }
+
+ // Fill custom headers.
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+ for (let i = 0; i < otherHeaders.length; i++) {
+ if (gMsgCompose.compFields.otherHeaders[i]) {
+ let row = document.getElementById(`addressRow${otherHeaders[i]}`);
+ addressRowSetVisibility(row, true);
+ let input = document.getElementById(`${otherHeaders[i]}AddrInput`);
+ input.value = gMsgCompose.compFields.otherHeaders[i];
+ }
+ }
+
+ document
+ .getElementById("msgcomposeWindow")
+ .dispatchEvent(
+ new Event("compose-window-init", { bubbles: false, cancelable: true })
+ );
+
+ dispatchAttachmentBucketEvent(
+ "attachments-added",
+ gMsgCompose.compFields.attachments
+ );
+
+ // Add an observer to be called when document is done loading,
+ // which creates the editor.
+ try {
+ GetCurrentCommandManager().addCommandObserver(
+ gMsgEditorCreationObserver,
+ "obs_documentCreated"
+ );
+
+ // Load empty page to create the editor. The "?compose" is there so this
+ // URL does not exactly match "about:blank", which has some drawbacks. In
+ // particular it prevents WebExtension content scripts from running in
+ // this document.
+ let loadURIOptions = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ editorElement.webNavigation.loadURI(
+ Services.io.newURI("about:blank?compose"),
+ loadURIOptions
+ );
+ } catch (e) {
+ console.error(e);
+ }
+
+ gEditingDraft = gMsgCompose.compFields.draftId;
+
+ // Set up contacts sidebar.
+ let pageURL = document.URL;
+ let contactsSplitter = document.getElementById("contactsSplitter");
+ let contactsShown = Services.xulStore.getValue(
+ pageURL,
+ "contactsSplitter",
+ "shown"
+ );
+ let contactsWidth = Services.xulStore.getValue(
+ pageURL,
+ "contactsSplitter",
+ "width"
+ );
+ contactsSplitter.width =
+ contactsWidth == "" ? null : parseFloat(contactsWidth);
+ setContactsSidebarVisibility(contactsShown == "true", false);
+ contactsSplitter.addEventListener("splitter-resized", () => {
+ let width = contactsSplitter.width;
+ Services.xulStore.setValue(
+ pageURL,
+ "contactsSplitter",
+ "width",
+ width == null ? "" : String(width)
+ );
+ });
+ contactsSplitter.addEventListener("splitter-collapsed", () => {
+ Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "false");
+ });
+ contactsSplitter.addEventListener("splitter-expanded", () => {
+ Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "true");
+ });
+
+ // Update the priority button.
+ if (gMsgCompose.compFields.priority) {
+ updatePriorityToolbarButton(gMsgCompose.compFields.priority);
+ }
+
+ gAutoSaveInterval = Services.prefs.getBoolPref("mail.compose.autosave")
+ ? Services.prefs.getIntPref("mail.compose.autosaveinterval") * 60000
+ : 0;
+
+ if (gAutoSaveInterval) {
+ gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval);
+ }
+
+ gAutoSaveKickedIn = false;
+}
+/* eslint-enable complexity */
+
+function splitEmailAddress(aEmail) {
+ let at = aEmail.lastIndexOf("@");
+ return at != -1 ? [aEmail.slice(0, at), aEmail.slice(at + 1)] : [aEmail, ""];
+}
+
+// Emails are equal ignoring +suffixes (email+suffix@example.com).
+function emailSimilar(a, b) {
+ if (!a || !b) {
+ return a == b;
+ }
+ a = splitEmailAddress(a.toLowerCase());
+ b = splitEmailAddress(b.toLowerCase());
+ return a[1] == b[1] && a[0].split("+", 1)[0] == b[0].split("+", 1)[0];
+}
+
+// The new, nice, simple way of getting notified when a new editor has been created
+var gMsgEditorCreationObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "obs_documentCreated") {
+ var editor = GetCurrentEditor();
+ if (editor && GetCurrentCommandManager() == aSubject) {
+ InitEditor();
+ }
+ // Now that we know this document is an editor, update commands now if
+ // the document has focus, or next time it receives focus via
+ // CommandUpdate_MsgCompose()
+ if (gLastWindowToHaveFocus == document.commandDispatcher.focusedWindow) {
+ updateComposeItems();
+ } else {
+ gLastWindowToHaveFocus = null;
+ }
+ }
+ },
+};
+
+/**
+ * Adjust sign/encrypt settings after the identity was switched.
+ *
+ * @param {?nsIMsgIdentity} prevIdentity - The previously selected
+ * identity, when switching to a different identity.
+ * Null on initial identity setup.
+ */
+async function adjustEncryptAfterIdentityChange(prevIdentity) {
+ let identityHasConfiguredSMIME =
+ isSmimeSigningConfigured() || isSmimeEncryptionConfigured();
+
+ let identityHasConfiguredOpenPGP = isPgpConfigured();
+
+ // Show widgets based on the technologies available across all identities.
+ let allEmailIdentities = MailServices.accounts.allIdentities.filter(
+ i => i.email
+ );
+ let anyIdentityHasConfiguredOpenPGP = allEmailIdentities.some(i =>
+ i.getUnicharAttribute("openpgp_key_id")
+ );
+ let anyIdentityHasConfiguredSMIMEEncryption = allEmailIdentities.some(i =>
+ i.getUnicharAttribute("encryption_cert_name")
+ );
+
+ // Disable encryption widgets if this identity has no encryption configured.
+ // However, if encryption is currently enabled, we must keep it enabled,
+ // to allow the user to manually disable encryption (we don't disable
+ // encryption automatically, as the user might have seen that it is
+ // enabled and might rely on it).
+ let e2eeConfigured =
+ identityHasConfiguredOpenPGP || identityHasConfiguredSMIME;
+
+ let autoEnablePref = Services.prefs.getBoolPref(
+ "mail.e2ee.auto_enable",
+ false
+ );
+
+ // If neither OpenPGP nor SMIME are configured for any identity,
+ // then hide the entire menu.
+ let encOpt = document.getElementById("button-encryption-options");
+ if (encOpt) {
+ encOpt.hidden =
+ !anyIdentityHasConfiguredOpenPGP &&
+ !anyIdentityHasConfiguredSMIMEEncryption;
+ encOpt.disabled = !e2eeConfigured && !gSendEncrypted;
+ document.getElementById("encTech_OpenPGP_Toolbar").disabled =
+ !identityHasConfiguredOpenPGP;
+ document.getElementById("encTech_SMIME_Toolbar").disabled =
+ !identityHasConfiguredSMIME;
+ }
+ document.getElementById("encryptionMenu").hidden =
+ !anyIdentityHasConfiguredOpenPGP &&
+ !anyIdentityHasConfiguredSMIMEEncryption;
+
+ // Show menu items only if both technologies are available.
+ document.getElementById("encTech_OpenPGP_Menubar").hidden =
+ !anyIdentityHasConfiguredOpenPGP ||
+ !anyIdentityHasConfiguredSMIMEEncryption;
+ document.getElementById("encTech_SMIME_Menubar").hidden =
+ !anyIdentityHasConfiguredOpenPGP ||
+ !anyIdentityHasConfiguredSMIMEEncryption;
+ document.getElementById("encryptionOptionsSeparator_Menubar").hidden =
+ !anyIdentityHasConfiguredOpenPGP ||
+ !anyIdentityHasConfiguredSMIMEEncryption;
+
+ let encToggle = document.getElementById("button-encryption");
+ if (encToggle) {
+ encToggle.disabled = !e2eeConfigured && !gSendEncrypted;
+ }
+ let sigToggle = document.getElementById("button-signing");
+ if (sigToggle) {
+ sigToggle.disabled = !e2eeConfigured;
+ }
+
+ document.getElementById("encryptionMenu").disabled =
+ !e2eeConfigured && !gSendEncrypted;
+
+ // Enable the encryption menus of the technologies that are configured for
+ // this identity.
+ document.getElementById("encTech_OpenPGP_Menubar").disabled =
+ !identityHasConfiguredOpenPGP;
+
+ document.getElementById("encTech_SMIME_Menubar").disabled =
+ !identityHasConfiguredSMIME;
+
+ if (!prevIdentity) {
+ // For identities without any e2ee setup, we want a good default
+ // technology selection. Avoid a technology that isn't configured
+ // anywhere.
+
+ if (identityHasConfiguredOpenPGP) {
+ gSelectedTechnologyIsPGP = true;
+ } else if (identityHasConfiguredSMIME) {
+ gSelectedTechnologyIsPGP = false;
+ } else {
+ gSelectedTechnologyIsPGP = anyIdentityHasConfiguredOpenPGP;
+ }
+
+ if (identityHasConfiguredOpenPGP) {
+ if (!identityHasConfiguredSMIME) {
+ gSelectedTechnologyIsPGP = true;
+ } else {
+ // both are configured
+ let techPref = gCurrentIdentity.getIntAttribute("e2etechpref");
+ gSelectedTechnologyIsPGP = techPref != 1;
+ }
+ }
+
+ gSendSigned = false;
+
+ if (autoEnablePref) {
+ gSendEncrypted = gIsRelatedToEncryptedOriginal;
+ } else {
+ gSendEncrypted =
+ gIsRelatedToEncryptedOriginal ||
+ ((identityHasConfiguredOpenPGP || identityHasConfiguredSMIME) &&
+ gCurrentIdentity.encryptionPolicy > 0);
+ }
+
+ await checkEncryptionState();
+ return;
+ }
+
+ // Not initialCall (switching from, or changed recipients)
+
+ // If the new identity has only one technology configured,
+ // which is different than the currently selected technology,
+ // then switch over to that other technology.
+ // However, if the new account doesn't have any technology
+ // configured, then it doesn't really matter, so let's keep what's
+ // currently selected for consistency (in case the user switches
+ // the identity again).
+ if (
+ gSelectedTechnologyIsPGP &&
+ !identityHasConfiguredOpenPGP &&
+ identityHasConfiguredSMIME
+ ) {
+ gSelectedTechnologyIsPGP = false;
+ } else if (
+ !gSelectedTechnologyIsPGP &&
+ !identityHasConfiguredSMIME &&
+ identityHasConfiguredOpenPGP
+ ) {
+ gSelectedTechnologyIsPGP = true;
+ }
+
+ if (
+ !autoEnablePref &&
+ !gSendEncrypted &&
+ !gUserTouchedEncryptSubject &&
+ prevIdentity.encryptionPolicy == 0 &&
+ gCurrentIdentity.encryptionPolicy > 0
+ ) {
+ gSendEncrypted = true;
+ }
+
+ await checkEncryptionState();
+}
+
+async function ComposeLoad() {
+ updateTroubleshootMenuItem();
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+
+ AddMessageComposeOfflineQuitObserver();
+
+ BondOpenPGP.init();
+
+ // Give the message header a minimum height based on its current height,
+ // before more recipient rows are revealed in #extraAddressRowsArea. This
+ // ensures that the area cannot be shrunk below its current height by the
+ // #headersSplitter.
+ // NOTE: At this stage, we only expect the "To" row to be visible within the
+ // recipients container.
+ let messageHeader = document.getElementById("MsgHeadersToolbar");
+ let recipientsContainer = document.getElementById("recipientsContainer");
+ // In the unlikely situation where the recipients container is already
+ // overflowing, we make sure to increase the minHeight by the overflow.
+ let headerHeight =
+ messageHeader.clientHeight +
+ recipientsContainer.scrollHeight -
+ recipientsContainer.clientHeight;
+ messageHeader.style.minHeight = `${headerHeight}px`;
+
+ // Setup the attachment bucket.
+ gAttachmentBucket = document.getElementById("attachmentBucket");
+
+ let attachmentArea = document.getElementById("attachmentArea");
+ attachmentArea.addEventListener("toggle", attachmentAreaOnToggle);
+
+ // Setup the attachment animation counter.
+ gAttachmentCounter = document.getElementById("newAttachmentIndicator");
+ gAttachmentCounter.addEventListener(
+ "animationend",
+ toggleAttachmentAnimation
+ );
+
+ // Set up the drag & drop event listeners.
+ let messageArea = document.getElementById("messageArea");
+ messageArea.addEventListener("dragover", event =>
+ envelopeDragObserver.onDragOver(event)
+ );
+ messageArea.addEventListener("dragleave", event =>
+ envelopeDragObserver.onDragLeave(event)
+ );
+ messageArea.addEventListener("drop", event =>
+ envelopeDragObserver.onDrop(event)
+ );
+
+ // Setup the attachment overlay animation listeners.
+ let overlay = document.getElementById("dropAttachmentOverlay");
+ overlay.addEventListener("animationend", e => {
+ // Make the overlay constantly visible If the user is dragging a file over
+ // the compose windown.
+ if (e.animationName == "showing-animation") {
+ // We don't remove the "showing" class here since the dragOver event will
+ // keep adding it and we would have a flashing effect.
+ overlay.classList.add("show");
+ return;
+ }
+
+ // Permanently hide the overlay after the hiding animation ended.
+ if (e.animationName == "hiding-animation") {
+ overlay.classList.remove("show", "hiding");
+ // Remove the hover class from the child items to reset the style.
+ document.getElementById("addInline").classList.remove("hover");
+ document.getElementById("addAsAttachment").classList.remove("hover");
+ }
+ });
+
+ if (otherHeaders) {
+ let extraAddressRowsMenu = document.getElementById("extraAddressRowsMenu");
+
+ let existingTypes = Array.from(
+ document.querySelectorAll(".address-row"),
+ row => row.dataset.recipienttype
+ );
+
+ for (let header of otherHeaders) {
+ if (existingTypes.includes(header)) {
+ continue;
+ }
+ existingTypes.push(header);
+
+ header = header.trim();
+ let recipient = {
+ rowId: `addressRow${header}`,
+ labelId: `${header}AddrLabel`,
+ containerId: `${header}AddrContainer`,
+ inputId: `${header}AddrInput`,
+ showRowMenuItemId: `${header}ShowAddressRowMenuItem`,
+ type: header,
+ };
+
+ let newEls = recipientsContainer.buildRecipientRow(recipient, true);
+
+ recipientsContainer.appendChild(newEls.row);
+ extraAddressRowsMenu.appendChild(newEls.showRowMenuItem);
+ }
+ }
+
+ try {
+ SetupCommandUpdateHandlers();
+ await ComposeStartup();
+ } catch (ex) {
+ console.error(ex);
+ Services.prompt.alert(
+ window,
+ getComposeBundle().getString("initErrorDlogTitle"),
+ getComposeBundle().getString("initErrorDlgMessage")
+ );
+
+ MsgComposeCloseWindow();
+ return;
+ }
+
+ ToolbarIconColor.init();
+
+ // initialize the customizeDone method on the customizeable toolbar
+ var toolbox = document.getElementById("compose-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeComposeToolbar");
+ };
+
+ updateAttachmentPane();
+ updateAriaLabelsAndTooltipsOfAllAddressRows();
+
+ for (let input of document.querySelectorAll(".address-row-input")) {
+ input.onBeforeHandleKeyDown = event =>
+ addressInputOnBeforeHandleKeyDown(event);
+ }
+
+ top.controllers.appendController(SecurityController);
+ gMsgCompose.compFields.composeSecure = null;
+ gSMFields = Cc[
+ "@mozilla.org/messengercompose/composesecure;1"
+ ].createInstance(Ci.nsIMsgComposeSecure);
+ if (gSMFields) {
+ gMsgCompose.compFields.composeSecure = gSMFields;
+ }
+
+ // Set initial encryption settings.
+ adjustEncryptAfterIdentityChange(null);
+
+ ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ GetCurrentEditorElement()
+ );
+
+ setComposeLabelsAndMenuItems();
+ setKeyboardShortcuts();
+
+ gFocusAreas = [
+ {
+ // #abContactsPanel.
+ // NOTE: If focus is within the browser shadow document, then the
+ // top.document.activeElement points to the browser, which is below
+ // #contactsSidebar.
+ root: document.getElementById("contactsSidebar"),
+ focus: focusContactsSidebarSearchInput,
+ },
+ {
+ // #msgIdentity, .recipient-button and #extraAddressRowsMenuButton.
+ root: document.getElementById("top-gradient-box"),
+ focus: focusMsgIdentity,
+ },
+ ...Array.from(document.querySelectorAll(".address-row"), row => {
+ return { root: row, focus: focusAddressRowInput };
+ }),
+ {
+ root: document.getElementById("subject-box"),
+ focus: focusSubjectInput,
+ },
+ // "#FormatToolbox" cannot receive focus.
+ {
+ // #messageEditor and #FindToolbar
+ root: document.getElementById("messageArea"),
+ focus: focusMsgBody,
+ },
+ {
+ root: document.getElementById("attachmentArea"),
+ focus: focusAttachmentBucket,
+ },
+ {
+ root: document.getElementById("compose-notification-bottom"),
+ focus: focusNotification,
+ },
+ {
+ root: document.getElementById("status-bar"),
+ focus: focusStatusBar,
+ },
+ ];
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+}
+
+/**
+ * Add fluent strings to labels and menu items requiring a shortcut key.
+ */
+function setComposeLabelsAndMenuItems() {
+ // To field.
+ document.l10n.setAttributes(
+ document.getElementById("menu_showToField"),
+ "show-to-row-main-menuitem",
+ {
+ key: SHOW_TO_KEY,
+ }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_toShowAddressRowMenuItem"),
+ "show-to-row-extra-menuitem"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_toShowAddressRowButton"),
+ "show-to-row-button",
+ {
+ key: SHOW_TO_KEY,
+ }
+ );
+
+ // Cc field.
+ document.l10n.setAttributes(
+ document.getElementById("menu_showCcField"),
+ "show-cc-row-main-menuitem",
+ {
+ key: SHOW_CC_KEY,
+ }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_ccShowAddressRowMenuItem"),
+ "show-cc-row-extra-menuitem"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_ccShowAddressRowButton"),
+ "show-cc-row-button",
+ {
+ key: SHOW_CC_KEY,
+ }
+ );
+
+ // Bcc field.
+ document.l10n.setAttributes(
+ document.getElementById("menu_showBccField"),
+ "show-bcc-row-main-menuitem",
+ {
+ key: SHOW_BCC_KEY,
+ }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_bccShowAddressRowMenuItem"),
+ "show-bcc-row-extra-menuitem"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("addr_bccShowAddressRowButton"),
+ "show-bcc-row-button",
+ {
+ key: SHOW_BCC_KEY,
+ }
+ );
+}
+
+/**
+ * Add a keydown document event listener for international keyboard shortcuts.
+ */
+async function setKeyboardShortcuts() {
+ let [filePickerKey, toggleBucketKey] = await l10nCompose.formatValues([
+ { id: "trigger-attachment-picker-key" },
+ { id: "toggle-attachment-pane-key" },
+ ]);
+
+ document.addEventListener("keydown", event => {
+ // Return if we don't have the right modifier combination, CTRL/CMD + SHIFT,
+ // or if the pressed key is a modifier (each modifier will keep firing
+ // keydown event until another key is pressed in addition).
+ if (
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ !event.shiftKey ||
+ ["Shift", "Control", "Meta"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // Always use lowercase to compare the key and avoid OS inconsistencies:
+ // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A".
+ switch (event.key.toLowerCase()) {
+ // Always prevent the default behavior of the keydown if we intercepted
+ // the key in order to avoid triggering OS specific shortcuts.
+ case filePickerKey.toLowerCase():
+ // Ctrl/Cmd+Shift+A.
+ event.preventDefault();
+ goDoCommand("cmd_attachFile");
+ break;
+ case toggleBucketKey.toLowerCase():
+ // Ctrl/Cmd+Shift+M.
+ event.preventDefault();
+ goDoCommand("cmd_toggleAttachmentPane");
+ break;
+ case SHOW_TO_KEY.toLowerCase():
+ // Ctrl/Cmd+Shift+T.
+ event.preventDefault();
+ showAndFocusAddressRow("addressRowTo");
+ break;
+ case SHOW_CC_KEY.toLowerCase():
+ // Ctrl/Cmd+Shift+C.
+ event.preventDefault();
+ showAndFocusAddressRow("addressRowCc");
+ break;
+ case SHOW_BCC_KEY.toLowerCase():
+ // Ctrl/Cmd+Shift+B.
+ event.preventDefault();
+ showAndFocusAddressRow("addressRowBcc");
+ break;
+ }
+ });
+
+ document.addEventListener("keypress", event => {
+ // If the user presses Esc and the drop attachment overlay is still visible,
+ // call the onDragLeave() method to properly hide it.
+ if (
+ event.key == "Escape" &&
+ document
+ .getElementById("dropAttachmentOverlay")
+ .classList.contains("show")
+ ) {
+ envelopeDragObserver.onDragLeave(event);
+ }
+ });
+}
+
+function ComposeUnload() {
+ // Send notification that the window is going away completely.
+ document
+ .getElementById("msgcomposeWindow")
+ .dispatchEvent(
+ new Event("compose-window-unload", { bubbles: false, cancelable: false })
+ );
+
+ GetCurrentCommandManager().removeCommandObserver(
+ gMsgEditorCreationObserver,
+ "obs_documentCreated"
+ );
+ UnloadCommandUpdateHandlers();
+
+ // In some tests, the window is closed so quickly that the observer
+ // hasn't fired and removed itself yet, so let's remove it here.
+ spellCheckReadyObserver.removeObserver();
+ // Stop spell checker so personal dictionary is saved.
+ enableInlineSpellCheck(false);
+
+ EditorCleanup();
+
+ if (gMsgCompose) {
+ gMsgCompose.removeMsgSendListener(gSendListener);
+ }
+
+ RemoveMessageComposeOfflineQuitObserver();
+ gAttachmentNotifier.shutdown();
+ ToolbarIconColor.uninit();
+
+ // Stop observing dictionary removals.
+ dictionaryRemovalObserver.removeObserver();
+
+ if (gMsgCompose) {
+ // Notify the SendListener that Send has been aborted and Stopped
+ gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT);
+ gMsgCompose.UnregisterStateListener(stateListener);
+ }
+ if (gAutoSaveTimeout) {
+ clearTimeout(gAutoSaveTimeout);
+ }
+ if (msgWindow) {
+ msgWindow.closeWindow();
+ }
+
+ ReleaseGlobalVariables();
+
+ top.controllers.removeController(SecurityController);
+
+ // This destroys the window for us.
+ MsgComposeCloseWindow();
+}
+
+function onEncryptionChoice(value) {
+ switch (value) {
+ case "OpenPGP":
+ if (isPgpConfigured()) {
+ gSelectedTechnologyIsPGP = true;
+ checkEncryptionState();
+ }
+ break;
+
+ case "SMIME":
+ if (isSmimeEncryptionConfigured()) {
+ gSelectedTechnologyIsPGP = false;
+ checkEncryptionState();
+ }
+ break;
+
+ case "enc":
+ toggleEncryptMessage();
+ break;
+
+ case "encsub":
+ gEncryptSubject = !gEncryptSubject;
+ gUserTouchedEncryptSubject = true;
+ updateEncryptedSubject();
+ break;
+
+ case "sig":
+ toggleGlobalSignMessage();
+ break;
+
+ case "status":
+ showMessageComposeSecurityStatus();
+ break;
+
+ case "manager":
+ openKeyManager();
+ break;
+ }
+}
+
+var SecurityController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_viewSecurityStatus":
+ return true;
+
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ switch (command) {
+ case "cmd_viewSecurityStatus":
+ return true;
+
+ default:
+ return false;
+ }
+ },
+};
+
+function updateEncryptOptionsMenuElements() {
+ let encOpt = document.getElementById("button-encryption-options");
+ if (encOpt) {
+ document.l10n.setAttributes(
+ encOpt,
+ gSelectedTechnologyIsPGP
+ ? "encryption-options-openpgp"
+ : "encryption-options-smime"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("menu_recipientStatus_Toolbar"),
+ gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates"
+ );
+ document.getElementById("menu_securityEncryptSubject_Toolbar").hidden =
+ !gSelectedTechnologyIsPGP;
+ }
+ document.l10n.setAttributes(
+ document.getElementById("menu_recipientStatus_Menubar"),
+ gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates"
+ );
+ document.getElementById("menu_securityEncryptSubject_Menubar").hidden =
+ !gSelectedTechnologyIsPGP;
+}
+
+/**
+ * Update the aria labels of all non-custom address inputs and all pills in the
+ * addressing area. Also update the tooltips of the close labels of all address
+ * rows, including custom header fields.
+ */
+async function updateAriaLabelsAndTooltipsOfAllAddressRows() {
+ for (let row of document
+ .getElementById("recipientsContainer")
+ .querySelectorAll(".address-row")) {
+ updateAriaLabelsOfAddressRow(row);
+ updateTooltipsOfAddressRow(row);
+ }
+}
+
+/**
+ * Update the aria labels of the address input and all pills of an address row.
+ * This is needed whenever a pill gets added or removed, because the aria label
+ * of each pill contains the current count of all pills in that row ("1 of n").
+ *
+ * @param {Element} row - The address row.
+ */
+async function updateAriaLabelsOfAddressRow(row) {
+ // Bail out for custom header input where pills are disabled.
+ if (row.classList.contains("address-row-raw")) {
+ return;
+ }
+ let input = row.querySelector(".address-row-input");
+
+ let type = row.querySelector(".address-label-container > label").value;
+ let pills = row.querySelectorAll("mail-address-pill");
+
+ input.setAttribute(
+ "aria-label",
+ await l10nCompose.formatValue("address-input-type-aria-label", {
+ type,
+ count: pills.length,
+ })
+ );
+
+ for (let pill of pills) {
+ pill.setAttribute(
+ "aria-label",
+ await l10nCompose.formatValue("pill-aria-label", {
+ email: pill.fullAddress,
+ count: pills.length,
+ })
+ );
+ }
+}
+
+/**
+ * Update the tooltip of the close label of an address row.
+ *
+ * @param {Element} row - The address row.
+ */
+function updateTooltipsOfAddressRow(row) {
+ let type = row.querySelector(".address-label-container > label").value;
+ let el = row.querySelector(".remove-field-button");
+ document.l10n.setAttributes(el, "remove-address-row-button", { type });
+}
+
+function onSendSMIME() {
+ let emailAddresses = [];
+
+ try {
+ if (!gMsgCompose.compFields.composeSecure.requireEncryptMessage) {
+ return;
+ }
+
+ for (let email of getEncryptionCompatibleRecipients()) {
+ if (!gSMFields.haveValidCertForEmail(email)) {
+ emailAddresses.push(email);
+ }
+ }
+ } catch (e) {
+ return;
+ }
+
+ if (emailAddresses.length == 0) {
+ return;
+ }
+
+ // The rules here: If the current identity has a directoryServer set, then
+ // use that, otherwise, try the global preference instead.
+
+ let autocompleteDirectory;
+
+ // Does the current identity override the global preference?
+ if (gCurrentIdentity.overrideGlobalPref) {
+ autocompleteDirectory = gCurrentIdentity.directoryServer;
+ } else if (Services.prefs.getBoolPref("ldap_2.autoComplete.useDirectory")) {
+ // Try the global one
+ autocompleteDirectory = Services.prefs.getCharPref(
+ "ldap_2.autoComplete.directoryServer"
+ );
+ }
+
+ if (autocompleteDirectory) {
+ window.openDialog(
+ "chrome://messenger-smime/content/certFetchingStatus.xhtml",
+ "",
+ "chrome,modal,resizable,centerscreen",
+ autocompleteDirectory,
+ emailAddresses
+ );
+ }
+}
+
+// Add-ons can override this to customize the behavior.
+function DoSpellCheckBeforeSend() {
+ return Services.prefs.getBoolPref("mail.SpellCheckBeforeSend");
+}
+
+/**
+ * Updates gMsgCompose.compFields to match the UI.
+ *
+ * @returns {nsIMsgCompFields}
+ */
+function GetComposeDetails() {
+ let msgCompFields = gMsgCompose.compFields;
+
+ Recipients2CompFields(msgCompFields);
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ document.getElementById("msgIdentity").value
+ );
+ msgCompFields.from = MailServices.headerParser.makeMimeHeader(addresses);
+ msgCompFields.subject = document.getElementById("msgSubject").value;
+ Attachments2CompFields(msgCompFields);
+
+ return msgCompFields;
+}
+
+/**
+ * Updates the UI to match newValues.
+ *
+ * @param {object} newValues - New values to use. Values that should not change
+ * should be null or not present.
+ * @param {string} [newValues.to]
+ * @param {string} [newValues.cc]
+ * @param {string} [newValues.bcc]
+ * @param {string} [newValues.replyTo]
+ * @param {string} [newValues.newsgroups]
+ * @param {string} [newValues.followupTo]
+ * @param {string} [newValues.subject]
+ * @param {string} [newValues.body]
+ * @param {string} [newValues.plainTextBody]
+ */
+function SetComposeDetails(newValues) {
+ if (newValues.identityKey !== null) {
+ let identityList = document.getElementById("msgIdentity");
+ for (let menuItem of identityList.menupopup.children) {
+ if (menuItem.getAttribute("identitykey") == newValues.identityKey) {
+ identityList.selectedItem = menuItem;
+ LoadIdentity(false);
+ break;
+ }
+ }
+ }
+ CompFields2Recipients(newValues);
+ if (typeof newValues.subject == "string") {
+ gMsgCompose.compFields.subject = document.getElementById(
+ "msgSubject"
+ ).value = newValues.subject;
+ SetComposeWindowTitle();
+ }
+ if (
+ typeof newValues.body == "string" &&
+ typeof newValues.plainTextBody == "string"
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let editor = GetCurrentEditor();
+ if (typeof newValues.body == "string") {
+ if (!IsHTMLEditor()) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ editor.rebuildDocumentFromSource(newValues.body);
+ gMsgCompose.bodyModified = true;
+ }
+ if (typeof newValues.plainTextBody == "string") {
+ editor.selectAll();
+ // Remove \r from line endings, which cause extra newlines (bug 1672407).
+ let mailEditor = editor.QueryInterface(Ci.nsIEditorMailSupport);
+ if (newValues.plainTextBody === "") {
+ editor.deleteSelection(editor.eNone, editor.eStrip);
+ } else {
+ mailEditor.insertTextWithQuotations(
+ newValues.plainTextBody.replaceAll("\r\n", "\n")
+ );
+ }
+ gMsgCompose.bodyModified = true;
+ }
+ gContentChanged = true;
+}
+
+/**
+ * Handles message sending operations.
+ *
+ * @param {nsIMsgCompDeliverMode} mode - The delivery mode of the operation.
+ */
+async function GenericSendMessage(msgType) {
+ let msgCompFields = GetComposeDetails();
+
+ // Some other msgCompFields have already been updated instantly in their
+ // respective toggle functions, e.g. ToggleReturnReceipt(), ToggleDSN(),
+ // ToggleAttachVCard(), and toggleAttachmentReminder().
+
+ let sending =
+ msgType == Ci.nsIMsgCompDeliverMode.Now ||
+ msgType == Ci.nsIMsgCompDeliverMode.Later ||
+ msgType == Ci.nsIMsgCompDeliverMode.Background;
+
+ // Notify about a new message being prepared for sending.
+ window.dispatchEvent(
+ new CustomEvent("compose-prepare-message-start", {
+ detail: { msgType },
+ })
+ );
+
+ try {
+ if (sending) {
+ // Since the onBeforeSend event can manipulate compose details, execute it
+ // before the final sanity checks.
+ try {
+ await new Promise((resolve, reject) => {
+ let beforeSendEvent = new CustomEvent("beforesend", {
+ cancelable: true,
+ detail: {
+ resolve,
+ reject,
+ },
+ });
+ window.dispatchEvent(beforeSendEvent);
+ if (!beforeSendEvent.defaultPrevented) {
+ resolve();
+ }
+ });
+ } catch (ex) {
+ throw new Error(`Send aborted by an onBeforeSend event`);
+ }
+
+ expandRecipients();
+ // Check if e-mail addresses are complete, in case user turned off
+ // autocomplete to local domain.
+ if (!CheckValidEmailAddress(msgCompFields)) {
+ throw new Error(`Send aborted: invalid recipient address found`);
+ }
+
+ // Do we need to check the spelling?
+ if (DoSpellCheckBeforeSend()) {
+ // We disable spellcheck for the following -subject line, attachment
+ // pane, identity and addressing widget therefore we need to explicitly
+ // focus on the mail body when we have to do a spellcheck.
+ focusMsgBody();
+ window.cancelSendMessage = false;
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml",
+ "_blank",
+ "dialog,close,titlebar,modal,resizable",
+ true,
+ true,
+ false
+ );
+
+ if (window.cancelSendMessage) {
+ throw new Error(`Send aborted by the user: spelling errors found`);
+ }
+ }
+
+ // Strip trailing spaces and long consecutive WSP sequences from the
+ // subject line to prevent getting only WSP chars on a folded line.
+ let subject = msgCompFields.subject;
+ let fixedSubject = subject.replace(/\s{74,}/g, " ").trimRight();
+ if (fixedSubject != subject) {
+ subject = fixedSubject;
+ msgCompFields.subject = fixedSubject;
+ document.getElementById("msgSubject").value = fixedSubject;
+ }
+
+ // Remind the person if there isn't a subject
+ if (subject == "") {
+ if (
+ Services.prompt.confirmEx(
+ window,
+ getComposeBundle().getString("subjectEmptyTitle"),
+ getComposeBundle().getString("subjectEmptyMessage"),
+ Services.prompt.BUTTON_TITLE_IS_STRING *
+ Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING *
+ Services.prompt.BUTTON_POS_1,
+ getComposeBundle().getString("sendWithEmptySubjectButton"),
+ getComposeBundle().getString("cancelSendingButton"),
+ null,
+ null,
+ { value: 0 }
+ ) == 1
+ ) {
+ document.getElementById("msgSubject").focus();
+ throw new Error(`Send aborted by the user: subject missing`);
+ }
+ }
+
+ // Attachment Reminder: Alert the user if
+ // - the user requested "Remind me later" from either the notification bar or the menu
+ // (alert regardless of the number of files already attached: we can't guess for how many
+ // or which files users want the reminder, and guessing wrong will annoy them a lot), OR
+ // - the aggressive pref is set and the latest notification is still showing (implying
+ // that the message has no attachment(s) yet, message still contains some attachment
+ // keywords, and notification was not dismissed).
+ if (
+ gManualAttachmentReminder ||
+ (Services.prefs.getBoolPref(
+ "mail.compose.attachment_reminder_aggressive"
+ ) &&
+ gComposeNotification.getNotificationWithValue("attachmentReminder"))
+ ) {
+ let flags =
+ Services.prompt.BUTTON_POS_0 *
+ Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING;
+ let hadForgotten = Services.prompt.confirmEx(
+ window,
+ getComposeBundle().getString("attachmentReminderTitle"),
+ getComposeBundle().getString("attachmentReminderMsg"),
+ flags,
+ getComposeBundle().getString("attachmentReminderFalseAlarm"),
+ getComposeBundle().getString("attachmentReminderYesIForgot"),
+ null,
+ null,
+ { value: 0 }
+ );
+ // Deactivate manual attachment reminder after showing the alert to avoid alert loop.
+ // We also deactivate reminder when user ignores alert with [x] or [ESC].
+ if (gManualAttachmentReminder) {
+ toggleAttachmentReminder(false);
+ }
+
+ if (hadForgotten) {
+ throw new Error(`Send aborted by the user: attachment missing`);
+ }
+ }
+
+ // Aggressive many public recipients prompt.
+ let publicRecipientCount = getPublicAddressPillsCount();
+ if (
+ Services.prefs.getBoolPref(
+ "mail.compose.warn_public_recipients.aggressive"
+ ) &&
+ publicRecipientCount >=
+ Services.prefs.getIntPref(
+ "mail.compose.warn_public_recipients.threshold"
+ )
+ ) {
+ let flags =
+ Services.prompt.BUTTON_POS_0 *
+ Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING;
+ let [title, msg, cancel, send] = l10nComposeSync.formatValuesSync([
+ "many-public-recipients-prompt-title",
+ {
+ id: "many-public-recipients-prompt-msg",
+ args: { count: getPublicAddressPillsCount() },
+ },
+ "many-public-recipients-prompt-cancel",
+ "many-public-recipients-prompt-send",
+ ]);
+ let willCancel = Services.prompt.confirmEx(
+ window,
+ title,
+ msg,
+ flags,
+ send,
+ cancel,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (willCancel) {
+ if (!gRecipientObserver) {
+ // Re-create this observer as it is destroyed when the user dismisses
+ // the warning.
+ gRecipientObserver = new MutationObserver(function (mutations) {
+ if (mutations.some(m => m.type == "childList")) {
+ checkPublicRecipientsLimit();
+ }
+ });
+ }
+ checkPublicRecipientsLimit();
+ throw new Error(
+ `Send aborted by the user: too many public recipients found`
+ );
+ }
+ }
+
+ // Check if the user tries to send a message to a newsgroup through a mail
+ // account.
+ var currentAccountKey = getCurrentAccountKey();
+ let account = MailServices.accounts.getAccount(currentAccountKey);
+ if (
+ account.incomingServer.type != "nntp" &&
+ msgCompFields.newsgroups != ""
+ ) {
+ const kDontAskAgainPref = "mail.compose.dontWarnMail2Newsgroup";
+ // default to ask user if the pref is not set
+ let dontAskAgain = Services.prefs.getBoolPref(kDontAskAgainPref);
+ if (!dontAskAgain) {
+ let checkbox = { value: false };
+ let okToProceed = Services.prompt.confirmCheck(
+ window,
+ getComposeBundle().getString("noNewsgroupSupportTitle"),
+ getComposeBundle().getString("recipientDlogMessage"),
+ getComposeBundle().getString("CheckMsg"),
+ checkbox
+ );
+ if (!okToProceed) {
+ throw new Error(`Send aborted by the user: wrong account used`);
+ }
+
+ if (checkbox.value) {
+ Services.prefs.setBoolPref(kDontAskAgainPref, true);
+ }
+ }
+
+ // remove newsgroups to prevent news_p to be set
+ // in nsMsgComposeAndSend::DeliverMessage()
+ msgCompFields.newsgroups = "";
+ }
+
+ if (Services.prefs.getBoolPref("mail.compose.add_link_preview", true)) {
+ // Remove any card "close" button from content before sending.
+ for (let close of getBrowser().contentDocument.querySelectorAll(
+ ".moz-card .remove-card"
+ )) {
+ close.remove();
+ }
+ }
+
+ let sendFormat = determineSendFormat();
+ switch (sendFormat) {
+ case Ci.nsIMsgCompSendFormat.PlainText:
+ msgCompFields.forcePlainText = true;
+ msgCompFields.useMultipartAlternative = false;
+ break;
+ case Ci.nsIMsgCompSendFormat.HTML:
+ msgCompFields.forcePlainText = false;
+ msgCompFields.useMultipartAlternative = false;
+ break;
+ case Ci.nsIMsgCompSendFormat.Both:
+ msgCompFields.forcePlainText = false;
+ msgCompFields.useMultipartAlternative = true;
+ break;
+ default:
+ throw new Error(`Invalid send format ${sendFormat}`);
+ }
+ }
+
+ await CompleteGenericSendMessage(msgType);
+ window.dispatchEvent(new CustomEvent("compose-prepare-message-success"));
+ } catch (exception) {
+ console.error(exception);
+ window.dispatchEvent(
+ new CustomEvent("compose-prepare-message-failure", {
+ detail: { exception },
+ })
+ );
+ }
+}
+
+/**
+ * Finishes message sending. This should ONLY be called directly from
+ * GenericSendMessage. This is a separate function so that it can be easily mocked
+ * in tests.
+ *
+ * @param msgType nsIMsgCompDeliverMode of the operation.
+ */
+async function CompleteGenericSendMessage(msgType) {
+ // hook for extra compose pre-processing
+ Services.obs.notifyObservers(window, "mail:composeOnSend");
+
+ if (!gSelectedTechnologyIsPGP) {
+ gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted;
+ gMsgCompose.compFields.composeSecure.signMessage = gSendSigned;
+ onSendSMIME();
+ }
+
+ let sendError = null;
+ try {
+ // Just before we try to send the message, fire off the
+ // compose-send-message event for listeners, so they can do
+ // any pre-security work before sending.
+ var event = document.createEvent("UIEvents");
+ event.initEvent("compose-send-message", false, true);
+ var msgcomposeWindow = document.getElementById("msgcomposeWindow");
+ msgcomposeWindow.setAttribute("msgtype", msgType);
+ msgcomposeWindow.dispatchEvent(event);
+ if (event.defaultPrevented) {
+ throw Components.Exception(
+ "compose-send-message prevented",
+ Cr.NS_ERROR_ABORT
+ );
+ }
+
+ gAutoSaving = msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft;
+
+ // disable the ui if we're not auto-saving
+ if (!gAutoSaving) {
+ ToggleWindowLock(true);
+ } else {
+ // If we're auto saving, mark the body as not changed here, and not
+ // when the save is done, because the user might change it between now
+ // and when the save is done.
+ SetContentAndBodyAsUnmodified();
+ }
+
+ // Keep track of send/saved cloudFiles and mark them as immutable.
+ let items = [...gAttachmentBucket.itemChildren];
+ for (let item of items) {
+ if (item.attachment.sendViaCloud && item.cloudFileAccount) {
+ item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id);
+ }
+ }
+
+ var progress = Cc["@mozilla.org/messenger/progress;1"].createInstance(
+ Ci.nsIMsgProgress
+ );
+ if (progress) {
+ progress.registerListener(progressListener);
+ if (
+ msgType == Ci.nsIMsgCompDeliverMode.Save ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate
+ ) {
+ gSaveOperationInProgress = true;
+ } else {
+ gSendOperationInProgress = true;
+ }
+ }
+ msgWindow.domWindow = window;
+ msgWindow.rootDocShell.allowAuth = true;
+ await gMsgCompose.sendMsg(
+ msgType,
+ gCurrentIdentity,
+ getCurrentAccountKey(),
+ msgWindow,
+ progress
+ );
+ } catch (ex) {
+ console.error("GenericSendMessage FAILED: " + ex);
+ ToggleWindowLock(false);
+ sendError = ex;
+ }
+
+ if (
+ msgType == Ci.nsIMsgCompDeliverMode.Now ||
+ msgType == Ci.nsIMsgCompDeliverMode.Later ||
+ msgType == Ci.nsIMsgCompDeliverMode.Background
+ ) {
+ window.dispatchEvent(new CustomEvent("aftersend"));
+
+ let maxSize =
+ Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") *
+ 1024;
+ let items = [...gAttachmentBucket.itemChildren];
+
+ // When any big attachment is not sent via filelink, increment
+ // `tb.filelink.ignored`.
+ if (
+ items.some(
+ item => item.attachment.size >= maxSize && !item.attachment.sendViaCloud
+ )
+ ) {
+ Services.telemetry.scalarAdd("tb.filelink.ignored", 1);
+ }
+ } else if (
+ msgType == Ci.nsIMsgCompDeliverMode.Save ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft ||
+ msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate
+ ) {
+ window.dispatchEvent(new CustomEvent("aftersave"));
+ }
+
+ if (sendError) {
+ throw sendError;
+ }
+}
+
+/**
+ * Check if the given email address is valid (contains an @).
+ *
+ * @param {string} address - The email address string to check.
+ */
+function isValidAddress(address) {
+ return address.includes("@", 1) && !address.endsWith("@");
+}
+
+/**
+ * Check if the given news address is valid (contains a dot).
+ *
+ * @param {string} address - The news address string to check.
+ */
+function isValidNewsAddress(address) {
+ return address.includes(".", 1) && !address.endsWith(".");
+}
+
+/**
+ * Force the focus on the autocomplete input if the user clicks on an empty
+ * area of the address container.
+ *
+ * @param {Event} event - the event triggered by the click.
+ */
+function focusAddressInputOnClick(event) {
+ let container = event.target;
+ if (container.classList.contains("address-container")) {
+ container.querySelector(".address-row-input").focus();
+ }
+}
+
+/**
+ * Keep the Send buttons disabled until any recipient is entered.
+ */
+function updateSendLock() {
+ gSendLocked = true;
+ if (!gMsgCompose) {
+ return;
+ }
+
+ const addressRows = [
+ "toAddrContainer",
+ "ccAddrContainer",
+ "bccAddrContainer",
+ "newsgroupsAddrContainer",
+ ];
+
+ for (let parentID of addressRows) {
+ if (!gSendLocked) {
+ break;
+ }
+
+ let parent = document.getElementById(parentID);
+
+ if (!parent) {
+ continue;
+ }
+
+ for (let address of parent.querySelectorAll(".address-pill")) {
+ let listNames = MimeParser.parseHeaderField(
+ address.fullAddress,
+ MimeParser.HEADER_ADDRESS
+ );
+ let isMailingList =
+ listNames.length > 0 &&
+ MailServices.ab.mailListNameExists(listNames[0].name);
+
+ if (
+ isValidAddress(address.emailAddress) ||
+ isMailingList ||
+ address.emailInput.classList.contains("news-input")
+ ) {
+ gSendLocked = false;
+ break;
+ }
+ }
+ }
+
+ // Check the non pillified input text inside the autocomplete input fields.
+ for (let input of document.querySelectorAll(
+ ".address-row:not(.hidden):not(.address-row-raw) .address-row-input"
+ )) {
+ let inputValueTrim = input.value.trim();
+ // If there's no text in the input, proceed with next input.
+ if (!inputValueTrim) {
+ continue;
+ }
+ // If text contains " >> " (typically from an unfinished autocompletion),
+ // lock Send and return.
+ if (inputValueTrim.includes(" >> ")) {
+ gSendLocked = true;
+ return;
+ }
+
+ // If we find at least one valid pill, and in spite of potential other
+ // invalid pills or invalid addresses in the input, enable the Send button.
+ // It might be disabled again if the above autocomplete artifact is present
+ // in a subsequent row, to prevent sending the artifact as a valid address.
+ if (
+ input.classList.contains("news-input")
+ ? isValidNewsAddress(inputValueTrim)
+ : isValidAddress(inputValueTrim)
+ ) {
+ gSendLocked = false;
+ }
+ }
+}
+
+/**
+ * Check if the entered addresses are valid and alert the user if they are not.
+ *
+ * @param aMsgCompFields A nsIMsgCompFields object containing the fields to check.
+ */
+function CheckValidEmailAddress(aMsgCompFields) {
+ let invalidStr;
+ let recipientCount = 0;
+ // Check that each of the To, CC, and BCC recipients contains a '@'.
+ for (let type of ["to", "cc", "bcc"]) {
+ let recipients = aMsgCompFields.splitRecipients(
+ aMsgCompFields[type],
+ false
+ );
+ // MsgCompFields contains only non-empty recipients.
+ recipientCount += recipients.length;
+ for (let recipient of recipients) {
+ if (!isValidAddress(recipient)) {
+ invalidStr = recipient;
+ break;
+ }
+ }
+ if (invalidStr) {
+ break;
+ }
+ }
+
+ if (recipientCount == 0 && aMsgCompFields.newsgroups.trim() == "") {
+ Services.prompt.alert(
+ window,
+ getComposeBundle().getString("addressInvalidTitle"),
+ getComposeBundle().getString("noRecipients")
+ );
+ return false;
+ }
+
+ if (invalidStr) {
+ Services.prompt.alert(
+ window,
+ getComposeBundle().getString("addressInvalidTitle"),
+ getComposeBundle().getFormattedString("addressInvalid", [invalidStr], 1)
+ );
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Cycle through all the currently visible autocomplete addressing rows and
+ * generate pills for those inputs with leftover strings. Do the same if we
+ * have a pill currently being edited. This is necessary in case a user writes
+ * an extra address and clicks "Send" or "Save as..." before the text is
+ * converted into a pill. The input onBlur doesn't work if the click interaction
+ * happens on the window's menu bar.
+ */
+async function pillifyRecipients() {
+ for (let input of document.querySelectorAll(
+ ".address-row:not(.hidden):not(.address-row-raw) .address-row-input"
+ )) {
+ // If we find a leftover string in the input field, create a pill. If the
+ // newly created pill is not a valid address, the sending will stop.
+ if (input.value.trim()) {
+ recipientAddPills(input);
+ }
+ }
+
+ // Update the currently editing pill, if any.
+ // It's impossible to edit more than one pill at once.
+ await document.querySelector("mail-address-pill.editing")?.updatePill();
+}
+
+/**
+ * Handle the dragover event on a recipient disclosure label.
+ *
+ * @param {Event} - The DOM dragover event on a recipient disclosure label.
+ */
+function showAddressRowButtonOnDragover(event) {
+ // Prevent dragover event's default action (which resets the current drag
+ // operation to "none").
+ event.preventDefault();
+}
+
+/**
+ * Handle the drop event on a recipient disclosure label.
+ *
+ * @param {Event} - The DOM drop event on a recipient disclosure label.
+ */
+function showAddressRowButtonOnDrop(event) {
+ if (event.dataTransfer.types.includes("text/pills")) {
+ // If the dragged data includes the type "text/pills", we believe that
+ // the user is dragging our own pills, so we try to move the selected pills
+ // to the address row of the recipient label they were dropped on (Cc, Bcc,
+ // etc.), which will also show the row if needed. If there are no selected
+ // pills (so "text/pills" was generated elsewhere), moveSelectedPills() will
+ // bail out and we'll do nothing.
+ let row = document.getElementById(event.target.dataset.addressRow);
+ document.getElementById("recipientsContainer").moveSelectedPills(row);
+ }
+}
+
+/**
+ * Command handler: Cut the selected pills.
+ */
+function cutSelectedPillsOnCommand() {
+ document.getElementById("recipientsContainer").cutSelectedPills();
+}
+
+/**
+ * Command handler: Copy the selected pills.
+ */
+function copySelectedPillsOnCommand() {
+ document.getElementById("recipientsContainer").copySelectedPills();
+}
+
+/**
+ * Command handler: Select the focused pill and all siblings in the same
+ * address row.
+ *
+ * @param {Element} focusPill - The focused <mail-address-pill> element.
+ */
+function selectAllSiblingPillsOnCommand(focusPill) {
+ let recipientsContainer = document.getElementById("recipientsContainer");
+ // First deselect all pills to ensure that no pills outside the current
+ // address row are selected, e.g. when this action was triggered from
+ // context menu on already selected pill(s).
+ recipientsContainer.deselectAllPills();
+ // Select all pills of the current address row.
+ recipientsContainer.selectSiblingPills(focusPill);
+}
+
+/**
+ * Command handler: Select all recipient pills in the addressing area.
+ */
+function selectAllPillsOnCommand() {
+ document.getElementById("recipientsContainer").selectAllPills();
+}
+
+/**
+ * Command handler: Delete the selected pills.
+ */
+function deleteSelectedPillsOnCommand() {
+ document.getElementById("recipientsContainer").removeSelectedPills();
+}
+
+/**
+ * Command handler: Move the selected pills to another address row.
+ *
+ * @param {string} rowId - The id of the address row to move to.
+ */
+function moveSelectedPillsOnCommand(rowId) {
+ document
+ .getElementById("recipientsContainer")
+ .moveSelectedPills(document.getElementById(rowId));
+}
+
+/**
+ * Check if there are too many public recipients and offer to send them as BCC.
+ */
+function checkPublicRecipientsLimit() {
+ let notification = gComposeNotification.getNotificationWithValue(
+ "warnPublicRecipientsNotification"
+ );
+
+ let recipLimit = Services.prefs.getIntPref(
+ "mail.compose.warn_public_recipients.threshold"
+ );
+
+ let publicAddressPillsCount = getPublicAddressPillsCount();
+
+ if (publicAddressPillsCount < recipLimit) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ // Reuse the existing notification since one is shown already.
+ if (notification) {
+ if (publicAddressPillsCount > 1) {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-multi",
+ {
+ count: publicAddressPillsCount,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-single"
+ );
+ }
+ return;
+ }
+
+ // Construct the notification as we don't have one.
+ let bccButton = {
+ "l10n-id": "many-public-recipients-bcc",
+ callback() {
+ // Get public addresses before we remove the pills.
+ let publicAddresses = getPublicAddressPills().map(
+ pill => pill.fullAddress
+ );
+
+ addressRowClearPills(document.getElementById("addressRowTo"));
+ addressRowClearPills(document.getElementById("addressRowCc"));
+ // Add previously public address pills to Bcc address row and select them.
+ let bccRow = document.getElementById("addressRowBcc");
+ addressRowAddRecipientsArray(bccRow, publicAddresses, true);
+ // Focus last added pill to prevent sticky selection with focus elsewhere.
+ bccRow.querySelector("mail-address-pill:last-of-type").focus();
+ return false;
+ },
+ };
+
+ let ignoreButton = {
+ "l10n-id": "many-public-recipients-ignore",
+ callback() {
+ gRecipientObserver.disconnect();
+ gRecipientObserver = null;
+ // After closing notification with `Keep Recipients Public`, actively
+ // manage focus to prevent weird focus change e.g. to Contacts Sidebar.
+ // If focus was in addressing area before, restore that as the user might
+ // dismiss the notification when it appears while still adding recipients.
+ if (gLastFocusElement?.classList.contains("address-input")) {
+ gLastFocusElement.focus();
+ return false;
+ }
+
+ // Otherwise if there's no subject yet, focus that (ux-error-prevention).
+ let msgSubject = document.getElementById("msgSubject");
+ if (!msgSubject.value) {
+ msgSubject.focus();
+ return false;
+ }
+
+ // Otherwise default to focusing message body.
+ document.getElementById("messageEditor").focus();
+ return false;
+ },
+ };
+
+ // NOTE: setting "public-recipients-notice-single" below, after the notification
+ // has been appended, so that the notification can be found and no further
+ // notifications are appended.
+ notification = gComposeNotification.appendNotification(
+ "warnPublicRecipientsNotification",
+ {
+ label: "", // "public-recipients-notice-single"
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ eventCallback(state) {
+ if (state == "dismissed") {
+ ignoreButton.callback();
+ }
+ },
+ },
+ [bccButton, ignoreButton]
+ );
+
+ if (notification) {
+ if (publicAddressPillsCount > 1) {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-multi",
+ {
+ count: publicAddressPillsCount,
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ notification.messageText,
+ "public-recipients-notice-single"
+ );
+ }
+ }
+}
+
+/**
+ * Get all the address pills in the "To" and "Cc" fields.
+ *
+ * @returns {Element[]} All <mail-address-pill> elements in "To" and "CC" fields.
+ */
+function getPublicAddressPills() {
+ return [
+ ...document.querySelectorAll("#toAddrContainer > mail-address-pill"),
+ ...document.querySelectorAll("#ccAddrContainer > mail-address-pill"),
+ ];
+}
+
+/**
+ * Gets the count of all the address pills in the "To" and "Cc" fields. This
+ * takes mailing lists into consideration as well.
+ */
+function getPublicAddressPillsCount() {
+ let pills = getPublicAddressPills();
+ return pills.reduce(
+ (total, pill) =>
+ pill.isMailList ? total + pill.listAddressCount : total + 1,
+ 0
+ );
+}
+
+/**
+ * Check for Bcc recipients in an encrypted message and warn the user.
+ * The warning is not shown if the only Bcc recipient is the sender.
+ */
+async function checkEncryptedBccRecipients() {
+ let notification = gComposeNotification.getNotificationWithValue(
+ "warnEncryptedBccRecipients"
+ );
+
+ if (!gWantCannotEncryptBCCNotification) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ let bccRecipients = [
+ ...document.querySelectorAll("#bccAddrContainer > mail-address-pill"),
+ ];
+ let bccIsSender = bccRecipients.every(
+ pill => pill.emailAddress == gCurrentIdentity.email
+ );
+
+ if (!gSendEncrypted || !bccRecipients.length || bccIsSender) {
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ return;
+ }
+
+ if (notification) {
+ return;
+ }
+
+ let ignoreButton = {
+ "l10n-id": "encrypted-bcc-ignore-button",
+ callback() {
+ gWantCannotEncryptBCCNotification = false;
+ return false;
+ },
+ };
+
+ gComposeNotification.appendNotification(
+ "warnEncryptedBccRecipients",
+ {
+ label: await document.l10n.formatValue("encrypted-bcc-warning"),
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ eventCallback(state) {
+ if (state == "dismissed") {
+ ignoreButton.callback();
+ }
+ },
+ },
+ [ignoreButton]
+ );
+}
+
+async function SendMessage() {
+ await pillifyRecipients();
+ let sendInBackground = Services.prefs.getBoolPref(
+ "mailnews.sendInBackground"
+ );
+ if (sendInBackground && AppConstants.platform != "macosx") {
+ let count = [...Services.wm.getEnumerator(null)].length;
+ if (count == 1) {
+ sendInBackground = false;
+ }
+ }
+
+ await GenericSendMessage(
+ sendInBackground
+ ? Ci.nsIMsgCompDeliverMode.Background
+ : Ci.nsIMsgCompDeliverMode.Now
+ );
+ ExitFullscreenMode();
+}
+
+async function SendMessageWithCheck() {
+ await pillifyRecipients();
+ var warn = Services.prefs.getBoolPref("mail.warn_on_send_accel_key");
+
+ if (warn) {
+ let bundle = getComposeBundle();
+ let checkValue = { value: false };
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ bundle.getString("sendMessageCheckWindowTitle"),
+ bundle.getString("sendMessageCheckLabel"),
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1,
+ bundle.getString("sendMessageCheckSendButtonLabel"),
+ null,
+ null,
+ bundle.getString("CheckMsg"),
+ checkValue
+ );
+ if (buttonPressed != 0) {
+ return;
+ }
+ if (checkValue.value) {
+ Services.prefs.setBoolPref("mail.warn_on_send_accel_key", false);
+ }
+ }
+
+ let sendInBackground = Services.prefs.getBoolPref(
+ "mailnews.sendInBackground"
+ );
+
+ let mode;
+ if (Services.io.offline) {
+ mode = Ci.nsIMsgCompDeliverMode.Later;
+ } else {
+ mode = sendInBackground
+ ? Ci.nsIMsgCompDeliverMode.Background
+ : Ci.nsIMsgCompDeliverMode.Now;
+ }
+ await GenericSendMessage(mode);
+ ExitFullscreenMode();
+}
+
+async function SendMessageLater() {
+ await pillifyRecipients();
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later);
+ ExitFullscreenMode();
+}
+
+function ExitFullscreenMode() {
+ // On OS X we need to deliberately exit full screen mode after sending.
+ if (AppConstants.platform == "macosx") {
+ window.fullScreen = false;
+ }
+}
+
+function Save() {
+ switch (defaultSaveOperation) {
+ case "file":
+ SaveAsFile(false);
+ break;
+ case "template":
+ SaveAsTemplate(false).catch(console.error);
+ break;
+ default:
+ SaveAsDraft(false).catch(console.error);
+ break;
+ }
+}
+
+function SaveAsFile(saveAs) {
+ GetCurrentEditorElement().contentDocument.title =
+ document.getElementById("msgSubject").value;
+
+ if (gMsgCompose.bodyConvertible() == Ci.nsIMsgCompConvertible.Plain) {
+ SaveDocument(saveAs, false, "text/plain");
+ } else {
+ SaveDocument(saveAs, false, "text/html");
+ }
+ defaultSaveOperation = "file";
+}
+
+async function SaveAsDraft() {
+ gAutoSaveKickedIn = false;
+ gEditingDraft = true;
+
+ await pillifyRecipients();
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsDraft);
+ defaultSaveOperation = "draft";
+}
+
+async function SaveAsTemplate() {
+ gAutoSaveKickedIn = false;
+ gEditingDraft = false;
+
+ await pillifyRecipients();
+ let savedReferences = null;
+ if (gMsgCompose && gMsgCompose.compFields) {
+ // Clear References header. When we use the template, we don't want that
+ // header, yet, "edit as new message" maintains it. So we need to clear
+ // it when saving the template.
+ // Note: The In-Reply-To header is the last entry in the references header,
+ // so it will get cleared as well.
+ savedReferences = gMsgCompose.compFields.references;
+ gMsgCompose.compFields.references = null;
+ }
+
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsTemplate);
+ defaultSaveOperation = "template";
+
+ if (savedReferences) {
+ gMsgCompose.compFields.references = savedReferences;
+ }
+}
+
+// Sets the additional FCC, in addition to the default FCC.
+function MessageFcc(aFolder) {
+ if (!gMsgCompose) {
+ return;
+ }
+
+ var msgCompFields = gMsgCompose.compFields;
+ if (!msgCompFields) {
+ return;
+ }
+
+ // Get the uri for the folder to FCC into.
+ var fccURI = aFolder.URI;
+ msgCompFields.fcc2 = msgCompFields.fcc2 == fccURI ? "nocopy://" : fccURI;
+}
+
+function updateOptionsMenu() {
+ setSecuritySettings("_Menubar");
+
+ let menuItem = document.getElementById("menu_inlineSpellCheck");
+ if (gSpellCheckingEnabled) {
+ menuItem.setAttribute("checked", "true");
+ } else {
+ menuItem.removeAttribute("checked");
+ }
+}
+
+function updatePriorityMenu() {
+ if (gMsgCompose) {
+ var msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields && msgCompFields.priority) {
+ var priorityMenu = document.getElementById("priorityMenu");
+ priorityMenu.querySelector('[checked="true"]').removeAttribute("checked");
+ priorityMenu
+ .querySelector('[value="' + msgCompFields.priority + '"]')
+ .setAttribute("checked", "true");
+ }
+ }
+}
+
+function updatePriorityToolbarButton(newPriorityValue) {
+ var prioritymenu = document.getElementById("priorityMenu-button");
+ if (prioritymenu) {
+ prioritymenu.value = newPriorityValue;
+ }
+}
+
+function PriorityMenuSelect(target) {
+ if (gMsgCompose) {
+ var msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields) {
+ msgCompFields.priority = target.getAttribute("value");
+ }
+
+ // keep priority toolbar button in synch with possible changes via the menu item
+ updatePriorityToolbarButton(target.getAttribute("value"));
+ }
+}
+
+/**
+ * Initialise the send format menu using the current gMsgCompose.compFields.
+ */
+function initSendFormatMenu() {
+ let formatToId = new Map([
+ [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"],
+ [Ci.nsIMsgCompSendFormat.HTML, "format_html"],
+ [Ci.nsIMsgCompSendFormat.Both, "format_both"],
+ [Ci.nsIMsgCompSendFormat.Auto, "format_auto"],
+ ]);
+
+ let sendFormat = gMsgCompose.compFields.deliveryFormat;
+
+ if (sendFormat == Ci.nsIMsgCompSendFormat.Unset) {
+ sendFormat = Services.prefs.getIntPref(
+ "mail.default_send_format",
+ Ci.nsIMsgCompSendFormat.Auto
+ );
+
+ if (!formatToId.has(sendFormat)) {
+ // Unknown preference value.
+ sendFormat = Ci.nsIMsgCompSendFormat.Auto;
+ }
+ }
+
+ // Make the composition field uses the same as determined above. Specifically,
+ // if the deliveryFormat was Unset, we now set it to a specific value.
+ gMsgCompose.compFields.deliveryFormat = sendFormat;
+
+ for (let [format, id] of formatToId.entries()) {
+ let menuitem = document.getElementById(id);
+ menuitem.value = String(format);
+ if (format == sendFormat) {
+ menuitem.setAttribute("checked", "true");
+ } else {
+ menuitem.removeAttribute("checked");
+ }
+ }
+
+ document
+ .getElementById("outputFormatMenu")
+ .addEventListener("command", event => {
+ let prevSendFormat = gMsgCompose.compFields.deliveryFormat;
+ let newSendFormat = parseInt(event.target.value, 10);
+ gMsgCompose.compFields.deliveryFormat = newSendFormat;
+ gContentChanged = prevSendFormat != newSendFormat;
+ });
+}
+
+/**
+ * Walk through a plain text list of recipients and add them to the inline spell
+ * checker ignore list, e.g. to avoid that known recipient names get marked
+ * wrong in message body.
+ *
+ * @param {string} aAddressesToAdd - A (comma-separated) recipient(s) string.
+ */
+function addRecipientsToIgnoreList(aAddressesToAdd) {
+ if (gSpellCheckingEnabled) {
+ // break the list of potentially many recipients back into individual names
+ let addresses =
+ MailServices.headerParser.parseEncodedHeader(aAddressesToAdd);
+ let tokenizedNames = [];
+
+ // Each name could consist of multiple word delimited by either commas or spaces, i.e. Green Lantern
+ // or Lantern,Green. Tokenize on comma first, then tokenize again on spaces.
+ for (let addr of addresses) {
+ if (!addr.name) {
+ continue;
+ }
+ let splitNames = addr.name.split(",");
+ for (let i = 0; i < splitNames.length; i++) {
+ // now tokenize off of white space
+ let splitNamesFromWhiteSpaceArray = splitNames[i].split(" ");
+ for (
+ let whiteSpaceIndex = 0;
+ whiteSpaceIndex < splitNamesFromWhiteSpaceArray.length;
+ whiteSpaceIndex++
+ ) {
+ if (splitNamesFromWhiteSpaceArray[whiteSpaceIndex]) {
+ tokenizedNames.push(splitNamesFromWhiteSpaceArray[whiteSpaceIndex]);
+ }
+ }
+ }
+ }
+ spellCheckReadyObserver.addWordsToIgnore(tokenizedNames);
+ }
+}
+
+/**
+ * Observer waiting for spell checker to become initialized or to complete
+ * checking. When it fires, it pushes new words to be ignored to the speller.
+ */
+var spellCheckReadyObserver = {
+ _topic: "inlineSpellChecker-spellCheck-ended",
+
+ _ignoreWords: [],
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != this._topic) {
+ return;
+ }
+
+ this.removeObserver();
+ this._addWords();
+ },
+
+ _isAdded: false,
+
+ addObserver() {
+ if (this._isAdded) {
+ return;
+ }
+
+ Services.obs.addObserver(this, this._topic);
+ this._isAdded = true;
+ },
+
+ removeObserver() {
+ if (!this._isAdded) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, this._topic);
+ this._clearPendingWords();
+ this._isAdded = false;
+ },
+
+ addWordsToIgnore(aIgnoreWords) {
+ this._ignoreWords.push(...aIgnoreWords);
+ let checker = GetCurrentEditorSpellChecker();
+ if (!checker || checker.spellCheckPending) {
+ // spellchecker is enabled, but we must wait for its init to complete
+ this.addObserver();
+ } else {
+ this._addWords();
+ }
+ },
+
+ _addWords() {
+ // At the time the speller finally got initialized, we may already be closing
+ // the compose together with the speller, so we need to check if they
+ // are still valid.
+ let checker = GetCurrentEditorSpellChecker();
+ if (gMsgCompose && checker?.enableRealTimeSpell) {
+ checker.ignoreWords(this._ignoreWords);
+ }
+ this._clearPendingWords();
+ },
+
+ _clearPendingWords() {
+ this._ignoreWords.length = 0;
+ },
+};
+
+/**
+ * Called if the list of recipients changed in any way.
+ *
+ * @param {boolean} automatic - Set to true if the change of recipients was
+ * invoked programmatically and should not be considered a change of message
+ * content.
+ */
+function onRecipientsChanged(automatic) {
+ if (!automatic) {
+ gContentChanged = true;
+ }
+ updateSendCommands(true);
+}
+
+/**
+ * Show the popup identified by aPopupID
+ * at the anchor element identified by aAnchorID.
+ *
+ * Note: All but the first 2 parameters are identical with the parameters of
+ * the openPopup() method of XUL popup element. For details, please consult docs.
+ * Except aPopupID, all parameters are optional.
+ * Example: showPopupById("aPopupID", "aAnchorID");
+ *
+ * @param aPopupID the ID of the popup element to be shown
+ * @param aAnchorID the ID of an element to which the popup should be anchored
+ * @param aPosition a single-word alignment value for the position parameter
+ * of openPopup() method; defaults to "after_start" if omitted.
+ * @param x x offset from default position
+ * @param y y offset from default position
+ * @param isContextMenu {boolean} For details, see documentation.
+ * @param attributesOverride {boolean} whether the position attribute on the
+ * popup node overrides the position parameter
+ * @param triggerEvent the event that triggered the popup
+ */
+function showPopupById(
+ aPopupID,
+ aAnchorID,
+ aPosition = "after_start",
+ x,
+ y,
+ isContextMenu,
+ attributesOverride,
+ triggerEvent
+) {
+ let popup = document.getElementById(aPopupID);
+ let anchor = document.getElementById(aAnchorID);
+ popup.openPopup(
+ anchor,
+ aPosition,
+ x,
+ y,
+ isContextMenu,
+ attributesOverride,
+ triggerEvent
+ );
+}
+
+function InitLanguageMenu() {
+ var languageMenuList = document.getElementById("languageMenuList");
+ if (!languageMenuList) {
+ return;
+ }
+
+ var spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ // Get the list of dictionaries from
+ // the spellchecker.
+
+ var dictList = spellChecker.getDictionaryList();
+
+ let extraItemCount = dictList.length === 0 ? 1 : 2;
+
+ // If dictionary count hasn't changed then no need to update the menu.
+ if (dictList.length + extraItemCount == languageMenuList.childElementCount) {
+ return;
+ }
+
+ var sortedList = gSpellChecker.sortDictionaryList(dictList);
+
+ let getMoreItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(getMoreItem, "spell-add-dictionaries");
+ getMoreItem.addEventListener("command", event => {
+ event.stopPropagation();
+ openDictionaryList();
+ });
+ let getMoreArray = [getMoreItem];
+
+ if (extraItemCount > 1) {
+ getMoreArray.unshift(document.createXULElement("menuseparator"));
+ }
+
+ // Remove any languages from the list.
+ languageMenuList.replaceChildren(
+ ...sortedList.map(dict => {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", dict.displayName);
+ item.setAttribute("value", dict.localeCode);
+ item.setAttribute("type", "checkbox");
+ item.setAttribute("selection-type", "multiple");
+ if (dictList.length > 1) {
+ item.setAttribute("closemenu", "none");
+ }
+ return item;
+ }),
+ ...getMoreArray
+ );
+}
+
+function OnShowDictionaryMenu(aTarget) {
+ InitLanguageMenu();
+
+ for (let item of aTarget.children) {
+ item.setAttribute(
+ "checked",
+ gActiveDictionaries.has(item.getAttribute("value"))
+ );
+ }
+}
+
+function languageMenuListOpened() {
+ document
+ .getElementById("languageStatusButton")
+ .setAttribute("aria-expanded", "true");
+}
+
+function languageMenuListClosed() {
+ document
+ .getElementById("languageStatusButton")
+ .setAttribute("aria-expanded", "false");
+}
+
+/**
+ * Set of the active dictionaries. We maintain this cached state so we don't
+ * need a spell checker instance to know the active dictionaries. This is
+ * especially relevant when inline spell checking is disabled.
+ *
+ * @type {Set<string>}
+ */
+var gActiveDictionaries = new Set();
+/**
+ * Change the language of the composition and if we are using inline
+ * spell check, recheck the message with the new dictionary.
+ *
+ * Note: called from the "Check Spelling" panel in SelectLanguage().
+ *
+ * @param {string[]} languages - New languages to set.
+ */
+async function ComposeChangeLanguage(languages) {
+ let currentLanguage = document.documentElement.getAttribute("lang");
+ if (
+ (languages.length === 1 && currentLanguage != languages[0]) ||
+ languages.length !== 1
+ ) {
+ let languageToSet = "";
+ if (languages.length === 1) {
+ languageToSet = languages[0];
+ }
+ // Update the document language as well.
+ document.documentElement.setAttribute("lang", languageToSet);
+ }
+
+ await gSpellChecker?.selectDictionaries(languages);
+
+ let checker = GetCurrentEditorSpellChecker();
+ if (checker?.spellChecker) {
+ await checker.spellChecker.setCurrentDictionaries(languages);
+ }
+ // Update subject spell checker languages. If for some reason the spell
+ // checker isn't ready yet, don't auto-create it, hence pass 'false'.
+ let subjectSpellChecker = checker?.spellChecker
+ ? document.getElementById("msgSubject").editor.getInlineSpellChecker(false)
+ : null;
+ if (subjectSpellChecker?.spellChecker) {
+ await subjectSpellChecker.spellChecker.setCurrentDictionaries(languages);
+ }
+
+ // now check the document over again with the new dictionary
+ if (gSpellCheckingEnabled) {
+ if (checker?.spellChecker) {
+ checker.spellCheckRange(null);
+ }
+
+ if (subjectSpellChecker?.spellChecker) {
+ // Also force a recheck of the subject.
+ subjectSpellChecker.spellCheckRange(null);
+ }
+ }
+
+ await updateLanguageInStatusBar(languages);
+
+ // Update the language in the composition fields, so we can save it
+ // to the draft next time.
+ if (gMsgCompose?.compFields) {
+ let langs = "";
+ if (!Services.prefs.getBoolPref("mail.suppress_content_language")) {
+ langs = languages.join(", ");
+ }
+ gMsgCompose.compFields.contentLanguage = langs;
+ }
+
+ gActiveDictionaries = new Set(languages);
+
+ // Notify compose WebExtension API about changed dictionaries.
+ window.dispatchEvent(
+ new CustomEvent("active-dictionaries-changed", {
+ detail: languages.join(","),
+ })
+ );
+}
+
+/**
+ * Change the language of the composition and if we are using inline
+ * spell check, recheck the message with the new dictionary.
+ *
+ * @param {Event} event - Event of selecting an item in the spelling button
+ * menulist popup.
+ */
+function ChangeLanguage(event) {
+ let curLangs = new Set(gActiveDictionaries);
+ if (curLangs.has(event.target.value)) {
+ curLangs.delete(event.target.value);
+ } else {
+ curLangs.add(event.target.value);
+ }
+ ComposeChangeLanguage(Array.from(curLangs));
+ event.stopPropagation();
+}
+
+/**
+ * Update the active dictionaries in the status bar.
+ *
+ * @param {string[]} dictionaries
+ */
+async function updateLanguageInStatusBar(dictionaries) {
+ // HACK: calling sortDictionaryList (in InitLanguageMenu) may fail the first
+ // time due to synchronous loading of the .ftl files. If we load the files
+ // and wait for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+
+ InitLanguageMenu();
+ let languageMenuList = document.getElementById("languageMenuList");
+ let languageStatusButton = document.getElementById("languageStatusButton");
+ if (!languageMenuList || !languageStatusButton) {
+ return;
+ }
+
+ if (!dictionaries) {
+ dictionaries = Array.from(gActiveDictionaries);
+ }
+ let listFormat = new Intl.ListFormat(undefined, {
+ type: "conjunction",
+ style: "short",
+ });
+ let languages = [];
+ let item = languageMenuList.firstElementChild;
+
+ // No status display, if there is only one or no spelling dictionary available.
+ if (languageMenuList.childElementCount <= 3) {
+ languageStatusButton.hidden = true;
+ languageStatusButton.textContent = "";
+ return;
+ }
+
+ languageStatusButton.hidden = false;
+ while (item) {
+ if (item.tagName.toLowerCase() === "menuseparator") {
+ break;
+ }
+ if (dictionaries.includes(item.getAttribute("value"))) {
+ languages.push(item.getAttribute("label"));
+ }
+ item = item.nextElementSibling;
+ }
+ if (languages.length > 0) {
+ languageStatusButton.textContent = listFormat.format(languages);
+ } else {
+ languageStatusButton.textContent = listFormat.format(dictionaries);
+ }
+}
+
+/**
+ * Toggle Return Receipt (Disposition-Notification-To: header).
+ *
+ * @param {boolean} [forcedState] - Forced state to use for returnReceipt.
+ * If not set, the current state will be toggled.
+ */
+function ToggleReturnReceipt(forcedState) {
+ let msgCompFields = gMsgCompose.compFields;
+ if (!msgCompFields) {
+ return;
+ }
+ if (forcedState === undefined) {
+ msgCompFields.returnReceipt = !msgCompFields.returnReceipt;
+ gReceiptOptionChanged = true;
+ } else {
+ if (msgCompFields.returnReceipt != forcedState) {
+ gReceiptOptionChanged = true;
+ }
+ msgCompFields.returnReceipt = forcedState;
+ }
+ for (let item of document.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"],
+ toolbarbutton[command="cmd_toggleReturnReceipt"]`)) {
+ item.setAttribute("checked", msgCompFields.returnReceipt);
+ }
+}
+
+function ToggleDSN(target) {
+ let msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields) {
+ msgCompFields.DSN = !msgCompFields.DSN;
+ target.setAttribute("checked", msgCompFields.DSN);
+ gDSNOptionChanged = true;
+ }
+}
+
+function ToggleAttachVCard(target) {
+ var msgCompFields = gMsgCompose.compFields;
+ if (msgCompFields) {
+ msgCompFields.attachVCard = !msgCompFields.attachVCard;
+ target.setAttribute("checked", msgCompFields.attachVCard);
+ gAttachVCardOptionChanged = true;
+ }
+}
+
+/**
+ * Toggles or sets the status of manual Attachment Reminder, i.e. whether
+ * the user will get the "Attachment Reminder" alert before sending or not.
+ * Toggles checkmark on "Remind me later" menuitem and internal
+ * gManualAttachmentReminder flag accordingly.
+ *
+ * @param aState (optional) true = activate reminder.
+ * false = deactivate reminder.
+ * (default) = toggle reminder state.
+ */
+function toggleAttachmentReminder(aState = !gManualAttachmentReminder) {
+ gManualAttachmentReminder = aState;
+ document.getElementById("cmd_remindLater").setAttribute("checked", aState);
+ gMsgCompose.compFields.attachmentReminder = aState;
+
+ // If we enabled manual reminder, the reminder can't be turned off.
+ if (aState) {
+ gDisableAttachmentReminder = false;
+ }
+
+ manageAttachmentNotification(false);
+}
+
+/**
+ * Triggers or removes the CSS animation for the counter of newly uploaded
+ * attachments.
+ */
+function toggleAttachmentAnimation() {
+ gAttachmentCounter.classList.toggle("is_animating");
+}
+
+function FillIdentityList(menulist) {
+ let accounts = FolderUtils.allAccountsSorted(true);
+
+ let accountHadSeparator = false;
+ let firstAccountWithIdentities = true;
+ for (let account of accounts) {
+ let identities = account.identities;
+
+ if (identities.length == 0) {
+ continue;
+ }
+
+ let needSeparator = identities.length > 1;
+ if (needSeparator || accountHadSeparator) {
+ // Separate identities from this account from the previous
+ // account's identities if there is more than 1 in the current
+ // or previous account.
+ if (!firstAccountWithIdentities) {
+ // only if this is not the first account shown
+ let separator = document.createXULElement("menuseparator");
+ menulist.menupopup.appendChild(separator);
+ }
+ accountHadSeparator = needSeparator;
+ }
+ firstAccountWithIdentities = false;
+
+ for (let i = 0; i < identities.length; i++) {
+ let identity = identities[i];
+ let item = menulist.appendItem(
+ identity.identityName,
+ identity.fullAddress,
+ account.incomingServer.prettyName
+ );
+ item.setAttribute("identitykey", identity.key);
+ item.setAttribute("accountkey", account.key);
+ if (i == 0) {
+ // Mark the first identity as default.
+ item.setAttribute("default", "true");
+ }
+ // Create the menuitem description and add it after the last label in the
+ // menuitem internals.
+ let desc = document.createXULElement("label");
+ desc.value = item.getAttribute("description");
+ desc.classList.add("menu-description");
+ desc.setAttribute("crop", "end");
+ item.querySelector("label:last-child").after(desc);
+ }
+ }
+
+ menulist.menupopup.appendChild(document.createXULElement("menuseparator"));
+ menulist.menupopup
+ .appendChild(document.createXULElement("menuitem"))
+ .setAttribute("command", "cmd_customizeFromAddress");
+}
+
+function getCurrentAccountKey() {
+ // Get the account's key.
+ let identityList = document.getElementById("msgIdentity");
+ return identityList.getAttribute("accountkey");
+}
+
+function getCurrentIdentityKey() {
+ // Get the identity key.
+ return gCurrentIdentity.key;
+}
+
+function AdjustFocus() {
+ // If is NNTP account, check the newsgroup field.
+ let account = MailServices.accounts.getAccount(getCurrentAccountKey());
+ let accountType = account.incomingServer.type;
+
+ let element =
+ accountType == "nntp"
+ ? document.getElementById("newsgroupsAddrContainer")
+ : document.getElementById("toAddrContainer");
+
+ // Focus on the recipient input field if no pills are present.
+ if (element.querySelectorAll("mail-address-pill").length == 0) {
+ element.querySelector(".address-row-input").focus();
+ return;
+ }
+
+ // Focus subject if empty.
+ element = document.getElementById("msgSubject");
+ if (element.value == "") {
+ element.focus();
+ return;
+ }
+
+ // Focus message body.
+ focusMsgBody();
+}
+
+/**
+ * Set the compose window title with flavors (Write | Print Preview).
+ *
+ * @param isPrintPreview (optional) true: Set title for 'Print Preview' window.
+ * false: Set title for 'Write' window (default).
+ */
+function SetComposeWindowTitle(isPrintPreview = false) {
+ let aStringName = isPrintPreview
+ ? "windowTitlePrintPreview"
+ : "windowTitleWrite";
+ let subject =
+ document.getElementById("msgSubject").value.trim() ||
+ getComposeBundle().getString("defaultSubject");
+ let brandBundle = document.getElementById("brandBundle");
+ let brandShortName = brandBundle.getString("brandShortName");
+ let newTitle = getComposeBundle().getFormattedString(aStringName, [
+ subject,
+ brandShortName,
+ ]);
+ document.title = newTitle;
+ if (AppConstants.platform == "macosx") {
+ document.getElementById("titlebar-title-label").value = newTitle;
+ }
+}
+
+// Check for changes to document and allow saving before closing
+// This is hooked up to the OS's window close widget (e.g., "X" for Windows)
+function ComposeCanClose() {
+ // No open compose window?
+ if (!gMsgCompose) {
+ return true;
+ }
+
+ // Do this early, so ldap sessions have a better chance to
+ // cleanup after themselves.
+ if (gSendOperationInProgress || gSaveOperationInProgress) {
+ let result;
+
+ let brandBundle = document.getElementById("brandBundle");
+ let brandShortName = brandBundle.getString("brandShortName");
+ let promptTitle = gSendOperationInProgress
+ ? getComposeBundle().getString("quitComposeWindowTitle")
+ : getComposeBundle().getString("quitComposeWindowSaveTitle");
+ let promptMsg = gSendOperationInProgress
+ ? getComposeBundle().getFormattedString(
+ "quitComposeWindowMessage2",
+ [brandShortName],
+ 1
+ )
+ : getComposeBundle().getFormattedString(
+ "quitComposeWindowSaveMessage",
+ [brandShortName],
+ 1
+ );
+ let quitButtonLabel = getComposeBundle().getString(
+ "quitComposeWindowQuitButtonLabel2"
+ );
+ let waitButtonLabel = getComposeBundle().getString(
+ "quitComposeWindowWaitButtonLabel2"
+ );
+
+ result = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMsg,
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ waitButtonLabel,
+ quitButtonLabel,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (result == 1) {
+ gMsgCompose.abort();
+ return true;
+ }
+ return false;
+ }
+
+ // Returns FALSE only if user cancels save action
+ if (
+ gContentChanged ||
+ gMsgCompose.bodyModified ||
+ gAutoSaveKickedIn ||
+ gReceiptOptionChanged ||
+ gDSNOptionChanged
+ ) {
+ // call window.focus, since we need to pop up a dialog
+ // and therefore need to be visible (to prevent user confusion)
+ window.focus();
+ let draftFolderURI = gCurrentIdentity.draftFolder;
+ let draftFolderName =
+ MailUtils.getOrCreateFolder(draftFolderURI).prettyName;
+ let result = Services.prompt.confirmEx(
+ window,
+ getComposeBundle().getString("saveDlogTitle"),
+ getComposeBundle().getFormattedString("saveDlogMessages3", [
+ draftFolderName,
+ ]),
+ Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_2,
+ null,
+ null,
+ getComposeBundle().getString("discardButtonLabel"),
+ null,
+ { value: 0 }
+ );
+ switch (result) {
+ case 0: // Save
+ // Since we're going to save the message, we tell toolkit that
+ // the close command failed, by returning false, and then
+ // we close the window ourselves after the save is done.
+ gCloseWindowAfterSave = true;
+ // We catch the exception because we need to tell toolkit that it
+ // shouldn't close the window, because we're going to close it
+ // ourselves. If we don't tell toolkit that, and then close the window
+ // ourselves, the toolkit code that keeps track of the open windows
+ // gets off by one and the app can close unexpectedly on os's that
+ // shutdown the app when the last window is closed.
+ GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft).catch(
+ console.error
+ );
+ return false;
+ case 1: // Cancel
+ return false;
+ case 2: // Don't Save
+ // don't delete the draft if we didn't start off editing a draft
+ // and the user hasn't explicitly saved it.
+ if (!gEditingDraft && gAutoSaveKickedIn) {
+ RemoveDraft();
+ }
+ // Remove auto-saved draft created during "edit template".
+ if (gMsgCompose.compFields.templateId && gAutoSaveKickedIn) {
+ RemoveDraft();
+ }
+ break;
+ }
+ }
+
+ return true;
+}
+
+function RemoveDraft() {
+ try {
+ var draftUri = gMsgCompose.compFields.draftId;
+ var msgKey = draftUri.substr(draftUri.indexOf("#") + 1);
+ let folder = MailUtils.getExistingFolder(gMsgCompose.savedFolderURI);
+ if (!folder) {
+ return;
+ }
+ try {
+ if (folder.getFlag(Ci.nsMsgFolderFlags.Drafts)) {
+ let msgHdr = folder.GetMessageHeader(msgKey);
+ folder.deleteMessages([msgHdr], null, true, false, null, false);
+ }
+ } catch (ex) {
+ // couldn't find header - perhaps an imap folder.
+ var imapFolder = folder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ if (imapFolder) {
+ imapFolder.storeImapFlags(
+ Ci.nsMsgFolderFlags.Expunged,
+ true,
+ [msgKey],
+ null
+ );
+ }
+ }
+ } catch (ex) {}
+}
+
+function SetContentAndBodyAsUnmodified() {
+ gMsgCompose.bodyModified = false;
+ gContentChanged = false;
+}
+
+function MsgComposeCloseWindow() {
+ if (gMsgCompose) {
+ gMsgCompose.CloseWindow();
+ } else {
+ window.close();
+ }
+}
+
+function GetLastAttachDirectory() {
+ var lastDirectory;
+
+ try {
+ lastDirectory = Services.prefs.getComplexValue(
+ kComposeAttachDirPrefName,
+ Ci.nsIFile
+ );
+ } catch (ex) {
+ // this will fail the first time we attach a file
+ // as we won't have a pref value.
+ lastDirectory = null;
+ }
+
+ return lastDirectory;
+}
+
+// attachedLocalFile must be a nsIFile
+function SetLastAttachDirectory(attachedLocalFile) {
+ try {
+ let file = attachedLocalFile.QueryInterface(Ci.nsIFile);
+ let parent = file.parent.QueryInterface(Ci.nsIFile);
+
+ Services.prefs.setComplexValue(
+ kComposeAttachDirPrefName,
+ Ci.nsIFile,
+ parent
+ );
+ } catch (ex) {
+ dump("error: SetLastAttachDirectory failed: " + ex + "\n");
+ }
+}
+
+function AttachFile() {
+ if (gAttachmentBucket.itemCount) {
+ // If there are existing attachments already, restore attachment pane before
+ // showing the file picker so that user can see them while adding more.
+ toggleAttachmentPane("show");
+ }
+
+ // Get file using nsIFilePicker and convert to URL
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(
+ window,
+ getComposeBundle().getString("chooseFileToAttach"),
+ Ci.nsIFilePicker.modeOpenMultiple
+ );
+
+ let lastDirectory = GetLastAttachDirectory();
+ if (lastDirectory) {
+ fp.displayDirectory = lastDirectory;
+ }
+
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.files) {
+ return;
+ }
+
+ let file;
+ let attachments = [];
+
+ for (file of [...fp.files]) {
+ attachments.push(FileToAttachment(file));
+ }
+
+ AddAttachments(attachments);
+ SetLastAttachDirectory(file);
+ });
+}
+
+/**
+ * Convert an nsIFile instance into an nsIMsgAttachment.
+ *
+ * @param file the nsIFile
+ * @returns an attachment pointing to the file
+ */
+function FileToAttachment(file) {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ attachment.url = fileHandler.getURLSpecFromActualFile(file);
+ attachment.size = file.fileSize;
+ return attachment;
+}
+
+async function messageAttachmentToFile(attachment) {
+ let pathTempDir = PathUtils.join(
+ PathUtils.tempDir,
+ "pid-" + Services.appinfo.processID
+ );
+ await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 });
+ let pathTempFile = await IOUtils.createUniqueFile(
+ pathTempDir,
+ attachment.name.replaceAll(/[/:*?\"<>|]/g, "_"),
+ 0o600
+ );
+ let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tempFile.initWithPath(pathTempFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let service = MailServices.messageServiceFromURI(attachment.url);
+ let bytes = await new Promise((resolve, reject) => {
+ let streamlistener = {
+ _data: [],
+ _stream: null,
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ if (!this._stream) {
+ this._stream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ this._stream.init(aInputStream);
+ }
+ this._data.push(this._stream.read(aCount));
+ },
+ onStartRequest() {},
+ onStopRequest(aRequest, aStatus) {
+ if (aStatus == Cr.NS_OK) {
+ resolve(this._data.join(""));
+ } else {
+ console.error(aStatus);
+ reject();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ service.streamMessage(
+ attachment.url,
+ streamlistener,
+ null, // aMsgWindow
+ null, // aUrlListener
+ false, // aConvertData
+ "" //aAdditionalHeader
+ );
+ });
+ await IOUtils.write(
+ pathTempFile,
+ lazy.MailStringUtils.byteStringToUint8Array(bytes)
+ );
+ return tempFile;
+}
+
+/**
+ * Add a list of attachment objects as attachments. The attachment URLs must
+ * be set.
+ *
+ * @param {nsIMsgAttachment[]} aAttachments - Objects to add as attachments.
+ * @param {boolean} [aContentChanged=true] - Optional value to assign gContentChanged
+ * after adding attachments.
+ */
+async function AddAttachments(aAttachments, aContentChanged = true) {
+ let addedAttachments = [];
+ let items = [];
+
+ for (let attachment of aAttachments) {
+ if (!attachment?.url || DuplicateFileAlreadyAttached(attachment)) {
+ continue;
+ }
+
+ if (!attachment.name) {
+ attachment.name = gMsgCompose.AttachmentPrettyName(attachment.url, null);
+ }
+
+ // For security reasons, don't allow *-message:// uris to leak out.
+ // We don't want to reveal the .slt path (for mailbox://), or the username
+ // or hostname.
+ // Don't allow file or mail/news protocol uris to leak out either.
+ if (
+ /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.name)
+ ) {
+ attachment.name = getComposeBundle().getString(
+ "messageAttachmentSafeName"
+ );
+ } else if (/^file:|^mailbox:|^imap:|^s?news:/i.test(attachment.name)) {
+ attachment.name = getComposeBundle().getString("partAttachmentSafeName");
+ }
+
+ // Create temporary files for message attachments.
+ if (
+ /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.url)
+ ) {
+ try {
+ let messageFile = await messageAttachmentToFile(attachment);
+ // Store the original mailbox:// url in contentLocation.
+ attachment.contentLocation = attachment.url;
+ attachment.url = Services.io.newFileURI(messageFile).spec;
+ } catch (ex) {
+ console.error(
+ `Could not save message attachment ${attachment.url} as file: ${ex}`
+ );
+ }
+ }
+
+ if (
+ attachment.msgUri &&
+ /^mailbox-message:|^imap-message:|^news-message:/i.test(
+ attachment.msgUri
+ ) &&
+ attachment.url &&
+ /^mailbox:|^imap:|^s?news:/i.test(attachment.url)
+ ) {
+ // This is an attachment of another message, create a temporary file and
+ // update the url.
+ let pathTempDir = PathUtils.join(
+ PathUtils.tempDir,
+ "pid-" + Services.appinfo.processID
+ );
+ await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 });
+ let tempDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tempDir.initWithPath(pathTempDir);
+
+ let tempFile = gMessenger.saveAttachmentToFolder(
+ attachment.contentType,
+ attachment.url,
+ encodeURIComponent(attachment.name),
+ attachment.msgUri,
+ tempDir
+ );
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+ // Store the original mailbox:// url in contentLocation.
+ attachment.contentLocation = attachment.url;
+ attachment.url = Services.io.newFileURI(tempFile).spec;
+ }
+
+ let item = gAttachmentBucket.appendItem(attachment);
+ addedAttachments.push(attachment);
+
+ let tooltiptext;
+ try {
+ tooltiptext = decodeURI(attachment.url);
+ } catch {
+ tooltiptext = attachment.url;
+ }
+ item.setAttribute("tooltiptext", tooltiptext);
+ item.addEventListener("command", OpenSelectedAttachment);
+ items.push(item);
+ }
+
+ if (addedAttachments.length > 0) {
+ // Trigger a visual feedback to let the user know how many attachments have
+ // been added.
+ gAttachmentCounter.textContent = `+${addedAttachments.length}`;
+ toggleAttachmentAnimation();
+
+ // Move the focus on the last attached file so the user can see a visual
+ // feedback of what was added.
+ gAttachmentBucket.selectedIndex = gAttachmentBucket.getIndexOfItem(
+ items[items.length - 1]
+ );
+
+ // Ensure the selected item is visible and if not the box will scroll to it.
+ gAttachmentBucket.ensureIndexIsVisible(gAttachmentBucket.selectedIndex);
+
+ AttachmentsChanged("show", aContentChanged);
+ dispatchAttachmentBucketEvent("attachments-added", addedAttachments);
+
+ // Set min height for the attachment bucket.
+ if (!gAttachmentBucket.style.minHeight) {
+ // Min height is the height of the first child plus padding and border.
+ // Note: we assume the computed styles have px values.
+ let bucketStyle = getComputedStyle(gAttachmentBucket);
+ let childStyle = getComputedStyle(gAttachmentBucket.firstChild);
+ let minHeight =
+ gAttachmentBucket.firstChild.getBoundingClientRect().height +
+ parseFloat(childStyle.marginBlockStart) +
+ parseFloat(childStyle.marginBlockEnd) +
+ parseFloat(bucketStyle.paddingBlockStart) +
+ parseFloat(bucketStyle.paddingBlockEnd) +
+ parseFloat(bucketStyle.borderBlockStartWidth) +
+ parseFloat(bucketStyle.borderBlockEndWidth);
+ gAttachmentBucket.style.minHeight = `${minHeight}px`;
+ }
+ }
+
+ // Always show the attachment pane if we have any attachment, to prevent
+ // keeping the panel collapsed when the user interacts with the attachment
+ // button.
+ if (gAttachmentBucket.itemCount) {
+ toggleAttachmentPane("show");
+ }
+
+ return items;
+}
+
+/**
+ * Returns a sorted-by-index, "non-live" array of attachment list items.
+ *
+ * @param aAscending {boolean}: true (default): sort return array ascending
+ * false : sort return array descending
+ * @param aSelectedOnly {boolean}: true: return array of selected items only.
+ * false (default): return array of all items.
+ *
+ * @returns {Array} an array of (all | selected) listItem elements in
+ * attachmentBucket listbox, "non-live" and sorted by their index
+ * in the list; [] if there are (no | no selected) attachments.
+ */
+function attachmentsGetSortedArray(aAscending = true, aSelectedOnly = false) {
+ let listItems;
+
+ if (aSelectedOnly) {
+ // Selected attachments only.
+ if (!gAttachmentBucket.selectedCount) {
+ return [];
+ }
+
+ // gAttachmentBucket.selectedItems is a "live" and "unordered" node list
+ // (items get added in the order they were added to the selection). But we
+ // want a stable ("non-live") array of selected items, sorted by their index
+ // in the list.
+ listItems = [...gAttachmentBucket.selectedItems];
+ } else {
+ // All attachments.
+ if (!gAttachmentBucket.itemCount) {
+ return [];
+ }
+
+ listItems = [...gAttachmentBucket.itemChildren];
+ }
+
+ if (aAscending) {
+ listItems.sort(
+ (a, b) =>
+ gAttachmentBucket.getIndexOfItem(a) -
+ gAttachmentBucket.getIndexOfItem(b)
+ );
+ } else {
+ // descending
+ listItems.sort(
+ (a, b) =>
+ gAttachmentBucket.getIndexOfItem(b) -
+ gAttachmentBucket.getIndexOfItem(a)
+ );
+ }
+ return listItems;
+}
+
+/**
+ * Returns a sorted-by-index, "non-live" array of selected attachment list items.
+ *
+ * @param aAscending {boolean}: true (default): sort return array ascending
+ * false : sort return array descending
+ * @returns {Array} an array of selected listitem elements in attachmentBucket
+ * listbox, "non-live" and sorted by their index in the list;
+ * [] if no attachments selected
+ */
+function attachmentsSelectionGetSortedArray(aAscending = true) {
+ return attachmentsGetSortedArray(aAscending, true);
+}
+
+/**
+ * Return true if the selected attachment items are a coherent block in the list,
+ * otherwise false.
+ *
+ * @param aListPosition (optional) - "top" : Return true only if the block is
+ * at the top of the list.
+ * "bottom": Return true only if the block is
+ * at the bottom of the list.
+ * @returns {boolean} true : The selected attachment items are a coherent block
+ * (at the list edge if/as specified by 'aListPosition'),
+ * or only 1 item selected.
+ * false: The selected attachment items are NOT a coherent block
+ * (at the list edge if/as specified by 'aListPosition'),
+ * or no attachments selected, or no attachments,
+ * or no attachmentBucket.
+ */
+function attachmentsSelectionIsBlock(aListPosition) {
+ if (!gAttachmentBucket.selectedCount) {
+ // No attachments selected, no attachments, or no attachmentBucket.
+ return false;
+ }
+
+ let selItems = attachmentsSelectionGetSortedArray();
+ let indexFirstSelAttachment = gAttachmentBucket.getIndexOfItem(selItems[0]);
+ let indexLastSelAttachment = gAttachmentBucket.getIndexOfItem(
+ selItems[gAttachmentBucket.selectedCount - 1]
+ );
+ let isBlock =
+ indexFirstSelAttachment ==
+ indexLastSelAttachment + 1 - gAttachmentBucket.selectedCount;
+
+ switch (aListPosition) {
+ case "top":
+ // True if selection is a coherent block at the top of the list.
+ return indexFirstSelAttachment == 0 && isBlock;
+ case "bottom":
+ // True if selection is a coherent block at the bottom of the list.
+ return (
+ indexLastSelAttachment == gAttachmentBucket.itemCount - 1 && isBlock
+ );
+ default:
+ // True if selection is a coherent block.
+ return isBlock;
+ }
+}
+
+function AttachPage() {
+ let result = { value: "http://" };
+ if (
+ Services.prompt.prompt(
+ window,
+ getComposeBundle().getString("attachPageDlogTitle"),
+ getComposeBundle().getString("attachPageDlogMessage"),
+ result,
+ null,
+ { value: 0 }
+ )
+ ) {
+ if (result.value.length <= "http://".length) {
+ // Nothing filled, just show the dialog again.
+ AttachPage();
+ return;
+ }
+
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.url = result.value;
+ AddAttachments([attachment]);
+ }
+}
+
+/**
+ * Check if the given attachment already exists in the attachment bucket.
+ *
+ * @param nsIMsgAttachment - the attachment to check
+ * @returns true if the attachment is already attached
+ */
+function DuplicateFileAlreadyAttached(attachment) {
+ for (let item of gAttachmentBucket.itemChildren) {
+ if (item.attachment && item.attachment.url) {
+ if (item.attachment.url == attachment.url) {
+ return true;
+ }
+ // Also check, if an attachment has been saved as a temporary file and its
+ // original url is a match.
+ if (
+ item.attachment.contentLocation &&
+ item.attachment.contentLocation == attachment.url
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+function Attachments2CompFields(compFields) {
+ // First, we need to clear all attachment in the compose fields.
+ compFields.removeAttachments();
+
+ for (let item of gAttachmentBucket.itemChildren) {
+ if (item.attachment) {
+ compFields.addAttachment(item.attachment);
+ }
+ }
+}
+
+async function RemoveAllAttachments() {
+ // Ensure that attachment pane is shown before removing all attachments.
+ toggleAttachmentPane("show");
+
+ if (!gAttachmentBucket.itemCount) {
+ return;
+ }
+
+ await RemoveAttachments(gAttachmentBucket.itemChildren);
+}
+
+/**
+ * Show or hide the attachment pane after updating its header bar information
+ * (number and total file size of attachments) and tooltip.
+ *
+ * @param aShowBucket {Boolean} true: show the attachment pane
+ * false (or omitted): hide the attachment pane
+ */
+function UpdateAttachmentBucket(aShowBucket) {
+ updateAttachmentPane(aShowBucket ? "show" : "hide");
+}
+
+/**
+ * Update the header bar information (number and total file size of attachments)
+ * and tooltip of attachment pane, then (optionally) show or hide the pane.
+ *
+ * @param aShowPane {string} "show": show the attachment pane
+ * "hide": hide the attachment pane
+ * omitted: just update without changing pane visibility
+ */
+function updateAttachmentPane(aShowPane) {
+ let count = gAttachmentBucket.itemCount;
+
+ document.l10n.setAttributes(
+ document.getElementById("attachmentBucketCount"),
+ "attachment-bucket-count-value",
+ {
+ count,
+ }
+ );
+
+ let attachmentsSize = 0;
+ for (let item of gAttachmentBucket.itemChildren) {
+ gAttachmentBucket.invalidateItem(item);
+ attachmentsSize += item.cloudHtmlFileSize
+ ? item.cloudHtmlFileSize
+ : item.attachment.size;
+ }
+
+ document.getElementById("attachmentBucketSize").textContent =
+ count > 0 ? gMessenger.formatFileSize(attachmentsSize) : "";
+
+ document
+ .getElementById("composeContentBox")
+ .classList.toggle("attachment-area-hidden", !count);
+
+ attachmentBucketUpdateTooltips();
+
+ // If aShowPane argument is omitted, it's just updating, so we're done.
+ if (aShowPane === undefined) {
+ return;
+ }
+
+ // Otherwise, show or hide the panel per aShowPane argument.
+ toggleAttachmentPane(aShowPane);
+}
+
+async function RemoveSelectedAttachment() {
+ if (!gAttachmentBucket.selectedCount) {
+ return;
+ }
+
+ await RemoveAttachments(gAttachmentBucket.selectedItems);
+}
+
+/**
+ * Removes the provided attachmentItems from the composer and deletes all
+ * associated cloud files.
+ *
+ * Note: Cloud file delete errors are not considered to be fatal errors. They do
+ * not prevent the attachments from being removed from the composer. Such
+ * errors are caught and logged to the console.
+ *
+ * @param {DOMNode[]} items - AttachmentItems to be removed
+ */
+async function RemoveAttachments(items) {
+ // Remember the current focus index so we can try to restore it when done.
+ let focusIndex = gAttachmentBucket.currentIndex;
+
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let removedAttachments = [];
+
+ let promises = [];
+ for (let i = items.length - 1; i >= 0; i--) {
+ let item = items[i];
+
+ if (item.attachment.sendViaCloud && item.cloudFileAccount) {
+ if (item.uploading) {
+ let file = fileHandler.getFileFromURLSpec(item.attachment.url);
+ promises.push(
+ item.uploading
+ .cancelFileUpload(window, file)
+ .catch(ex => console.warn(ex.message))
+ );
+ } else {
+ promises.push(
+ item.cloudFileAccount
+ .deleteFile(window, item.cloudFileUpload.id)
+ .catch(ex => console.warn(ex.message))
+ );
+ }
+ }
+
+ removedAttachments.push(item.attachment);
+ // Let's release the attachment object held by the node else it won't go
+ // away until the window is destroyed
+ item.attachment = null;
+ item.remove();
+ }
+
+ if (removedAttachments.length > 0) {
+ // Bug 1661507 workaround: Force update of selectedCount and selectedItem,
+ // both wrong after item removal, to avoid confusion for listening command
+ // controllers.
+ gAttachmentBucket.clearSelection();
+
+ AttachmentsChanged();
+ dispatchAttachmentBucketEvent("attachments-removed", removedAttachments);
+ }
+
+ // Collapse the attachment container if all the items have been deleted.
+ if (!gAttachmentBucket.itemCount) {
+ toggleAttachmentPane("hide");
+ } else {
+ // Try to restore the original focused item or somewhere close by.
+ gAttachmentBucket.currentIndex =
+ focusIndex < gAttachmentBucket.itemCount
+ ? focusIndex
+ : gAttachmentBucket.itemCount - 1;
+ }
+
+ await Promise.all(promises);
+}
+
+async function RenameSelectedAttachment() {
+ if (gAttachmentBucket.selectedItems.length != 1) {
+ // Not one attachment selected.
+ return;
+ }
+
+ let item = gAttachmentBucket.getSelectedItem(0);
+ let originalName = item.attachment.name;
+ let attachmentName = { value: originalName };
+ if (
+ Services.prompt.prompt(
+ window,
+ getComposeBundle().getString("renameAttachmentTitle"),
+ getComposeBundle().getString("renameAttachmentMessage"),
+ attachmentName,
+ null,
+ { value: 0 }
+ )
+ ) {
+ if (attachmentName.value == "" || attachmentName.value == originalName) {
+ // Name was not filled nor changed, bail out.
+ return;
+ }
+ try {
+ await UpdateAttachment(item, {
+ name: attachmentName.value,
+ relatedCloudFileUpload: item.CloudFileUpload,
+ });
+ } catch (ex) {
+ showLocalizedCloudFileAlert(ex);
+ }
+ }
+}
+
+/* eslint-disable complexity */
+/**
+ * Move selected attachment(s) within the attachment list.
+ *
+ * @param {string} aDirection - The direction in which to move the attachments.
+ * "left" : Move attachments left in the list.
+ * "right" : Move attachments right in the list.
+ * "top" : Move attachments to the top of the list.
+ * "bottom" : Move attachments to the bottom of the list.
+ * "bundleUp" : Move attachments together (upwards).
+ * "bundleDown": Move attachments together (downwards).
+ * "toggleSort": Sort attachments alphabetically (toggle).
+ */
+function moveSelectedAttachments(aDirection) {
+ // Command controllers will bail out if no or all attachments are selected,
+ // or if block selections can't be moved, or if other direction-specific
+ // adverse circumstances prevent the intended movement.
+ if (!aDirection) {
+ return;
+ }
+
+ // Ensure focus on gAttachmentBucket when we're coming from
+ // 'Reorder Attachments' panel.
+ gAttachmentBucket.focus();
+
+ // Get a sorted and "non-live" array of gAttachmentBucket.selectedItems.
+ let selItems = attachmentsSelectionGetSortedArray();
+
+ // In case of misspelled aDirection.
+ let visibleIndex = gAttachmentBucket.currentIndex;
+ // Keep track of the item we had focused originally. Deselect it though,
+ // since listbox gets confused if you move its focused item around.
+ let focusItem = gAttachmentBucket.currentItem;
+ gAttachmentBucket.currentItem = null;
+ let upwards;
+ let targetItem;
+
+ switch (aDirection) {
+ case "left":
+ case "right":
+ // Move selected attachments upwards/downwards.
+ upwards = aDirection == "left";
+ let blockItems = [];
+
+ for (let item of selItems) {
+ // Handle adjacent selected items en block, via blockItems array.
+ blockItems.push(item); // Add current selItem to blockItems.
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // If current selItem is the last blockItem, check out its adjacent
+ // item in the intended direction to see if there's room for moving.
+ // Note that the block might contain one or more items.
+ let checkItem = upwards
+ ? blockItems[0].previousElementSibling
+ : nextItem;
+ // If block-adjacent checkItem exists (and is not selected because
+ // then it would be part of the block), we can move the block to the
+ // right position.
+ if (checkItem) {
+ targetItem = upwards
+ ? // Upwards: Insert block items before checkItem,
+ // i.e. before previousElementSibling of block.
+ checkItem
+ : // Downwards: Insert block items *after* checkItem,
+ // i.e. *before* nextElementSibling.nextElementSibling of block,
+ // which works according to spec even if that's null.
+ checkItem.nextElementSibling;
+ // Move current blockItems.
+ for (let blockItem of blockItems) {
+ gAttachmentBucket.insertBefore(blockItem, targetItem);
+ }
+ }
+ // Else if checkItem doesn't exist, the block is already at the edge
+ // of the list, so we can't move it in the intended direction.
+ blockItems.length = 0; // Either way, we're done with the current block.
+ }
+ // Else if current selItem is NOT the end of the current block, proceed:
+ // Add next selItem to the block and see if that's the end of the block.
+ } // Next selItem.
+
+ // Ensure helpful visibility of moved items (scroll into view if needed):
+ // If first item of selection is now at the top, first list item.
+ // Else if last item of selection is now at the bottom, last list item.
+ // Otherwise, let's see where we are going by ensuring visibility of the
+ // nearest unselected sibling of selection according to direction of move.
+ if (gAttachmentBucket.getIndexOfItem(selItems[0]) == 0) {
+ visibleIndex = 0;
+ } else if (
+ gAttachmentBucket.getIndexOfItem(selItems[selItems.length - 1]) ==
+ gAttachmentBucket.itemCount - 1
+ ) {
+ visibleIndex = gAttachmentBucket.itemCount - 1;
+ } else if (upwards) {
+ visibleIndex = gAttachmentBucket.getIndexOfItem(
+ selItems[0].previousElementSibling
+ );
+ } else {
+ visibleIndex = gAttachmentBucket.getIndexOfItem(
+ selItems[selItems.length - 1].nextElementSibling
+ );
+ }
+ break;
+
+ case "top":
+ case "bottom":
+ case "bundleUp":
+ case "bundleDown":
+ // Bundle selected attachments to top/bottom of the list or upwards/downwards.
+
+ upwards = ["top", "bundleUp"].includes(aDirection);
+ // Downwards: Reverse order of selItems so we can use the same algorithm.
+ if (!upwards) {
+ selItems.reverse();
+ }
+
+ if (["top", "bottom"].includes(aDirection)) {
+ let listEdgeItem = gAttachmentBucket.getItemAtIndex(
+ upwards ? 0 : gAttachmentBucket.itemCount - 1
+ );
+ let selEdgeItem = selItems[0];
+ if (selEdgeItem != listEdgeItem) {
+ // Top/Bottom: Move the first/last selected item to the edge of the list
+ // so that we always have an initial anchor target block in the right
+ // place, so we can use the same algorithm for top/bottom and
+ // inner bundling.
+ targetItem = upwards
+ ? // Upwards: Insert before first list item.
+ listEdgeItem
+ : // Downwards: Insert after last list item, i.e.
+ // *before* non-existing listEdgeItem.nextElementSibling,
+ // which is null. It works because it's a feature.
+ null;
+ gAttachmentBucket.insertBefore(selEdgeItem, targetItem);
+ }
+ }
+ // We now have a selected block (at least one item) at the target position.
+ // Let's find the end (inner edge) of that block and move only the
+ // remaining selected items to avoid unnecessary moves.
+ targetItem = null;
+ for (let item of selItems) {
+ if (targetItem) {
+ // We know where to move it, so move it!
+ gAttachmentBucket.insertBefore(item, targetItem);
+ if (!upwards) {
+ // Downwards: As selItems are reversed, and there's no insertAfter()
+ // method to insert *after* a stable target, we need to insert
+ // *before* the first item of the target block at target position,
+ // which is the current selItem which we've just moved onto the block.
+ targetItem = item;
+ }
+ } else {
+ // If there's no targetItem yet, find the inner edge of the target block.
+ let nextItem = upwards
+ ? item.nextElementSibling
+ : item.previousElementSibling;
+ if (!nextItem.selected) {
+ // If nextItem is not selected, current selItem is the inner edge of
+ // the initial anchor target block, so we can set targetItem.
+ targetItem = upwards
+ ? // Upwards: set stable targetItem.
+ nextItem
+ : // Downwards: set initial targetItem.
+ item;
+ }
+ // Else if nextItem is selected, it is still part of initial anchor
+ // target block, so just proceed to look for the edge of that block.
+ }
+ } // next selItem
+
+ // Ensure visibility of first/last selected item after the move.
+ visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]);
+ break;
+
+ case "toggleSort":
+ // Sort the selected attachments alphabetically after moving them together.
+ // The command updater of cmd_sortAttachmentsToggle toggles the sorting
+ // direction based on the current sorting and block status of the selection.
+
+ let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle");
+ let sortDirection =
+ toggleCmd.getAttribute("sortdirection") || "ascending";
+ let sortItems;
+ let sortSelection;
+
+ if (gAttachmentBucket.selectedCount > 1) {
+ // Sort selected attachments only.
+ sortSelection = true;
+ sortItems = selItems;
+ // Move selected attachments together before sorting as a block.
+ goDoCommand("cmd_moveAttachmentBundleUp");
+
+ // Find the end of the selected block to find our targetItem.
+ for (let item of selItems) {
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // If there's no nextItem (block at list bottom), or nextItem is
+ // not selected, we've reached the end of the block.
+ // Set the block's nextElementSibling as targetItem and exit loop.
+ // Works by definition even if nextElementSibling aka nextItem is null.
+ targetItem = nextItem;
+ break;
+ }
+ // else if (nextItem && nextItem.selected), nextItem is still part of
+ // the block, so proceed with checking its nextElementSibling.
+ } // next selItem
+ } else {
+ // Sort all attachments.
+ sortSelection = false;
+ sortItems = attachmentsGetSortedArray();
+ targetItem = null; // Insert at the end of the list.
+ }
+ // Now let's sort our sortItems according to sortDirection.
+ if (sortDirection == "ascending") {
+ sortItems.sort((a, b) =>
+ a.attachment.name.localeCompare(b.attachment.name)
+ );
+ } else {
+ // "descending"
+ sortItems.sort((a, b) =>
+ b.attachment.name.localeCompare(a.attachment.name)
+ );
+ }
+
+ // Insert sortItems in new order before the nextElementSibling of the block.
+ for (let item of sortItems) {
+ gAttachmentBucket.insertBefore(item, targetItem);
+ }
+
+ if (sortSelection) {
+ // After sorting selection: Ensure visibility of first selected item.
+ visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]);
+ } else {
+ // After sorting all items: Ensure visibility of selected item,
+ // otherwise first list item.
+ visibleIndex =
+ selItems.length == 1 ? gAttachmentBucket.selectedIndex : 0;
+ }
+ break;
+ } // end switch (aDirection)
+
+ // Restore original focus.
+ gAttachmentBucket.currentItem = focusItem;
+ // Ensure smart visibility of a relevant item according to direction.
+ gAttachmentBucket.ensureIndexIsVisible(visibleIndex);
+
+ // Moving selected items around does not trigger auto-updating of our command
+ // handlers, so we must do it now as the position of selected items has changed.
+ updateReorderAttachmentsItems();
+}
+/* eslint-enable complexity */
+
+/**
+ * Toggle attachment pane view state: show or hide it.
+ * If aAction parameter is omitted, toggle current view state.
+ *
+ * @param {string} [aAction = "toggle"] - "show": show attachment pane
+ * "hide": hide attachment pane
+ * "toggle": toggle attachment pane
+ */
+function toggleAttachmentPane(aAction = "toggle") {
+ let attachmentArea = document.getElementById("attachmentArea");
+
+ if (aAction == "toggle") {
+ // Interrupt if we don't have any attachment as we don't want nor need to
+ // show an empty container.
+ if (!gAttachmentBucket.itemCount) {
+ return;
+ }
+
+ if (attachmentArea.open && document.activeElement != gAttachmentBucket) {
+ // Interrupt and move the focus to the attachment pane if it's already
+ // visible but not currently focused.
+ moveFocusToAttachmentPane();
+ return;
+ }
+
+ // Toggle attachment pane.
+ attachmentArea.open = !attachmentArea.open;
+ } else {
+ attachmentArea.open = aAction != "hide";
+ }
+}
+
+/**
+ * Update the #attachmentArea according to its open state.
+ */
+function attachmentAreaOnToggle() {
+ let attachmentArea = document.getElementById("attachmentArea");
+ let bucketHasFocus = document.activeElement == gAttachmentBucket;
+ if (attachmentArea.open && !bucketHasFocus) {
+ moveFocusToAttachmentPane();
+ } else if (!attachmentArea.open && bucketHasFocus) {
+ // Move the focus to the message body only if the bucket was focused.
+ focusMsgBody();
+ }
+
+ // Make the splitter non-interactive whilst the bucket is hidden.
+ document
+ .getElementById("composeContentBox")
+ .classList.toggle("attachment-bucket-closed", !attachmentArea.open);
+
+ // Update the checkmark on menuitems hooked up with cmd_toggleAttachmentPane.
+ // Menuitem does not have .checked property nor .toggleAttribute(), sigh.
+ for (let menuitem of document.querySelectorAll(
+ 'menuitem[command="cmd_toggleAttachmentPane"]'
+ )) {
+ if (attachmentArea.open) {
+ menuitem.setAttribute("checked", "true");
+ continue;
+ }
+ menuitem.removeAttribute("checked");
+ }
+
+ // Update the title based on the collapsed status of the bucket.
+ document.l10n.setAttributes(
+ attachmentArea.querySelector("summary"),
+ attachmentArea.open ? "attachment-area-hide" : "attachment-area-show"
+ );
+}
+
+/**
+ * Ensure the focus is properly moved to the Attachment Bucket, and to the first
+ * available item if present.
+ */
+function moveFocusToAttachmentPane() {
+ gAttachmentBucket.focus();
+
+ if (gAttachmentBucket.currentItem) {
+ gAttachmentBucket.ensureElementIsVisible(gAttachmentBucket.currentItem);
+ }
+}
+
+function showReorderAttachmentsPanel() {
+ // Ensure attachment pane visibility as it might be collapsed.
+ toggleAttachmentPane("show");
+ showPopupById(
+ "reorderAttachmentsPanel",
+ "attachmentBucket",
+ "after_start",
+ 15,
+ 0
+ );
+ // After the panel is shown, focus attachmentBucket so that keyboard
+ // operation for selecting and moving attachment items works; the panel
+ // helpfully presents the keyboard shortcuts for moving things around.
+ // Bucket focus is also required because the panel will only close with ESC
+ // or attachmentBucketOnBlur(), and that's because we're using noautohide as
+ // event.preventDefault() of onpopuphiding event fails when the panel
+ // is auto-hiding, but we don't want panel to hide when focus goes to bucket.
+ gAttachmentBucket.focus();
+}
+
+/**
+ * Returns a string representing the current sort order of selected attachment
+ * items by their names. We don't check if selected items form a coherent block
+ * or not; use attachmentsSelectionIsBlock() to check on that.
+ *
+ * @returns {string} "ascending" : Sort order is ascending.
+ * "descending": Sort order is descending.
+ * "equivalent": The names of all selected items are equivalent.
+ * "" : There's no sort order, or only 1 item selected,
+ * or no items selected, or no attachments,
+ * or no attachmentBucket.
+ */
+function attachmentsSelectionGetSortOrder() {
+ return attachmentsGetSortOrder(true);
+}
+
+/**
+ * Returns a string representing the current sort order of attachment items
+ * by their names.
+ *
+ * @param aSelectedOnly {boolean}: true: return sort order of selected items only.
+ * false (default): return sort order of all items.
+ *
+ * @returns {string} "ascending" : Sort order is ascending.
+ * "descending": Sort order is descending.
+ * "equivalent": The names of the items are equivalent.
+ * "" : There's no sort order, or no attachments,
+ * or no attachmentBucket; or (with aSelectedOnly),
+ * only 1 item selected, or no items selected.
+ */
+function attachmentsGetSortOrder(aSelectedOnly = false) {
+ let listItems;
+ if (aSelectedOnly) {
+ if (gAttachmentBucket.selectedCount <= 1) {
+ return "";
+ }
+
+ listItems = attachmentsSelectionGetSortedArray();
+ } else {
+ // aSelectedOnly == false
+ if (!gAttachmentBucket.itemCount) {
+ return "";
+ }
+
+ listItems = attachmentsGetSortedArray();
+ }
+
+ // We're comparing each item to the next item, so exclude the last item.
+ let listItems1 = listItems.slice(0, -1);
+ let someAscending;
+ let someDescending;
+
+ // Check if some adjacent items are sorted ascending.
+ someAscending = listItems1.some(
+ (item, index) =>
+ item.attachment.name.localeCompare(listItems[index + 1].attachment.name) <
+ 0
+ );
+
+ // Check if some adjacent items are sorted descending.
+ someDescending = listItems1.some(
+ (item, index) =>
+ item.attachment.name.localeCompare(listItems[index + 1].attachment.name) >
+ 0
+ );
+
+ // Unsorted (but not all equivalent in sort order)
+ if (someAscending && someDescending) {
+ return "";
+ }
+
+ if (someAscending && !someDescending) {
+ return "ascending";
+ }
+
+ if (someDescending && !someAscending) {
+ return "descending";
+ }
+
+ // No ascending pairs, no descending pairs, so all equivalent in sort order.
+ // if (!someAscending && !someDescending)
+ return "equivalent";
+}
+
+function reorderAttachmentsPanelOnPopupShowing() {
+ let panel = document.getElementById("reorderAttachmentsPanel");
+ let buttonsNodeList = panel.querySelectorAll(".panelButton");
+ let buttons = [...buttonsNodeList]; // convert NodeList to Array
+ // Let's add some pretty keyboard shortcuts to the buttons.
+ buttons.forEach(btn => {
+ if (btn.hasAttribute("key")) {
+ btn.setAttribute("prettykey", getPrettyKey(btn.getAttribute("key")));
+ }
+ });
+ // Focus attachment bucket to activate attachmentBucketController, which is
+ // required for updating the reorder commands.
+ gAttachmentBucket.focus();
+ // We're updating commands before showing the panel so that button states
+ // don't change after the panel is shown, and also because focus is still
+ // in attachment bucket right now, which is required for updating them.
+ updateReorderAttachmentsItems();
+}
+
+function attachmentHeaderContextOnPopupShowing() {
+ let initiallyShowItem = document.getElementById(
+ "attachmentHeaderContext_initiallyShowItem"
+ );
+
+ initiallyShowItem.setAttribute(
+ "checked",
+ Services.prefs.getBoolPref("mail.compose.show_attachment_pane")
+ );
+}
+
+function toggleInitiallyShowAttachmentPane(aMenuItem) {
+ Services.prefs.setBoolPref(
+ "mail.compose.show_attachment_pane",
+ aMenuItem.getAttribute("checked")
+ );
+}
+
+/**
+ * Handle blur event on attachment pane and control visibility of
+ * reorderAttachmentsPanel.
+ */
+function attachmentBucketOnBlur() {
+ let reorderAttachmentsPanel = document.getElementById(
+ "reorderAttachmentsPanel"
+ );
+ // If attachment pane has really lost focus, and if reorderAttachmentsPanel is
+ // not currently in the process of showing up, hide reorderAttachmentsPanel.
+ // Otherwise, keep attachments selected and the reorderAttachmentsPanel open
+ // when reordering and after renaming via dialog.
+ if (
+ document.activeElement.id != "attachmentBucket" &&
+ reorderAttachmentsPanel.state != "showing"
+ ) {
+ reorderAttachmentsPanel.hidePopup();
+ }
+}
+
+/**
+ * Handle the keypress on the attachment bucket.
+ *
+ * @param {Event} event - The keypress DOM Event.
+ */
+function attachmentBucketOnKeyPress(event) {
+ // Interrupt if the Alt modifier is pressed, meaning the user is reordering
+ // the list of attachments.
+ if (event.altKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Escape":
+ let reorderAttachmentsPanel = document.getElementById(
+ "reorderAttachmentsPanel"
+ );
+
+ // Close the reorderAttachmentsPanel if open and interrupt.
+ if (reorderAttachmentsPanel.state == "open") {
+ reorderAttachmentsPanel.hidePopup();
+ return;
+ }
+
+ if (gAttachmentBucket.itemCount) {
+ // Deselect selected items in a full bucket if any.
+ if (gAttachmentBucket.selectedCount) {
+ gAttachmentBucket.clearSelection();
+ return;
+ }
+
+ // Move the focus to the message body.
+ focusMsgBody();
+ return;
+ }
+
+ // Close an empty bucket.
+ toggleAttachmentPane("hide");
+ break;
+
+ case "Enter":
+ // Enter on empty bucket to add file attachments, convenience
+ // keyboard equivalent of single-click on bucket whitespace.
+ if (!gAttachmentBucket.itemCount) {
+ goDoCommand("cmd_attachFile");
+ }
+ break;
+
+ case "ArrowLeft":
+ gAttachmentBucket.moveByOffset(-1, !event.ctrlKey, event.shiftKey);
+ event.preventDefault();
+ break;
+
+ case "ArrowRight":
+ gAttachmentBucket.moveByOffset(1, !event.ctrlKey, event.shiftKey);
+ event.preventDefault();
+ break;
+
+ case "ArrowDown":
+ gAttachmentBucket.moveByOffset(
+ gAttachmentBucket._itemsPerRow(),
+ !event.ctrlKey,
+ event.shiftKey
+ );
+ event.preventDefault();
+ break;
+
+ case "ArrowUp":
+ gAttachmentBucket.moveByOffset(
+ -gAttachmentBucket._itemsPerRow(),
+ !event.ctrlKey,
+ event.shiftKey
+ );
+
+ event.preventDefault();
+ break;
+ }
+}
+
+function attachmentBucketOnClick(aEvent) {
+ // Handle click on attachment pane whitespace normally clear selection.
+ // If there are no attachments in the bucket, show 'Attach File(s)' dialog.
+ if (
+ aEvent.button == 0 &&
+ aEvent.target.getAttribute("is") == "attachment-list" &&
+ !aEvent.target.firstElementChild
+ ) {
+ goDoCommand("cmd_attachFile");
+ }
+}
+
+function attachmentBucketOnSelect() {
+ attachmentBucketUpdateTooltips();
+ updateAttachmentItems();
+}
+
+function attachmentBucketUpdateTooltips() {
+ // Attachment pane whitespace tooltip
+ if (gAttachmentBucket.selectedCount) {
+ gAttachmentBucket.tooltipText = getComposeBundle().getString(
+ "attachmentBucketClearSelectionTooltip"
+ );
+ } else {
+ gAttachmentBucket.tooltipText = getComposeBundle().getString(
+ "attachmentBucketAttachFilesTooltip"
+ );
+ }
+}
+
+function OpenSelectedAttachment() {
+ if (gAttachmentBucket.selectedItems.length != 1) {
+ return;
+ }
+ let attachment = gAttachmentBucket.getSelectedItem(0).attachment;
+ let attachmentUrl = attachment.url;
+
+ let messagePrefix = /^mailbox-message:|^imap-message:|^news-message:/i;
+ if (messagePrefix.test(attachmentUrl)) {
+ // we must be dealing with a forwarded attachment, treat this special
+ let msgHdr =
+ MailServices.messageServiceFromURI(attachmentUrl).messageURIToMsgHdr(
+ attachmentUrl
+ );
+ if (msgHdr) {
+ MailUtils.openMessageInNewWindow(msgHdr);
+ }
+ return;
+ }
+ if (
+ attachment.contentType == "application/pdf" ||
+ /\.pdf$/i.test(attachment.name)
+ ) {
+ // @see msgHdrView.js which has simililar opening functionality
+ let handlerInfo = gMIMEService.getFromTypeAndExtension(
+ attachment.contentType,
+ attachment.name.split(".").pop()
+ );
+ // Only open a new tab for pdfs if we are handling them internally.
+ if (
+ !handlerInfo.alwaysAskBeforeHandling &&
+ handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally
+ ) {
+ // Add the content type to avoid a "how do you want to open this?"
+ // dialog. The type may already be there, but that doesn't matter.
+ let url = attachment.url;
+ if (!url.includes("type=")) {
+ url += url.includes("?") ? "&" : "?";
+ url += "type=application/pdf";
+ }
+ let tabmail = Services.wm
+ .getMostRecentWindow("mail:3pane")
+ ?.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.openTab("contentTab", {
+ url,
+ background: false,
+ linkHandler: "single-page",
+ });
+ tabmail.ownerGlobal.focus();
+ return;
+ }
+ // If no tabmail, open PDF same as other attachments.
+ }
+ }
+ let uri = Services.io.newURI(attachmentUrl);
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let uriLoader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader);
+ uriLoader.openURI(channel, true, new nsAttachmentOpener());
+}
+
+function nsAttachmentOpener() {}
+
+nsAttachmentOpener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIURIContentListener",
+ "nsIInterfaceRequestor",
+ ]),
+
+ doContent(contentType, isContentPreferred, request, contentHandler) {
+ // If we came here to display an attached message, make sure we provide a type.
+ if (/[?&]part=/i.test(request.URI.query)) {
+ let newQuery = request.URI.query + "&type=message/rfc822";
+ request.URI = request.URI.mutate().setQuery(newQuery).finalize();
+ }
+ let newHandler = Cc[
+ "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display"
+ ].createInstance(Ci.nsIContentHandler);
+ newHandler.handleContent("application/x-message-display", this, request);
+ return true;
+ },
+
+ isPreferred(contentType, desiredContentType) {
+ if (contentType == "message/rfc822") {
+ return true;
+ }
+ return false;
+ },
+
+ canHandleContent(contentType, isContentPreferred, desiredContentType) {
+ return false;
+ },
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIDOMWindow)) {
+ return window;
+ }
+ if (iid.equals(Ci.nsIDocShell)) {
+ return window.docShell;
+ }
+ return this.QueryInterface(iid);
+ },
+
+ loadCookie: null,
+ parentContentListener: null,
+};
+
+/**
+ * Determine the sending format depending on the selected format, or the content
+ * of the message body.
+ *
+ * @returns {nsIMsgCompSendFormat} The determined send format: either PlainText,
+ * HTML or Both (never Auto or Unset).
+ */
+function determineSendFormat() {
+ if (!gMsgCompose.composeHTML) {
+ return Ci.nsIMsgCompSendFormat.PlainText;
+ }
+
+ let sendFormat = gMsgCompose.compFields.deliveryFormat;
+ if (sendFormat != Ci.nsIMsgCompSendFormat.Auto) {
+ return sendFormat;
+ }
+
+ // Auto downgrade if safe to do so.
+ let convertible;
+ try {
+ convertible = gMsgCompose.bodyConvertible();
+ } catch (ex) {
+ return Ci.nsIMsgCompSendFormat.Both;
+ }
+ return convertible == Ci.nsIMsgCompConvertible.Plain
+ ? Ci.nsIMsgCompSendFormat.PlainText
+ : Ci.nsIMsgCompSendFormat.Both;
+}
+
+/**
+ * Expands mailinglists found in the recipient fields.
+ */
+function expandRecipients() {
+ gMsgCompose.expandMailingLists();
+}
+
+/**
+ * Hides addressing options (To, CC, Bcc, Newsgroup, Followup-To, etc.)
+ * that are not relevant for the account type used for sending.
+ *
+ * @param {string} accountKey - Key of the account that is currently selected
+ * as the sending account.
+ * @param {string} prevKey - Key of the account that was previously selected
+ * as the sending account.
+ */
+function hideIrrelevantAddressingOptions(accountKey, prevKey) {
+ let showNews = false;
+ for (let account of MailServices.accounts.accounts) {
+ if (account.incomingServer.type == "nntp") {
+ showNews = true;
+ }
+ }
+ // If there is no News (NNTP) account existing then
+ // hide the Newsgroup and Followup-To recipient type menuitems.
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetVisibility(item, showNews);
+ }
+
+ let account = MailServices.accounts.getAccount(accountKey);
+ let accountType = account.incomingServer.type;
+
+ // If the new account is a News (NNTP) account.
+ if (accountType == "nntp") {
+ updateUIforNNTPAccount();
+ return;
+ }
+
+ // If the new account is a Mail account and a previous account was selected.
+ if (accountType != "nntp" && prevKey != "") {
+ updateUIforMailAccount();
+ }
+}
+
+function LoadIdentity(startup) {
+ let identityElement = document.getElementById("msgIdentity");
+ let prevIdentity = gCurrentIdentity;
+
+ let idKey = null;
+ let accountKey = null;
+ let prevKey = getCurrentAccountKey();
+ if (identityElement.selectedItem) {
+ // Set the identity key value on the menu list.
+ idKey = identityElement.selectedItem.getAttribute("identitykey");
+ identityElement.setAttribute("identitykey", idKey);
+ gCurrentIdentity = MailServices.accounts.getIdentity(idKey);
+
+ // Set the account key value on the menu list.
+ accountKey = identityElement.selectedItem.getAttribute("accountkey");
+ identityElement.setAttribute("accountkey", accountKey);
+
+ // Update the addressing options only if a new account was selected.
+ if (prevKey != getCurrentAccountKey()) {
+ hideIrrelevantAddressingOptions(accountKey, prevKey);
+ }
+ }
+ for (let input of document.querySelectorAll(".mail-input,.news-input")) {
+ let params = JSON.parse(input.searchParam);
+ params.idKey = idKey;
+ params.accountKey = accountKey;
+ input.searchParam = JSON.stringify(params);
+ }
+
+ if (startup) {
+ // During compose startup, bail out here.
+ return;
+ }
+
+ // Since switching the signature loses the caret position, we record it
+ // and restore it later.
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+ let range = selection.getRangeAt(0);
+ let start = range.startOffset;
+ let startNode = range.startContainer;
+
+ editor.enableUndo(false);
+
+ // Handle non-startup changing of identity.
+ if (prevIdentity && idKey != prevIdentity.key) {
+ let changedRecipients = false;
+ let prevReplyTo = prevIdentity.replyTo;
+ let prevCc = "";
+ let prevBcc = "";
+ let prevReceipt = prevIdentity.requestReturnReceipt;
+ let prevDSN = prevIdentity.DSN;
+ let prevAttachVCard = prevIdentity.attachVCard;
+
+ if (prevIdentity.doCc && prevIdentity.doCcList) {
+ prevCc += prevIdentity.doCcList;
+ }
+
+ if (prevIdentity.doBcc && prevIdentity.doBccList) {
+ prevBcc += prevIdentity.doBccList;
+ }
+
+ let newReplyTo = gCurrentIdentity.replyTo;
+ let newCc = "";
+ let newBcc = "";
+ let newReceipt = gCurrentIdentity.requestReturnReceipt;
+ let newDSN = gCurrentIdentity.DSN;
+ let newAttachVCard = gCurrentIdentity.attachVCard;
+
+ if (gCurrentIdentity.doCc && gCurrentIdentity.doCcList) {
+ newCc += gCurrentIdentity.doCcList;
+ }
+
+ if (gCurrentIdentity.doBcc && gCurrentIdentity.doBccList) {
+ newBcc += gCurrentIdentity.doBccList;
+ }
+
+ let msgCompFields = gMsgCompose.compFields;
+ // Update recipients in msgCompFields to match pills currently in the UI.
+ Recipients2CompFields(msgCompFields);
+
+ if (
+ !gReceiptOptionChanged &&
+ prevReceipt == msgCompFields.returnReceipt &&
+ prevReceipt != newReceipt
+ ) {
+ msgCompFields.returnReceipt = newReceipt;
+ ToggleReturnReceipt(msgCompFields.returnReceipt);
+ }
+
+ if (
+ !gDSNOptionChanged &&
+ prevDSN == msgCompFields.DSN &&
+ prevDSN != newDSN
+ ) {
+ msgCompFields.DSN = newDSN;
+ document
+ .getElementById("dsnMenu")
+ .setAttribute("checked", msgCompFields.DSN);
+ }
+
+ if (
+ !gAttachVCardOptionChanged &&
+ prevAttachVCard == msgCompFields.attachVCard &&
+ prevAttachVCard != newAttachVCard
+ ) {
+ msgCompFields.attachVCard = newAttachVCard;
+ document
+ .getElementById("cmd_attachVCard")
+ .setAttribute("checked", msgCompFields.attachVCard);
+ }
+
+ if (newReplyTo != prevReplyTo) {
+ if (prevReplyTo != "") {
+ awRemoveRecipients(msgCompFields, "addr_reply", prevReplyTo);
+ }
+ if (newReplyTo != "") {
+ awAddRecipients(msgCompFields, "addr_reply", newReplyTo);
+ }
+ }
+
+ let toCcAddrs = new Set([
+ ...msgCompFields.splitRecipients(msgCompFields.to, true),
+ ...msgCompFields.splitRecipients(msgCompFields.cc, true),
+ ]);
+
+ if (newCc != prevCc) {
+ if (prevCc) {
+ awRemoveRecipients(msgCompFields, "addr_cc", prevCc);
+ }
+ if (newCc) {
+ // Add only Auto-Cc recipients whose email is not already in To or CC.
+ newCc = msgCompFields
+ .splitRecipients(newCc, false)
+ .filter(
+ x => !toCcAddrs.has(...msgCompFields.splitRecipients(x, true))
+ )
+ .join(", ");
+ awAddRecipients(msgCompFields, "addr_cc", newCc);
+ }
+ changedRecipients = true;
+ }
+
+ if (newBcc != prevBcc) {
+ let toCcBccAddrs = new Set([
+ ...toCcAddrs,
+ ...msgCompFields.splitRecipients(newCc, true),
+ ...msgCompFields.splitRecipients(msgCompFields.bcc, true),
+ ]);
+
+ if (prevBcc) {
+ awRemoveRecipients(msgCompFields, "addr_bcc", prevBcc);
+ }
+ if (newBcc) {
+ // Add only Auto-Bcc recipients whose email is not already in To, Cc,
+ // Bcc, or added as Auto-CC from newCc declared above.
+ newBcc = msgCompFields
+ .splitRecipients(newBcc, false)
+ .filter(
+ x => !toCcBccAddrs.has(...msgCompFields.splitRecipients(x, true))
+ )
+ .join(", ");
+ awAddRecipients(msgCompFields, "addr_bcc", newBcc);
+ }
+ changedRecipients = true;
+ }
+
+ // Handle showing/hiding of empty CC/BCC row after changing identity.
+ // Whenever "Cc/Bcc these email addresses" aka mail.identity.id#.doCc/doBcc
+ // is checked in Account Settings, show the address row, even if empty.
+ // This is a feature especially for ux-efficiency of enterprise workflows.
+ let addressRowCc = document.getElementById("addressRowCc");
+ if (gCurrentIdentity.doCc) {
+ // Per identity's doCc pref, show CC row, even if empty.
+ showAndFocusAddressRow("addressRowCc");
+ } else if (
+ prevIdentity.doCc &&
+ !addressRowCc.querySelector("mail-address-pill")
+ ) {
+ // Current identity doesn't need CC row shown, but previous identity did.
+ // Hide CC row if it's empty.
+ addressRowSetVisibility(addressRowCc, false);
+ }
+
+ let addressRowBcc = document.getElementById("addressRowBcc");
+ if (gCurrentIdentity.doBcc) {
+ // Per identity's doBcc pref, show BCC row, even if empty.
+ showAndFocusAddressRow("addressRowBcc");
+ } else if (
+ prevIdentity.doBcc &&
+ !addressRowBcc.querySelector("mail-address-pill")
+ ) {
+ // Current identity doesn't need BCC row shown, but previous identity did.
+ // Hide BCC row if it's empty.
+ addressRowSetVisibility(addressRowBcc, false);
+ }
+
+ // Trigger async checking and updating of encryption UI.
+ adjustEncryptAfterIdentityChange(prevIdentity);
+
+ try {
+ gMsgCompose.identity = gCurrentIdentity;
+ } catch (ex) {
+ dump("### Cannot change the identity: " + ex + "\n");
+ }
+
+ window.dispatchEvent(new CustomEvent("compose-from-changed"));
+
+ gComposeNotificationBar.clearIdentityWarning();
+
+ // Trigger this method only if the Cc or Bcc recipients changed from the
+ // previous identity.
+ if (changedRecipients) {
+ onRecipientsChanged(true);
+ }
+ }
+
+ // Only do this if we aren't starting up...
+ // It gets done as part of startup already.
+ addRecipientsToIgnoreList(gCurrentIdentity.fullAddress);
+
+ // If the From field is editable, reset the address from the identity.
+ if (identityElement.editable) {
+ identityElement.value = identityElement.selectedItem.value;
+ identityElement.placeholder = getComposeBundle().getFormattedString(
+ "msgIdentityPlaceholder",
+ [identityElement.selectedItem.value]
+ );
+ }
+
+ editor.enableUndo(true);
+ editor.resetModificationCount();
+ selection.collapse(startNode, start);
+
+ // Try to focus the first available address row. If there are none, focus the
+ // Subject which is always available.
+ for (let row of document.querySelectorAll(".address-row")) {
+ if (focusAddressRowInput(row)) {
+ return;
+ }
+ }
+ focusSubjectInput();
+}
+
+function MakeFromFieldEditable(ignoreWarning) {
+ let bundle = getComposeBundle();
+ if (
+ !ignoreWarning &&
+ !Services.prefs.getBoolPref("mail.compose.warned_about_customize_from")
+ ) {
+ var check = { value: false };
+ if (
+ Services.prompt.confirmEx(
+ window,
+ bundle.getString("customizeFromAddressTitle"),
+ bundle.getString("customizeFromAddressWarning"),
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_OK +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ bundle.getString("customizeFromAddressIgnore"),
+ check
+ ) != 0
+ ) {
+ return;
+ }
+ Services.prefs.setBoolPref(
+ "mail.compose.warned_about_customize_from",
+ check.value
+ );
+ }
+
+ let customizeMenuitem = document.getElementById("cmd_customizeFromAddress");
+ customizeMenuitem.setAttribute("disabled", "true");
+ let identityElement = document.getElementById("msgIdentity");
+ let identityElementWidth = `${
+ identityElement.getBoundingClientRect().width
+ }px`;
+ identityElement.style.width = identityElementWidth;
+ identityElement.removeAttribute("type");
+ identityElement.setAttribute("editable", "true");
+ identityElement.focus();
+ identityElement.value = identityElement.selectedItem.value;
+ identityElement.select();
+ identityElement.placeholder = bundle.getFormattedString(
+ "msgIdentityPlaceholder",
+ [identityElement.selectedItem.value]
+ );
+}
+
+/**
+ * Set up autocomplete search parameters for address inputs of inbuilt headers.
+ *
+ * @param {Element} input - The address input of an inbuilt header field.
+ */
+function setupAutocompleteInput(input) {
+ let params = JSON.parse(input.getAttribute("autocompletesearchparam"));
+ params.type = input.closest(".address-row").dataset.recipienttype;
+ input.setAttribute("autocompletesearchparam", JSON.stringify(params));
+
+ // This method overrides the autocomplete binding's openPopup (essentially
+ // duplicating the logic from the autocomplete popup binding's
+ // openAutocompletePopup method), modifying it so that the popup is aligned
+ // and sized based on the parentNode of the input field.
+ input.openPopup = () => {
+ if (input.focused) {
+ input.popup.openAutocompletePopup(
+ input.nsIAutocompleteInput,
+ input.closest(".address-container")
+ );
+ }
+ };
+}
+
+/**
+ * Handle the keypress event of the From field.
+ *
+ * @param {Event} event - A DOM keypress event on #msgIdentity.
+ */
+function fromKeyPress(event) {
+ if (event.key == "Enter") {
+ // Move the focus to the first available address input.
+ document
+ .querySelector(
+ "#recipientsContainer .address-row:not(.hidden) .address-row-input"
+ )
+ .focus();
+ }
+}
+
+/**
+ * Handle the keypress event of the subject input.
+ *
+ * @param {Event} event - A DOM keypress event on #msgSubject.
+ */
+function subjectKeyPress(event) {
+ if (event.key == "Delete" && event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated Delete keypress event if the flag is set.
+ event.preventDefault();
+ return;
+ }
+ // Enable repeated deletion if any other key is pressed, or if the Delete
+ // keypress event is not repeated, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ // Move the focus to the body only if the Enter key is pressed without any
+ // modifier, as that would mean the user wants to send the message.
+ if (event.key == "Enter" && !event.ctrlKey && !event.metaKey) {
+ focusMsgBody();
+ }
+}
+
+/**
+ * Handle the input event of the subject input element.
+ *
+ * @param {Event} event - A DOM input event on #msgSubject.
+ */
+function msgSubjectOnInput(event) {
+ gSubjectChanged = true;
+ gContentChanged = true;
+ SetComposeWindowTitle();
+}
+
+// Content types supported in the envelopeDragObserver.
+const DROP_FLAVORS = [
+ "application/x-moz-file",
+ "text/x-moz-address",
+ "text/x-moz-message",
+ "text/x-moz-url",
+ "text/uri-list",
+];
+
+// We can drag and drop addresses, files, messages and urls into the compose
+// envelope.
+var envelopeDragObserver = {
+ /**
+ * Adjust the drop target when dragging from the attachment bucket onto itself
+ * by picking the nearest possible insertion point (generally, between two
+ * list items).
+ *
+ * @param {Event} event - The drag-and-drop event being performed.
+ * @returns {attachmentitem|string} - the adjusted drop target:
+ * - an attachmentitem node for inserting *before*
+ * - "none" if this isn't a valid insertion point
+ * - "afterLastItem" for appending at the bottom of the list.
+ */
+ _adjustDropTarget(event) {
+ let target = event.target;
+ if (target == gAttachmentBucket) {
+ // Dragging or dropping at top/bottom border of the listbox
+ if (
+ (event.screenY - target.screenY) /
+ target.getBoundingClientRect().height <
+ 0.5
+ ) {
+ target = gAttachmentBucket.firstElementChild;
+ } else {
+ target = gAttachmentBucket.lastElementChild;
+ }
+ // We'll check below if this is a valid target.
+ } else if (target.id == "attachmentBucketCount") {
+ // Dragging or dropping at top border of the listbox.
+ // Allow bottom half of attachment list header as extended drop target
+ // for top of list, because otherwise it would be too small.
+ if (
+ (event.screenY - target.screenY) /
+ target.getBoundingClientRect().height >=
+ 0.5
+ ) {
+ target = gAttachmentBucket.firstElementChild;
+ // We'll check below if this is a valid target.
+ } else {
+ // Top half of attachment list header: sorry, can't drop here.
+ return "none";
+ }
+ }
+
+ // Target is an attachmentitem.
+ if (target.matches("richlistitem.attachmentItem")) {
+ // If we're dragging/dropping in bottom half of attachmentitem,
+ // adjust target to target.nextElementSibling (to show dropmarker above that).
+ if (
+ (event.screenY - target.screenY) /
+ target.getBoundingClientRect().height >=
+ 0.5
+ ) {
+ target = target.nextElementSibling;
+
+ // If there's no target.nextElementSibling, we're dragging/dropping
+ // to the bottom of the list.
+ if (!target) {
+ // We can't move a bottom block selection to the bottom.
+ if (attachmentsSelectionIsBlock("bottom")) {
+ return "none";
+ }
+
+ // Not a bottom block selection: Target is *after* the last item.
+ return "afterLastItem";
+ }
+ }
+ // Check if the adjusted target attachmentitem is a valid target.
+ let isBlock = attachmentsSelectionIsBlock();
+ let prevItem = target.previousElementSibling;
+ // If target is first list item, there's no previous sibling;
+ // treat like unselected previous sibling.
+ let prevSelected = prevItem ? prevItem.selected : false;
+ if (
+ (target.selected && (isBlock || prevSelected)) ||
+ // target at end of block selection
+ (isBlock && prevSelected)
+ ) {
+ // We can't move a block selection before/after itself,
+ // or any selection onto itself, so trigger dropeffect "none".
+ return "none";
+ }
+ return target;
+ }
+
+ return "none";
+ },
+
+ _showDropMarker(targetItem) {
+ // Hide old drop marker.
+ this._hideDropMarker();
+
+ if (targetItem == "afterLastItem") {
+ targetItem = gAttachmentBucket.lastElementChild;
+ targetItem.setAttribute("dropOn", "after");
+ } else {
+ targetItem.setAttribute("dropOn", "before");
+ }
+ },
+
+ _hideDropMarker() {
+ gAttachmentBucket
+ .querySelector(".attachmentItem[dropOn]")
+ ?.removeAttribute("dropOn");
+ },
+
+ /**
+ * Loop through all the valid data type flavors and return a list of valid
+ * attachments to handle the various drag&drop actions.
+ *
+ * @param {Event} event - The drag-and-drop event being performed.
+ * @param {boolean} isDropping - If the action was performed from the onDrop
+ * method and it needs to handle pills creation.
+ *
+ * @returns {nsIMsgAttachment[]} - The array of valid attachments.
+ */
+ getValidAttachments(event, isDropping) {
+ let attachments = [];
+ let dt = event.dataTransfer;
+ let dataList = [];
+
+ // Extract all the flavors matching the data type of the dragged elements.
+ for (let i = 0; i < dt.mozItemCount; i++) {
+ let types = Array.from(dt.mozTypesAt(i));
+ for (let flavor of DROP_FLAVORS) {
+ if (types.includes(flavor)) {
+ let data = dt.mozGetDataAt(flavor, i);
+ if (data) {
+ dataList.push({ data, flavor });
+ break;
+ }
+ }
+ }
+ }
+
+ // Check if we have any valid attachment in the dragged data.
+ for (let { data, flavor } of dataList) {
+ gIsValidInline = false;
+ let isValidAttachment = false;
+ let prettyName;
+ let size;
+ let contentType;
+ let msgUri;
+ let cloudFileInfo;
+
+ // We could be dropping an attachment of various flavors OR an address;
+ // check and do the right thing.
+ switch (flavor) {
+ // Process attachments.
+ case "application/x-moz-file":
+ if (data instanceof Ci.nsIFile) {
+ size = data.fileSize;
+ }
+ try {
+ data = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getURLSpecFromActualFile(data);
+ isValidAttachment = true;
+ } catch (e) {
+ console.error(
+ "Couldn't process the dragged file " + data.leafName + ":" + e
+ );
+ }
+ break;
+
+ case "text/x-moz-message":
+ isValidAttachment = true;
+ let msgHdr =
+ MailServices.messageServiceFromURI(data).messageURIToMsgHdr(data);
+ prettyName = msgHdr.mime2DecodedSubject;
+ if (Services.prefs.getBoolPref("mail.forward_add_extension")) {
+ prettyName += ".eml";
+ }
+
+ size = msgHdr.messageSize;
+ contentType = "message/rfc822";
+ break;
+
+ // Data type representing:
+ // - URL strings dragged from a URL bar (Allow both attach and append).
+ // NOTE: This only works for macOS and Windows.
+ // - Attachments dragged from another message (Only attach).
+ // - Images dragged from the body of another message (Only append).
+ case "text/uri-list":
+ case "text/x-moz-url":
+ let pieces = data.split("\n");
+ data = pieces[0];
+ if (pieces.length > 1) {
+ prettyName = pieces[1];
+ }
+ if (pieces.length > 2) {
+ size = parseInt(pieces[2]);
+ }
+ if (pieces.length > 3) {
+ contentType = pieces[3];
+ }
+ if (pieces.length > 4) {
+ msgUri = pieces[4];
+ }
+ if (pieces.length > 6) {
+ cloudFileInfo = {
+ cloudFileAccountKey: pieces[5],
+ cloudPartHeaderData: pieces[6],
+ };
+ }
+
+ // Show the attachment overlay only if the user is not dragging an
+ // image form another message, since we can't get the correct file
+ // name, nor we can properly handle the append inline outside the
+ // editor drop event.
+ isValidAttachment = !event.dataTransfer.types.includes(
+ "application/x-moz-nativeimage"
+ );
+ // Show the append inline overlay only if this is not a file that was
+ // dragged from the attachment bucket of another message.
+ gIsValidInline = !event.dataTransfer.types.includes(
+ "application/x-moz-file-promise"
+ );
+ break;
+
+ // Process address: Drop it into recipient field.
+ case "text/x-moz-address":
+ // Process the drop only if the message body wasn't the target and we
+ // called this method from the onDrop() method.
+ if (event.target.baseURI != "about:blank?compose" && isDropping) {
+ DropRecipient(event.target, data);
+ // Prevent the default behaviour which drops the address text into
+ // the widget.
+ event.preventDefault();
+ }
+ break;
+ }
+
+ // Create the attachment and add it to attachments array.
+ if (isValidAttachment) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.url = data;
+ attachment.name = prettyName;
+ attachment.contentType = contentType;
+ attachment.msgUri = msgUri;
+
+ if (size !== undefined) {
+ attachment.size = size;
+ }
+
+ if (cloudFileInfo) {
+ attachment.cloudFileAccountKey = cloudFileInfo.cloudFileAccountKey;
+ attachment.cloudPartHeaderData = cloudFileInfo.cloudPartHeaderData;
+ }
+
+ attachments.push(attachment);
+ }
+ }
+
+ return attachments;
+ },
+
+ /**
+ * Reorder the attachments dragged within the attachment bucket.
+ *
+ * @param {Event} event - The drag event.
+ */
+ _reorderDraggedAttachments(event) {
+ // Adjust the drop target according to mouse position on list (items).
+ let target = this._adjustDropTarget(event);
+ // Get a non-live, sorted list of selected attachment list items.
+ let selItems = attachmentsSelectionGetSortedArray();
+ // Keep track of the item we had focused originally. Deselect it though,
+ // since listbox gets confused if you move its focused item around.
+ let focus = gAttachmentBucket.currentItem;
+ gAttachmentBucket.currentItem = null;
+ // Moving possibly non-coherent multiple selections around correctly
+ // is much more complex than one might think...
+ if (
+ (target.matches && target.matches("richlistitem.attachmentItem")) ||
+ target == "afterLastItem"
+ ) {
+ // Drop before targetItem in the list, or after last item.
+ let blockItems = [];
+ let targetItem;
+ for (let item of selItems) {
+ blockItems.push(item);
+ if (target == "afterLastItem") {
+ // Original target is the end of the list; append all items there.
+ gAttachmentBucket.appendChild(item);
+ } else if (target == selItems[0]) {
+ // Original target is first item of first selected block.
+ if (blockItems.includes(target)) {
+ // Item is in first block: do nothing, find the end of the block.
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // We've reached the end of the first block.
+ blockItems.length = 0;
+ targetItem = nextItem;
+ }
+ } else {
+ // Item is NOT in first block: insert before targetItem,
+ // i.e. after end of first block.
+ gAttachmentBucket.insertBefore(item, targetItem);
+ }
+ } else if (target.selected) {
+ // Original target is not first item of first block,
+ // but first item of another block.
+ if (
+ gAttachmentBucket.getIndexOfItem(item) <
+ gAttachmentBucket.getIndexOfItem(target)
+ ) {
+ // Insert all items from preceding blocks before original target.
+ gAttachmentBucket.insertBefore(item, target);
+ } else if (blockItems.includes(target)) {
+ // target is included in any selected block except first:
+ // do nothing for that block, find its end.
+ let nextItem = item.nextElementSibling;
+ if (!nextItem || !nextItem.selected) {
+ // end of block containing target
+ blockItems.length = 0;
+ targetItem = nextItem;
+ }
+ } else {
+ // Item from block after block containing target: insert before
+ // targetItem, i.e. after end of block containing target.
+ gAttachmentBucket.insertBefore(item, targetItem);
+ }
+ } else {
+ // target != selItems [0]
+ // Original target is NOT first item of any block, and NOT selected:
+ // Insert all items before the original target.
+ gAttachmentBucket.insertBefore(item, target);
+ }
+ }
+ }
+ gAttachmentBucket.currentItem = focus;
+ },
+
+ handleInlineDrop(event) {
+ // It would be nice here to be able to append images, but we can't really
+ // assume if users want to add the image URL as clickable link or embedded
+ // image, so we always default to clickable link.
+ // We can later explore adding some UI choice to allow controlling the
+ // outcome of this drop action, but users can still copy and paste the image
+ // in the editor to cirumvent this potential issue.
+ let editor = GetCurrentEditor();
+ let attachments = this.getValidAttachments(event, true);
+
+ for (let attachment of attachments) {
+ if (!attachment?.url) {
+ continue;
+ }
+
+ let link = editor.createElementWithDefaults("a");
+ link.setAttribute("href", attachment.url);
+ link.textContent =
+ attachment.name ||
+ gMsgCompose.AttachmentPrettyName(attachment.url, null);
+ editor.insertElementAtSelection(link, true);
+ }
+ },
+
+ async onDrop(event) {
+ this._hideDropOverlay();
+
+ let dragSession = gDragService.getCurrentSession();
+ if (dragSession.sourceNode?.parentNode == gAttachmentBucket) {
+ // We dragged from the attachment pane onto itself, so instead of
+ // attaching a new object, we're just reordering them.
+ this._reorderDraggedAttachments(event);
+ this._hideDropMarker();
+ return;
+ }
+
+ // Interrupt if we're dropping elements from within the message body.
+ if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") {
+ return;
+ }
+
+ // Interrupt if we're not dropping a file from outside the compose window
+ // and we're not dragging a supported data type.
+ if (
+ !event.dataTransfer.files.length &&
+ !DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f))
+ ) {
+ return;
+ }
+
+ // If the drop happened on the inline container, and the dragged data is
+ // valid for inline, bail out and handle it as inline text link.
+ if (event.target.id == "addInline" && gIsValidInline) {
+ this.handleInlineDrop(event);
+ return;
+ }
+
+ // Handle the inline adding of images without triggering the creation of
+ // any attachment if the user dropped only images above the #addInline box.
+ if (
+ event.target.id == "addInline" &&
+ !this.isNotDraggingOnlyImages(event.dataTransfer)
+ ) {
+ this.appendImagesInline(event.dataTransfer);
+ return;
+ }
+
+ let attachments = this.getValidAttachments(event, true);
+
+ // Interrupt if we don't have anything to attach.
+ if (!attachments.length) {
+ return;
+ }
+
+ let addedAttachmentItems = await AddAttachments(attachments);
+ // Convert attachments back to cloudFiles, if any.
+ for (let attachmentItem of addedAttachmentItems) {
+ if (
+ !attachmentItem.attachment.cloudFileAccountKey ||
+ !attachmentItem.attachment.cloudPartHeaderData
+ ) {
+ continue;
+ }
+ try {
+ let account = cloudFileAccounts.getAccount(
+ attachmentItem.attachment.cloudFileAccountKey
+ );
+ let upload = JSON.parse(
+ atob(attachmentItem.attachment.cloudPartHeaderData)
+ );
+ await UpdateAttachment(attachmentItem, {
+ cloudFileAccount: account,
+ relatedCloudFileUpload: upload,
+ });
+ } catch (ex) {
+ showLocalizedCloudFileAlert(ex);
+ }
+ }
+ gAttachmentBucket.focus();
+
+ // Stop the propagation only if we actually attached something.
+ event.stopPropagation();
+ },
+
+ onDragOver(event) {
+ let dragSession = gDragService.getCurrentSession();
+
+ // Check if we're dragging from the attachment bucket onto itself.
+ if (dragSession.sourceNode?.parentNode == gAttachmentBucket) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ // Show a drop marker.
+ let target = this._adjustDropTarget(event);
+
+ if (
+ (target.matches && target.matches("richlistitem.attachmentItem")) ||
+ target == "afterLastItem"
+ ) {
+ // Adjusted target is an attachment list item; show dropmarker.
+ this._showDropMarker(target);
+ return;
+ }
+
+ // target == "none", target is not a listItem, or no target:
+ // Indicate that we can't drop here.
+ this._hideDropMarker();
+ event.dataTransfer.dropEffect = "none";
+ return;
+ }
+
+ // Interrupt if we're dragging elements from within the message body.
+ if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") {
+ return;
+ }
+
+ // No need to check for the same dragged files if the previous dragging
+ // action didn't end.
+ if (gIsDraggingAttachments) {
+ // Prevent the default action of the event otherwise the onDrop event
+ // won't be triggered.
+ event.preventDefault();
+ this.detectHoveredOverlay(event.target.id);
+ return;
+ }
+
+ if (DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f))) {
+ // Show the drop overlay only if we dragged files or supported types.
+ let attachments = this.getValidAttachments(event);
+ if (attachments.length) {
+ // We're dragging files that can potentially be attached or added
+ // inline, so update the variable.
+ gIsDraggingAttachments = true;
+
+ event.stopPropagation();
+ event.preventDefault();
+ document
+ .getElementById("dropAttachmentOverlay")
+ .classList.add("showing");
+
+ document.l10n.setAttributes(
+ document.getElementById("addAsAttachmentLabel"),
+ "drop-file-label-attachment",
+ {
+ count: attachments.length || 1,
+ }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("addInlineLabel"),
+ "drop-file-label-inline",
+ {
+ count: attachments.length || 1,
+ }
+ );
+
+ // Show the #addInline box only if the user is dragging text that we
+ // want to allow adding as text, as well as dragging only images, and
+ // if this is not a plain text message.
+ // NOTE: We're using event.dataTransfer.files.length instead of
+ // attachments.length because we only need to consider images coming
+ // from outside the application. The attachments array might contain
+ // files dragged from other compose windows or received message, which
+ // should not trigger the inline attachment overlay.
+ document
+ .getElementById("addInline")
+ .classList.toggle(
+ "hidden",
+ !gIsValidInline &&
+ (!event.dataTransfer.files.length ||
+ this.isNotDraggingOnlyImages(event.dataTransfer) ||
+ !gMsgCompose.composeHTML)
+ );
+ } else {
+ DragAddressOverTargetControl(event);
+ }
+ }
+
+ this.detectHoveredOverlay(event.target.id);
+ },
+
+ onDragLeave(event) {
+ // Set the variable to false as a drag leave event was triggered.
+ gIsDraggingAttachments = false;
+
+ // We use a timeout since a drag leave event might occur also when the drag
+ // motion passes above a child element and doesn't actually leave the
+ // compose window.
+ setTimeout(() => {
+ // If after the timeout, the dragging boolean is true, it means the user
+ // is still dragging something above the compose window, so let's bail out
+ // to prevent visual flickering of the drop overlay.
+ if (gIsDraggingAttachments) {
+ return;
+ }
+
+ this._hideDropOverlay();
+ }, 100);
+
+ this._hideDropMarker();
+ },
+
+ /**
+ * Hide the drag & drop overlay and update the global dragging variable to
+ * false. This operations are set in a dedicated method since they need to be
+ * called outside of the onDragleave() method.
+ */
+ _hideDropOverlay() {
+ gIsDraggingAttachments = false;
+
+ let overlay = document.getElementById("dropAttachmentOverlay");
+ overlay.classList.remove("showing");
+ overlay.classList.add("hiding");
+ },
+
+ /**
+ * Loop through all the currently dragged or dropped files to see if there's
+ * at least 1 file which is not an image.
+ *
+ * @param {DataTransfer} dataTransfer - The dataTransfer object from the drag
+ * or drop event.
+ * @returns {boolean} True if at least one file is not an image.
+ */
+ isNotDraggingOnlyImages(dataTransfer) {
+ for (let file of dataTransfer.files) {
+ if (!file.type.includes("image/")) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Add or remove the hover effect to the droppable containers. We can't do it
+ * simply via CSS since the hover events don't work when dragging an item.
+ *
+ * @param {string} targetId - The ID of the hovered overlay element.
+ */
+ detectHoveredOverlay(targetId) {
+ document
+ .getElementById("addInline")
+ .classList.toggle("hover", targetId == "addInline");
+ document
+ .getElementById("addAsAttachment")
+ .classList.toggle("hover", targetId == "addAsAttachment");
+ },
+
+ /**
+ * Loop through all the images that have been dropped above the #addInline
+ * box and create an image element to append to the message body.
+ *
+ * @param {DataTransfer} dataTransfer - The dataTransfer object from the drop
+ * event.
+ */
+ appendImagesInline(dataTransfer) {
+ focusMsgBody();
+ let editor = GetCurrentEditor();
+ editor.beginTransaction();
+
+ for (let file of dataTransfer.files) {
+ if (!file.mozFullPath) {
+ continue;
+ }
+
+ let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ realFile.initWithPath(file.mozFullPath);
+
+ let imageElement;
+ try {
+ imageElement = editor.createElementWithDefaults("img");
+ } catch (e) {
+ dump("Failed to create a new image element!\n");
+ console.error(e);
+ continue;
+ }
+
+ let src = Services.io.newFileURI(realFile).spec;
+ imageElement.setAttribute("src", src);
+ imageElement.setAttribute("moz-do-not-send", "false");
+
+ editor.insertElementAtSelection(imageElement, true);
+
+ try {
+ loadBlockedImage(src);
+ } catch (e) {
+ dump("Failed to load the appended image!\n");
+ console.error(e);
+ continue;
+ }
+ }
+
+ editor.endTransaction();
+ },
+};
+
+// See attachmentListDNDObserver, which should have the same logic.
+let attachmentBucketDNDObserver = {
+ onDragStart(event) {
+ // NOTE: Starting a drag on an attachment item will normally also select
+ // the attachment item before this method is called. But this is not
+ // necessarily the case. E.g. holding Shift when starting the drag
+ // operation. When it isn't selected, we just don't transfer.
+ if (event.target.matches(".attachmentItem[selected]")) {
+ // Also transfer other selected attachment items.
+ let attachments = Array.from(
+ gAttachmentBucket.querySelectorAll(".attachmentItem[selected]"),
+ item => item.attachment
+ );
+ setupDataTransfer(event, attachments);
+ }
+ event.stopPropagation();
+ },
+};
+
+function DisplaySaveFolderDlg(folderURI) {
+ try {
+ var showDialog = gCurrentIdentity.showSaveMsgDlg;
+ } catch (e) {
+ return;
+ }
+
+ if (showDialog) {
+ let msgfolder = MailUtils.getExistingFolder(folderURI);
+ if (!msgfolder) {
+ return;
+ }
+ let checkbox = { value: 0 };
+ let bundle = getComposeBundle();
+ let SaveDlgTitle = bundle.getString("SaveDialogTitle");
+ let dlgMsg = bundle.getFormattedString("SaveDialogMsg", [
+ msgfolder.name,
+ msgfolder.server.prettyName,
+ ]);
+
+ Services.prompt.alertCheck(
+ window,
+ SaveDlgTitle,
+ dlgMsg,
+ bundle.getString("CheckMsg"),
+ checkbox
+ );
+ try {
+ gCurrentIdentity.showSaveMsgDlg = !checkbox.value;
+ } catch (e) {}
+ }
+}
+
+/**
+ * Focus the people search input in the contacts side panel.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {boolean} - Whether the peopleSearchInput was focused.
+ */
+function focusContactsSidebarSearchInput() {
+ if (document.getElementById("contactsSplitter").isCollapsed) {
+ return false;
+ }
+ let input = document
+ .getElementById("contactsBrowser")
+ .contentDocument.getElementById("peopleSearchInput");
+ if (!input) {
+ return false;
+ }
+ input.focus();
+ return true;
+}
+
+/**
+ * Focus the "From" identity input/selector.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {true} - Always returns true.
+ */
+function focusMsgIdentity() {
+ document.getElementById("msgIdentity").focus();
+ return true;
+}
+
+/**
+ * Focus the address row input, provided the row is not hidden.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @param {Element} row - The address row to focus.
+ *
+ * @returns {boolean} - Whether the input was focused.
+ */
+function focusAddressRowInput(row) {
+ if (row.classList.contains("hidden")) {
+ return false;
+ }
+ row.querySelector(".address-row-input").focus();
+ return true;
+}
+
+/**
+ * Focus the "Subject" input.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {true} - Always returns true.
+ */
+function focusSubjectInput() {
+ document.getElementById("msgSubject").focus();
+ return true;
+}
+
+/**
+ * Focus the composed message body.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {true} - Always returns true.
+ */
+function focusMsgBody() {
+ // window.content.focus() fails to blur the currently focused element
+ document.commandDispatcher.advanceFocusIntoSubtree(
+ document.getElementById("messageArea")
+ );
+ return true;
+}
+
+/**
+ * Focus the attachment bucket, provided it is not hidden.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @param {Element} attachmentArea - The attachment container.
+ *
+ * @returns {boolean} - Whether the attachment bucket was focused.
+ */
+function focusAttachmentBucket(attachmentArea) {
+ if (
+ document
+ .getElementById("composeContentBox")
+ .classList.contains("attachment-area-hidden")
+ ) {
+ return false;
+ }
+ if (!attachmentArea.open) {
+ // Focus the expander instead.
+ attachmentArea.querySelector("summary").focus();
+ return true;
+ }
+ gAttachmentBucket.focus();
+ return true;
+}
+
+/**
+ * Focus the first notification button.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @returns {boolean} - Whether a notification received focused.
+ */
+function focusNotification() {
+ let notification = gComposeNotification.allNotifications[0];
+ if (notification) {
+ let button = notification.buttonContainer.querySelector("button");
+ if (button) {
+ button.focus();
+ } else {
+ // Focus the close button instead.
+ notification.closeButton.focus();
+ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Focus the first focusable descendant of the status bar.
+ *
+ * Note, this is used as a {@link moveFocusWithin} method.
+ *
+ * @param {Element} attachmentArea - The status bar.
+ *
+ * @returns {boolean} - Whether a status bar descendant received focused.
+ */
+function focusStatusBar(statusBar) {
+ let button = statusBar.querySelector("button:not([hidden])");
+ if (!button) {
+ return false;
+ }
+ button.focus();
+ return true;
+}
+
+/**
+ * Fast-track focus ring: Switch focus between important (not all) elements
+ * in the message compose window in response to Ctrl+[Shift+]Tab or [Shift+]F6.
+ *
+ * @param {Event} event - A DOM keyboard event of a fast focus ring shortcut key
+ */
+function moveFocusToNeighbouringArea(event) {
+ event.preventDefault();
+ let currentElement = document.activeElement;
+
+ for (let i = 0; i < gFocusAreas.length; i++) {
+ // Go through each area and check if focus is within.
+ let area = gFocusAreas[i];
+ if (!area.root.contains(currentElement)) {
+ continue;
+ }
+ // Focus is within, so we find the neighbouring area to move focus to.
+ let end = i;
+ while (true) {
+ // Get the next neighbour.
+ // NOTE: The focus will loop around.
+ if (event.shiftKey) {
+ // Move focus backward. If the index points to the start of the Array,
+ // we loop back to the end of the Array.
+ i = (i || gFocusAreas.length) - 1;
+ } else {
+ // Move focus forward. If the index points to the end of the Array, we
+ // loop back to the start of the Array.
+ i = (i + 1) % gFocusAreas.length;
+ }
+ if (i == end) {
+ // Full loop around without finding an area to focus.
+ // Unexpected, but we make sure to stop looping.
+ break;
+ }
+ area = gFocusAreas[i];
+ if (area.focus(area.root)) {
+ // Successfully moved focus.
+ break;
+ }
+ // Else, try the next neighbour.
+ }
+ return;
+ }
+ // Focus is currently outside the gFocusAreas list, so do nothing.
+}
+
+/**
+ * If the contacts sidebar is shown, hide it. Otherwise, show the contacts
+ * sidebar and focus it.
+ */
+function toggleContactsSidebar() {
+ setContactsSidebarVisibility(
+ document.getElementById("contactsSplitter").isCollapsed,
+ true
+ );
+}
+
+/**
+ * Show or hide contacts sidebar.
+ *
+ * @param {boolean} show - Whether to show the sidebar or hide the sidebar.
+ * @param {boolean} focus - Whether to focus peopleSearchInput if the sidebar is
+ * shown.
+ */
+function setContactsSidebarVisibility(show, focus) {
+ let contactsSplitter = document.getElementById("contactsSplitter");
+ let sidebarAddrMenu = document.getElementById("menu_AddressSidebar");
+ let contactsButton = document.getElementById("button-contacts");
+
+ if (show) {
+ contactsSplitter.expand();
+ sidebarAddrMenu.setAttribute("checked", "true");
+ if (contactsButton) {
+ contactsButton.setAttribute("checked", "true");
+ }
+
+ let contactsBrowser = document.getElementById("contactsBrowser");
+ if (contactsBrowser.getAttribute("src") == "") {
+ // Url not yet set, load contacts side bar and focus the search
+ // input if applicable: We pass "?focus" as a URL querystring, then via
+ // onload event of <window id="abContactsPanel">, in AbPanelLoad() of
+ // abContactsPanel.js, we do the focusing first thing to avoid timing
+ // issues when trying to focus from here while contacts side bar is still
+ // loading.
+ let url = "chrome://messenger/content/addressbook/abContactsPanel.xhtml";
+ if (focus) {
+ url += "?focus";
+ }
+ contactsBrowser.setAttribute("src", url);
+ } else if (focus) {
+ // Url already set, so we can focus immediately if applicable.
+ focusContactsSidebarSearchInput();
+ }
+ } else {
+ let contactsSidebar = document.getElementById("contactsSidebar");
+ // Before closing, check if the focus was within the contacts sidebar.
+ let sidebarFocussed = contactsSidebar.contains(document.activeElement);
+
+ contactsSplitter.collapse();
+ sidebarAddrMenu.removeAttribute("checked");
+ if (contactsButton) {
+ contactsButton.removeAttribute("checked");
+ }
+
+ // Don't change the focus unless it was within the contacts sidebar.
+ if (!sidebarFocussed) {
+ return;
+ }
+ // Else, we need to explicitly move the focus out of the contacts sidebar.
+ // We choose the subject input if it is empty, otherwise the message body.
+ if (!document.getElementById("msgSubject").value) {
+ focusSubjectInput();
+ } else {
+ focusMsgBody();
+ }
+ }
+}
+
+function loadHTMLMsgPrefs() {
+ let fontFace = Services.prefs.getStringPref("msgcompose.font_face", "");
+ if (fontFace) {
+ doStatefulCommand("cmd_fontFace", fontFace, true);
+ }
+
+ let fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3");
+ EditorSetFontSize(fontSize);
+
+ let bodyElement = GetBodyElement();
+
+ let useDefault = Services.prefs.getBoolPref("msgcompose.default_colors");
+
+ let textColor = useDefault
+ ? ""
+ : Services.prefs.getCharPref("msgcompose.text_color", "");
+ if (!bodyElement.getAttribute("text") && textColor) {
+ bodyElement.setAttribute("text", textColor);
+ gDefaultTextColor = textColor;
+ document.getElementById("cmd_fontColor").setAttribute("state", textColor);
+ onFontColorChange();
+ }
+
+ let bgColor = useDefault
+ ? ""
+ : Services.prefs.getCharPref("msgcompose.background_color", "");
+ if (!bodyElement.getAttribute("bgcolor") && bgColor) {
+ bodyElement.setAttribute("bgcolor", bgColor);
+ gDefaultBackgroundColor = bgColor;
+ document
+ .getElementById("cmd_backgroundColor")
+ .setAttribute("state", bgColor);
+ onBackgroundColorChange();
+ }
+}
+
+async function AutoSave() {
+ if (
+ gMsgCompose.editor &&
+ (gContentChanged || gMsgCompose.bodyModified) &&
+ !gSendOperationInProgress &&
+ !gSaveOperationInProgress
+ ) {
+ try {
+ await GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft);
+ } catch (ex) {
+ console.error(ex);
+ }
+ gAutoSaveKickedIn = true;
+ }
+
+ gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval);
+}
+
+/**
+ * Periodically check for keywords in the message.
+ */
+var gAttachmentNotifier = {
+ _obs: null,
+
+ enabled: false,
+
+ init(aDocument) {
+ if (this._obs) {
+ this.shutdown();
+ }
+
+ this.enabled = Services.prefs.getBoolPref(
+ "mail.compose.attachment_reminder"
+ );
+ if (!this.enabled) {
+ return;
+ }
+
+ this._obs = new MutationObserver(function (aMutations) {
+ gAttachmentNotifier.timer.cancel();
+ gAttachmentNotifier.timer.initWithCallback(
+ gAttachmentNotifier.event,
+ 500,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ });
+
+ this._obs.observe(aDocument, {
+ attributes: true,
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+
+ // Add an input event listener for the subject field since there
+ // are ways of changing its value without key presses.
+ document
+ .getElementById("msgSubject")
+ .addEventListener("input", this.subjectInputObserver, true);
+
+ // We could have been opened with a draft message already containing
+ // some keywords, so run the checker once to pick them up.
+ this.event.notify();
+ },
+
+ // Timer based function triggered by the inputEventListener
+ // for the subject field.
+ subjectInputObserver() {
+ gAttachmentNotifier.timer.cancel();
+ gAttachmentNotifier.timer.initWithCallback(
+ gAttachmentNotifier.event,
+ 500,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * Checks for new keywords synchronously and run the usual handler.
+ *
+ * @param aManage Determines whether to manage the notification according to keywords found.
+ */
+ redetectKeywords(aManage) {
+ if (!this.enabled) {
+ return;
+ }
+
+ attachmentWorker.onmessage(
+ { data: this._checkForAttachmentKeywords(false) },
+ aManage
+ );
+ },
+
+ /**
+ * Check if there are any keywords in the message.
+ *
+ * @param async Whether we should run the regex checker asynchronously or not.
+ *
+ * @returns If async is true, attachmentWorker.message is called with the array
+ * of found keywords and this function returns null.
+ * If it is false, the array is returned from this function immediately.
+ */
+ _checkForAttachmentKeywords(async) {
+ if (!this.enabled) {
+ return async ? null : [];
+ }
+
+ if (attachmentNotificationSupressed()) {
+ // If we know we don't need to show the notification,
+ // we can skip the expensive checking of keywords in the message.
+ // but mark it in the .lastMessage that the keywords are unknown.
+ attachmentWorker.lastMessage = null;
+ return async ? null : [];
+ }
+
+ let keywordsInCsv = Services.prefs.getComplexValue(
+ "mail.compose.attachment_reminder_keywords",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ let mailBody = getBrowser().contentDocument.querySelector("body");
+
+ // We use a new document and import the body into it. We do that to avoid
+ // loading images that were previously blocked. Content policy of the newly
+ // created data document will block the loads. Details: Bug 1409458 comment #22.
+ let newDoc = getBrowser().contentDocument.implementation.createDocument(
+ "",
+ "",
+ null
+ );
+ let mailBodyNode = newDoc.importNode(mailBody, true);
+
+ // Don't check quoted text from reply.
+ let blockquotes = mailBodyNode.getElementsByTagName("blockquote");
+ for (let i = blockquotes.length - 1; i >= 0; i--) {
+ blockquotes[i].remove();
+ }
+
+ // For plaintext composition the quotes we need to find and exclude are
+ // <span _moz_quote="true">.
+ let spans = mailBodyNode.querySelectorAll("span[_moz_quote]");
+ for (let i = spans.length - 1; i >= 0; i--) {
+ spans[i].remove();
+ }
+
+ // Ignore signature (html compose mode).
+ let sigs = mailBodyNode.getElementsByClassName("moz-signature");
+ for (let i = sigs.length - 1; i >= 0; i--) {
+ sigs[i].remove();
+ }
+
+ // Replace brs with line breaks so node.textContent won't pull foo<br>bar
+ // together to foobar.
+ let brs = mailBodyNode.getElementsByTagName("br");
+ for (let i = brs.length - 1; i >= 0; i--) {
+ brs[i].parentNode.replaceChild(
+ mailBodyNode.ownerDocument.createTextNode("\n"),
+ brs[i]
+ );
+ }
+
+ // Ignore signature (plain text compose mode).
+ let mailData = mailBodyNode.textContent;
+ let sigIndex = mailData.indexOf("-- \n");
+ if (sigIndex > 0) {
+ mailData = mailData.substring(0, sigIndex);
+ }
+
+ // Ignore replied messages (plain text and html compose mode).
+ let repText = getComposeBundle().getString(
+ "mailnews.reply_header_originalmessage"
+ );
+ let repIndex = mailData.indexOf(repText);
+ if (repIndex > 0) {
+ mailData = mailData.substring(0, repIndex);
+ }
+
+ // Ignore forwarded messages (plain text and html compose mode).
+ let fwdText = getComposeBundle().getString(
+ "mailnews.forward_header_originalmessage"
+ );
+ let fwdIndex = mailData.indexOf(fwdText);
+ if (fwdIndex > 0) {
+ mailData = mailData.substring(0, fwdIndex);
+ }
+
+ // Prepend the subject to see if the subject contains any attachment
+ // keywords too, after making sure that the subject has changed
+ // or after reopening a draft. For reply, redirect and forward,
+ // only check when the input was changed by the user.
+ let subject = document.getElementById("msgSubject").value;
+ if (
+ subject &&
+ (gSubjectChanged ||
+ (gEditingDraft &&
+ (gComposeType == Ci.nsIMsgCompType.New ||
+ gComposeType == Ci.nsIMsgCompType.NewsPost ||
+ gComposeType == Ci.nsIMsgCompType.Draft ||
+ gComposeType == Ci.nsIMsgCompType.Template ||
+ gComposeType == Ci.nsIMsgCompType.EditTemplate ||
+ gComposeType == Ci.nsIMsgCompType.EditAsNew ||
+ gComposeType == Ci.nsIMsgCompType.MailToUrl)))
+ ) {
+ mailData = subject + " " + mailData;
+ }
+
+ if (!async) {
+ return AttachmentChecker.getAttachmentKeywords(mailData, keywordsInCsv);
+ }
+
+ attachmentWorker.postMessage([mailData, keywordsInCsv]);
+ return null;
+ },
+
+ shutdown() {
+ if (this._obs) {
+ this._obs.disconnect();
+ }
+ gAttachmentNotifier.timer.cancel();
+
+ this._obs = null;
+ },
+
+ event: {
+ notify(timer) {
+ // Only run the checker if the compose window is initialized
+ // and not shutting down.
+ if (gMsgCompose) {
+ // This runs the attachmentWorker asynchronously so if keywords are found
+ // manageAttachmentNotification is run from attachmentWorker.onmessage.
+ gAttachmentNotifier._checkForAttachmentKeywords(true);
+ }
+ },
+ },
+
+ timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer),
+};
+
+/**
+ * Helper function to remove a query part from a URL, so for example:
+ * ...?remove=xx&other=yy becomes ...?other=yy.
+ *
+ * @param aURL the URL from which to remove the query part
+ * @param aQuery the query part to remove
+ * @returns the URL with the query part removed
+ */
+function removeQueryPart(aURL, aQuery) {
+ // Quick pre-check.
+ if (!aURL.includes(aQuery)) {
+ return aURL;
+ }
+
+ let indexQM = aURL.indexOf("?");
+ if (indexQM < 0) {
+ return aURL;
+ }
+
+ let queryParts = aURL.substr(indexQM + 1).split("&");
+ let indexPart = queryParts.indexOf(aQuery);
+ if (indexPart < 0) {
+ return aURL;
+ }
+ queryParts.splice(indexPart, 1);
+ return aURL.substr(0, indexQM + 1) + queryParts.join("&");
+}
+
+function InitEditor() {
+ var editor = GetCurrentEditor();
+
+ // Set eEditorMailMask flag to avoid using content prefs for spell checker,
+ // otherwise dictionary setting in preferences is ignored and dictionary is
+ // inconsistent in subject and message body.
+ let eEditorMailMask = Ci.nsIEditor.eEditorMailMask;
+ editor.flags |= eEditorMailMask;
+ document.getElementById("msgSubject").editor.flags |= eEditorMailMask;
+
+ // Control insertion of line breaks.
+ editor.returnInParagraphCreatesNewParagraph = Services.prefs.getBoolPref(
+ "editor.CR_creates_new_p"
+ );
+ editor.document.execCommand(
+ "defaultparagraphseparator",
+ false,
+ gMsgCompose.composeHTML &&
+ Services.prefs.getBoolPref("mail.compose.default_to_paragraph")
+ ? "p"
+ : "br"
+ );
+ if (gMsgCompose.composeHTML) {
+ // Re-enable table/image resizers.
+ editor.QueryInterface(
+ Ci.nsIHTMLAbsPosEditor
+ ).absolutePositioningEnabled = true;
+ editor.QueryInterface(
+ Ci.nsIHTMLInlineTableEditor
+ ).inlineTableEditingEnabled = true;
+ editor.QueryInterface(Ci.nsIHTMLObjectResizer).objectResizingEnabled = true;
+ }
+
+ // We use loadSheetUsingURIString so that we get a synchronous load, rather
+ // than having a late-finishing async load mark our editor as modified when
+ // the user hasn't typed anything yet, but that means the sheet must not
+ // @import slow things, especially not over the network.
+ let domWindowUtils = GetCurrentEditorElement().contentWindow.windowUtils;
+ domWindowUtils.loadSheetUsingURIString(
+ "chrome://messenger/skin/messageQuotes.css",
+ domWindowUtils.AGENT_SHEET
+ );
+ domWindowUtils.loadSheetUsingURIString(
+ "chrome://messenger/skin/shared/composerOverlay.css",
+ domWindowUtils.AGENT_SHEET
+ );
+
+ window.content.browsingContext.allowJavascript = false;
+ window.content.browsingContext.docShell.allowAuth = false;
+ window.content.browsingContext.docShell.allowMetaRedirects = false;
+ gMsgCompose.initEditor(editor, window.content);
+
+ if (!editor.document.doctype) {
+ editor.document.insertBefore(
+ editor.document.implementation.createDocumentType("html", "", ""),
+ editor.document.firstChild
+ );
+ }
+
+ // Then, we enable related UI entries.
+ enableInlineSpellCheck(Services.prefs.getBoolPref("mail.spellcheck.inline"));
+ gAttachmentNotifier.init(editor.document);
+
+ // Listen for spellchecker changes, set document language to
+ // dictionary picked by the user via the right-click menu in the editor.
+ document.addEventListener("spellcheck-changed", updateDocumentLanguage);
+
+ // XXX: the error event fires twice for each load. Why??
+ editor.document.body.addEventListener(
+ "error",
+ function (event) {
+ if (event.target.localName != "img") {
+ return;
+ }
+
+ if (event.target.getAttribute("moz-do-not-send") == "true") {
+ return;
+ }
+
+ let src = event.target.src;
+ if (!src) {
+ return;
+ }
+ if (!/^file:/i.test(src)) {
+ // Check if this is a protocol that can fetch parts.
+ let protocol = src.substr(0, src.indexOf(":")).toLowerCase();
+ if (
+ !(
+ Services.io.getProtocolHandler(protocol) instanceof
+ Ci.nsIMsgMessageFetchPartService
+ )
+ ) {
+ // Can't fetch parts, don't try to load.
+ return;
+ }
+ }
+
+ if (event.target.classList.contains("loading-internal")) {
+ // We're already loading this, or tried so unsuccessfully.
+ return;
+ }
+ if (gOriginalMsgURI) {
+ let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI);
+ let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI);
+ if (
+ src.startsWith(
+ removeQueryPart(
+ originalMsgNeckoURI.spec,
+ "type=application/x-message-display"
+ )
+ ) ||
+ // Special hack for saved messages.
+ (src.includes("?number=0&") &&
+ originalMsgNeckoURI.spec.startsWith("file://") &&
+ src.startsWith(
+ removeQueryPart(
+ originalMsgNeckoURI.spec,
+ "type=application/x-message-display"
+ ).replace("file://", "mailbox://") + "number=0"
+ ))
+ ) {
+ // Reply/Forward/Edit Draft/Edit as New can contain references to
+ // images in the original message. Load those and make them data: URLs
+ // now.
+ event.target.classList.add("loading-internal");
+ try {
+ loadBlockedImage(src);
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ }
+ } else {
+ // Appears to reference a random message. Notify and keep blocking.
+ gComposeNotificationBar.setBlockedContent(src);
+ }
+ } else {
+ // For file:, and references to parts of random messages, show the
+ // blocked content notification.
+ gComposeNotificationBar.setBlockedContent(src);
+ }
+ },
+ true
+ );
+
+ // Convert mailnews URL back to data: URL.
+ let background = editor.document.body.background;
+ if (background && gOriginalMsgURI) {
+ // Check that background has the same URL as the message itself.
+ let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI);
+ let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI);
+ if (
+ background.startsWith(
+ removeQueryPart(
+ originalMsgNeckoURI.spec,
+ "type=application/x-message-display"
+ )
+ )
+ ) {
+ try {
+ editor.document.body.background = loadBlockedImage(background, true);
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ }
+ }
+ }
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ if (AppConstants.platform != "macosx") {
+ AutoHideMenubar.init();
+ }
+
+ // For plain text compose, set the styles for quoted text according to
+ // preferences.
+ if (!gMsgCompose.composeHTML) {
+ let style = editor.document.createElement("style");
+ editor.document.head.appendChild(style);
+ let fontStyle = "";
+ let fontSize = "";
+ switch (Services.prefs.getIntPref("mail.quoted_style")) {
+ case 1:
+ fontStyle = "font-weight: bold;";
+ break;
+ case 2:
+ fontStyle = "font-style: italic;";
+ break;
+ case 3:
+ fontStyle = "font-weight: bold; font-style: italic;";
+ break;
+ }
+
+ switch (Services.prefs.getIntPref("mail.quoted_size")) {
+ case 1:
+ fontSize = "font-size: large;";
+ break;
+ case 2:
+ fontSize = "font-size: small;";
+ break;
+ }
+
+ let citationColor =
+ "color: " + Services.prefs.getCharPref("mail.citation_color") + ";";
+
+ style.sheet.insertRule(
+ `span[_moz_quote="true"] {
+ ${fontStyle}
+ ${fontSize}
+ ${citationColor}
+ }`
+ );
+ gMsgCompose.bodyModified = false;
+ }
+
+ // Set document language to the draft language or the preference
+ // if this is a draft or template we prepared.
+ let draftLanguages = null;
+ if (
+ gMsgCompose.compFields.creatorIdentityKey &&
+ gMsgCompose.compFields.contentLanguage
+ ) {
+ draftLanguages = gMsgCompose.compFields.contentLanguage
+ .split(",")
+ .map(lang => lang.trim());
+ }
+
+ let dictionaries = getValidSpellcheckerDictionaries(draftLanguages);
+ ComposeChangeLanguage(dictionaries).catch(console.error);
+}
+
+function setFontSize(event) {
+ // Increase Font Menuitem and Decrease Font Menuitem from the main menu
+ // will call this function because of oncommand attribute on the menupopup
+ // and fontSize will be null for such function calls.
+ let fontSize = event.target.value;
+ if (fontSize) {
+ EditorSetFontSize(fontSize);
+ }
+}
+
+function setParagraphState(event) {
+ editorSetParagraphState(event.target.value);
+}
+
+// This is used as event listener to spellcheck-changed event to update
+// document language.
+function updateDocumentLanguage(e) {
+ ComposeChangeLanguage(e.detail.dictionaries).catch(console.error);
+}
+
+function toggleSpellCheckingEnabled() {
+ enableInlineSpellCheck(!gSpellCheckingEnabled);
+}
+
+// This function is called either at startup (see InitEditor above), or when
+// the user clicks on one of the two menu items that allow them to toggle the
+// spellcheck feature (either context menu or Options menu).
+function enableInlineSpellCheck(aEnableInlineSpellCheck) {
+ let checker = GetCurrentEditorSpellChecker();
+ if (!checker) {
+ return;
+ }
+ if (gSpellCheckingEnabled != aEnableInlineSpellCheck) {
+ // If state of spellchecker is about to change, clear any pending observer.
+ spellCheckReadyObserver.removeObserver();
+ }
+
+ gSpellCheckingEnabled = checker.enableRealTimeSpell = aEnableInlineSpellCheck;
+ document
+ .getElementById("msgSubject")
+ .setAttribute("spellcheck", aEnableInlineSpellCheck);
+}
+
+function getMailToolbox() {
+ return document.getElementById("compose-toolbox");
+}
+
+/**
+ * Helper function to dispatch a CustomEvent to the attachmentbucket.
+ *
+ * @param aEventType the name of the event to fire.
+ * @param aData any detail data to pass to the CustomEvent.
+ */
+function dispatchAttachmentBucketEvent(aEventType, aData) {
+ gAttachmentBucket.dispatchEvent(
+ new CustomEvent(aEventType, {
+ bubbles: true,
+ cancelable: true,
+ detail: aData,
+ })
+ );
+}
+
+/** Update state of zoom type (text vs. full) menu item. */
+function UpdateFullZoomMenu() {
+ let menuItem = document.getElementById("menu_fullZoomToggle");
+ menuItem.setAttribute("checked", !ZoomManager.useFullZoom);
+}
+
+/**
+ * Return the <editor> element of the mail compose window. The name is somewhat
+ * unfortunate; we need to maintain it since the zoom manager, view source and
+ * other functions still rely on it.
+ */
+function getBrowser() {
+ return document.getElementById("messageEditor");
+}
+
+function goUpdateMailMenuItems(commandset) {
+ for (let i = 0; i < commandset.children.length; i++) {
+ let commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ goUpdateCommand(commandID);
+ }
+ }
+}
+
+/**
+ * Object to handle message related notifications that are showing in a
+ * notificationbox below the composed message content.
+ */
+var gComposeNotificationBar = {
+ get brandBundle() {
+ delete this.brandBundle;
+ return (this.brandBundle = document.getElementById("brandBundle"));
+ },
+
+ setBlockedContent(aBlockedURI) {
+ let brandName = this.brandBundle.getString("brandShortName");
+ let buttonLabel = getComposeBundle().getString(
+ AppConstants.platform == "win"
+ ? "blockedContentPrefLabel"
+ : "blockedContentPrefLabelUnix"
+ );
+ let buttonAccesskey = getComposeBundle().getString(
+ AppConstants.platform == "win"
+ ? "blockedContentPrefAccesskey"
+ : "blockedContentPrefAccesskeyUnix"
+ );
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ accessKey: buttonAccesskey,
+ popup: "blockedContentOptions",
+ callback(aNotification, aButton) {
+ return true; // keep notification open
+ },
+ },
+ ];
+
+ // The popup value is a space separated list of all the blocked urls.
+ let popup = document.getElementById("blockedContentOptions");
+ let urls = popup.value ? popup.value.split(" ") : [];
+ if (!urls.includes(aBlockedURI)) {
+ urls.push(aBlockedURI);
+ }
+ popup.value = urls.join(" ");
+
+ let msg = getComposeBundle().getFormattedString("blockedContentMessage", [
+ brandName,
+ brandName,
+ ]);
+ msg = PluralForm.get(urls.length, msg);
+
+ if (!this.isShowingBlockedContentNotification()) {
+ gComposeNotification.appendNotification(
+ "blockedContent",
+ {
+ label: msg,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+ } else {
+ gComposeNotification
+ .getNotificationWithValue("blockedContent")
+ .setAttribute("label", msg);
+ }
+ },
+
+ isShowingBlockedContentNotification() {
+ return !!gComposeNotification.getNotificationWithValue("blockedContent");
+ },
+
+ clearBlockedContentNotification() {
+ gComposeNotification.removeNotification(
+ gComposeNotification.getNotificationWithValue("blockedContent")
+ );
+ },
+
+ clearNotifications(aValue) {
+ gComposeNotification.removeAllNotifications(true);
+ },
+
+ /**
+ * Show a warning notification when a newly typed identity in the Form field
+ * doesn't match any existing identity.
+ *
+ * @param {string} identity - The name of the identity to add to the
+ * notification. Most likely an email address.
+ */
+ async setIdentityWarning(identity) {
+ // Bail out if we are already showing this type of notification.
+ if (gComposeNotification.getNotificationWithValue("identityWarning")) {
+ return;
+ }
+
+ gComposeNotification.appendNotification(
+ "identityWarning",
+ {
+ label: await document.l10n.formatValue(
+ "compose-missing-identity-warning",
+ {
+ identity,
+ }
+ ),
+ priority: gComposeNotification.PRIORITY_WARNING_HIGH,
+ },
+ null
+ );
+ },
+
+ clearIdentityWarning() {
+ let idWarning =
+ gComposeNotification.getNotificationWithValue("identityWarning");
+ if (idWarning) {
+ gComposeNotification.removeNotification(idWarning);
+ }
+ },
+};
+
+/**
+ * Populate the menuitems of what blocked content to unblock.
+ */
+function onBlockedContentOptionsShowing(aEvent) {
+ let urls = aEvent.target.value ? aEvent.target.value.split(" ") : [];
+
+ // Out with the old...
+ while (aEvent.target.lastChild) {
+ aEvent.target.lastChild.remove();
+ }
+
+ // ... and in with the new.
+ for (let url of urls) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute(
+ "label",
+ getComposeBundle().getFormattedString("blockedAllowResource", [url])
+ );
+ menuitem.setAttribute("crop", "center");
+ menuitem.setAttribute("value", url);
+ menuitem.setAttribute(
+ "oncommand",
+ "onUnblockResource(this.value, this.parentNode);"
+ );
+ aEvent.target.appendChild(menuitem);
+ }
+}
+
+/**
+ * Handle clicking the "Load <url>" in the blocked content notification bar.
+ *
+ * @param {string} aURL - the URL that was unblocked
+ * @param {Node} aNode - the node holding as value the URLs of the blocked
+ * resources in the message (space separated).
+ */
+function onUnblockResource(aURL, aNode) {
+ try {
+ loadBlockedImage(aURL);
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ } finally {
+ // Remove it from the list on success and failure.
+ let urls = aNode.value.split(" ");
+ for (let i = 0; i < urls.length; i++) {
+ if (urls[i] == aURL) {
+ urls.splice(i, 1);
+ aNode.value = urls.join(" ");
+ if (urls.length == 0) {
+ gComposeNotificationBar.clearBlockedContentNotification();
+ }
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * Convert the blocked content to a data URL and swap the src to that for the
+ * elements that were using it.
+ *
+ * @param {string} aURL - (necko) URL to unblock
+ * @param {Bool} aReturnDataURL - return data: URL instead of processing image
+ * @returns {string} the image as data: URL.
+ * @throw Error() if reading the data failed
+ */
+function loadBlockedImage(aURL, aReturnDataURL = false) {
+ let filename;
+ if (/^(file|chrome|moz-extension):/i.test(aURL)) {
+ filename = aURL.substr(aURL.lastIndexOf("/") + 1);
+ } else {
+ let fnMatch = /[?&;]filename=([^?&]+)/.exec(aURL);
+ filename = (fnMatch && fnMatch[1]) || "";
+ }
+ filename = decodeURIComponent(filename);
+ let uri = Services.io.newURI(aURL);
+ let contentType;
+ if (filename) {
+ try {
+ contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromURI(uri);
+ } catch (ex) {
+ contentType = "image/png";
+ }
+
+ if (!contentType.startsWith("image/")) {
+ // Unsafe to unblock this. It would just be garbage either way.
+ throw new Error(
+ "Won't unblock; URL=" + aURL + ", contentType=" + contentType
+ );
+ }
+ } else {
+ // Assuming image/png is the best we can do.
+ contentType = "image/png";
+ }
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let inputStream = channel.open();
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(inputStream);
+ let streamData = "";
+ try {
+ while (stream.available() > 0) {
+ streamData += stream.readBytes(stream.available());
+ }
+ } catch (e) {
+ stream.close();
+ throw new Error("Couldn't read all data from URL=" + aURL + " (" + e + ")");
+ }
+ stream.close();
+ let encoded = btoa(streamData);
+ let dataURL =
+ "data:" +
+ contentType +
+ (filename ? ";filename=" + encodeURIComponent(filename) : "") +
+ ";base64," +
+ encoded;
+
+ if (aReturnDataURL) {
+ return dataURL;
+ }
+
+ let editor = GetCurrentEditor();
+ for (let img of editor.document.images) {
+ if (img.src == aURL) {
+ img.src = dataURL; // Swap to data URL.
+ img.classList.remove("loading-internal");
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Update state of encrypted/signed toolbar buttons
+ */
+function showSendEncryptedAndSigned() {
+ let encToggle = document.getElementById("button-encryption");
+ if (encToggle) {
+ if (gSendEncrypted) {
+ encToggle.setAttribute("checked", "true");
+ } else {
+ encToggle.removeAttribute("checked");
+ }
+ }
+
+ let sigToggle = document.getElementById("button-signing");
+ if (sigToggle) {
+ if (gSendSigned) {
+ sigToggle.setAttribute("checked", "true");
+ } else {
+ sigToggle.removeAttribute("checked");
+ }
+ }
+
+ // Should button remain enabled? Identity might be unable to
+ // encrypt, but we might have kept button enabled after identity change.
+ let identityHasConfiguredSMIME =
+ isSmimeSigningConfigured() || isSmimeEncryptionConfigured();
+ let identityHasConfiguredOpenPGP = isPgpConfigured();
+ let e2eeNotConfigured =
+ !identityHasConfiguredOpenPGP && !identityHasConfiguredSMIME;
+
+ if (encToggle) {
+ encToggle.disabled = e2eeNotConfigured && !gSendEncrypted;
+ }
+ if (sigToggle) {
+ sigToggle.disabled = e2eeNotConfigured;
+ }
+}
+
+/**
+ * Look at the current encryption setting, and perform necessary
+ * automatic adjustments to related settings.
+ */
+function updateEncryptionDependencies() {
+ let canSign = gSelectedTechnologyIsPGP
+ ? isPgpConfigured()
+ : isSmimeSigningConfigured();
+
+ if (!canSign) {
+ gSendSigned = false;
+ gUserTouchedSendSigned = false;
+ } else if (!gSendEncrypted) {
+ if (!gUserTouchedSendSigned) {
+ gSendSigned = gCurrentIdentity.signMail;
+ }
+ } else if (!gUserTouchedSendSigned) {
+ gSendSigned = true;
+ }
+
+ // if (!gSendEncrypted) we don't need to change gEncryptSubject,
+ // it will be ignored anyway.
+ if (gSendEncrypted) {
+ if (!gUserTouchedEncryptSubject) {
+ gEncryptSubject = gCurrentIdentity.protectSubject;
+ }
+ }
+
+ if (!gSendSigned) {
+ if (!gUserTouchedAttachMyPubKey) {
+ gAttachMyPublicPGPKey = false;
+ }
+ } else if (!gUserTouchedAttachMyPubKey) {
+ gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey;
+ }
+
+ if (!gSendEncrypted) {
+ clearRecipPillKeyIssues();
+ }
+
+ if (gSMFields && !gSelectedTechnologyIsPGP) {
+ gSMFields.requireEncryptMessage = gSendEncrypted;
+ gSMFields.signMessage = gSendSigned;
+ }
+
+ updateAttachMyPubKey();
+
+ updateEncryptedSubject();
+ showSendEncryptedAndSigned();
+
+ updateEncryptOptionsMenuElements();
+ checkEncryptedBccRecipients();
+}
+
+/**
+ * Listen to the click events on the compose window.
+ *
+ * @param {Event} event - The DOM Event
+ */
+function composeWindowOnClick(event) {
+ // Don't deselect pills if the click happened on another pill as the selection
+ // and focus change is handled by the pill itself. We also ignore clicks on
+ // toolbarbuttons, menus, and menu items. This will also prevent the unwanted
+ // deselection when opening the context menu on macOS.
+ if (
+ event.target?.tagName == "mail-address-pill" ||
+ event.target?.tagName == "toolbarbutton" ||
+ event.target?.tagName == "menu" ||
+ event.target?.tagName == "menuitem"
+ ) {
+ return;
+ }
+
+ document.getElementById("recipientsContainer").deselectAllPills();
+}
diff --git a/comm/mail/components/compose/content/addressingWidgetOverlay.js b/comm/mail/components/compose/content/addressingWidgetOverlay.js
new file mode 100644
index 0000000000..cee4b6889e
--- /dev/null
+++ b/comm/mail/components/compose/content/addressingWidgetOverlay.js
@@ -0,0 +1,1336 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from MsgComposeCommands.js */
+/* import-globals-from ../../addrbook/content/abCommon.js */
+/* globals goDoCommand */ // From globalOverlay.js
+
+var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm");
+var { DisplayNameUtils } = ChromeUtils.import(
+ "resource:///modules/DisplayNameUtils.jsm"
+);
+
+// Temporarily prevent repeated deletion key events in address rows or subject.
+// Prevent the keyboard shortcut for removing an empty address row (long
+// Backspace or Delete keypress) from affecting another row. Also, when a long
+// deletion keypress has just removed all text or all visible text from a row
+// input, prevent the ongoing keypress from removing the row.
+var gPreventRowDeletionKeysRepeat = false;
+
+/**
+ * Convert all the written recipients into string and store them into the
+ * msgCompFields array to be printed in the message header.
+ *
+ * @param {object} msgCompFields - An object to receive the recipients.
+ */
+function Recipients2CompFields(msgCompFields) {
+ if (!msgCompFields) {
+ throw new Error(
+ "Message Compose Error: msgCompFields is null (ExtractRecipients)"
+ );
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+ for (let row of document.querySelectorAll(".address-row-raw")) {
+ let recipientType = row.dataset.recipienttype;
+ let headerValue = row.querySelector(".address-row-input").value.trim();
+ if (headerValue) {
+ msgCompFields.setRawHeader(recipientType, headerValue);
+ } else if (otherHeaders.includes(recipientType)) {
+ msgCompFields.deleteHeader(recipientType);
+ }
+ }
+
+ let getRecipientList = recipientType =>
+ Array.from(
+ document.querySelectorAll(
+ `.address-row[data-recipienttype="${recipientType}"] mail-address-pill`
+ ),
+ pill => {
+ // Expect each pill to contain exactly one address.
+ let { name, email } = MailServices.headerParser.makeFromDisplayAddress(
+ pill.fullAddress
+ )[0];
+ return MailServices.headerParser.makeMimeAddress(name, email);
+ }
+ ).join(",");
+
+ msgCompFields.to = getRecipientList("addr_to");
+ msgCompFields.cc = getRecipientList("addr_cc");
+ msgCompFields.bcc = getRecipientList("addr_bcc");
+ msgCompFields.replyTo = getRecipientList("addr_reply");
+ msgCompFields.newsgroups = getRecipientList("addr_newsgroups");
+ msgCompFields.followupTo = getRecipientList("addr_followup");
+}
+
+/**
+ * Replace the specified address row's pills with new ones generated by the
+ * given header value. The address row will be automatically shown if the header
+ * value is non-empty.
+ *
+ * @param {string} rowId - The id of the address row to set.
+ * @param {string} headerValue - The headerValue to create pills from.
+ * @param {boolean} multi - If the headerValue contains potentially multiple
+ * addresses and needs to be parsed to extract them.
+ * @param {boolean} [forceShow=false] - Whether to show the row, even if the
+ * given value is empty.
+ */
+function setAddressRowFromCompField(
+ rowId,
+ headerValue,
+ multi,
+ forceShow = false
+) {
+ let row = document.getElementById(rowId);
+ addressRowClearPills(row);
+
+ let value = multi
+ ? MailServices.headerParser.parseEncodedHeaderW(headerValue).join(", ")
+ : headerValue;
+
+ if (value || forceShow) {
+ addressRowSetVisibility(row, true);
+ }
+ if (value) {
+ let input = row.querySelector(".address-row-input");
+ input.value = value;
+ recipientAddPills(input, true);
+ }
+}
+
+/**
+ * Convert all the recipients coming from a message header into pills.
+ *
+ * @param {object} msgCompFields - An object containing all the recipients. If
+ * any property is not a string, it is ignored.
+ */
+function CompFields2Recipients(msgCompFields) {
+ if (msgCompFields) {
+ // Populate all the recipients with the proper values.
+ if (typeof msgCompFields.replyTo == "string") {
+ setAddressRowFromCompField(
+ "addressRowReply",
+ msgCompFields.replyTo,
+ true
+ );
+ }
+
+ if (typeof msgCompFields.to == "string") {
+ setAddressRowFromCompField("addressRowTo", msgCompFields.to, true);
+ }
+
+ if (typeof msgCompFields.cc == "string") {
+ setAddressRowFromCompField(
+ "addressRowCc",
+ msgCompFields.cc,
+ true,
+ gCurrentIdentity.doCc
+ );
+ }
+
+ if (typeof msgCompFields.bcc == "string") {
+ setAddressRowFromCompField(
+ "addressRowBcc",
+ msgCompFields.bcc,
+ true,
+ gCurrentIdentity.doBcc
+ );
+ }
+
+ if (typeof msgCompFields.newsgroups == "string") {
+ setAddressRowFromCompField(
+ "addressRowNewsgroups",
+ msgCompFields.newsgroups,
+ false
+ );
+ }
+
+ if (typeof msgCompFields.followupTo == "string") {
+ setAddressRowFromCompField(
+ "addressRowFollowup",
+ msgCompFields.followupTo,
+ true
+ );
+ }
+
+ // Add the sender to our spell check ignore list.
+ if (gCurrentIdentity) {
+ addRecipientsToIgnoreList(gCurrentIdentity.fullAddress);
+ }
+
+ // Trigger this method only after all the pills have been created.
+ onRecipientsChanged(true);
+ }
+}
+
+/**
+ * Update the recipients area UI to show News related fields and hide
+ * Mail related fields.
+ */
+function updateUIforNNTPAccount() {
+ // Hide the `mail-primary-input` field row if no pills have been created.
+ let mailContainer = document
+ .querySelector(".mail-primary-input")
+ .closest(".address-container");
+ if (mailContainer.querySelectorAll("mail-address-pill").length == 0) {
+ mailContainer
+ .closest(".address-row")
+ .querySelector(".remove-field-button")
+ .click();
+ }
+
+ // Show the closing label.
+ mailContainer
+ .closest(".address-row")
+ .querySelector(".remove-field-button").hidden = false;
+
+ // Show the `news-primary-input` field row if not already visible.
+ let newsContainer = document
+ .querySelector(".news-primary-input")
+ .closest(".address-row");
+ showAndFocusAddressRow(newsContainer.id);
+
+ // Hide the closing label.
+ newsContainer.querySelector(".remove-field-button").hidden = true;
+
+ // Prefer showing the buttons for news-show-row-menuitem items.
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, true);
+ }
+
+ for (let item of document.querySelectorAll(".mail-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, false);
+ }
+}
+
+/**
+ * Update the recipients area UI to show Mail related fields and hide
+ * News related fields. This method is called only if the UI was previously
+ * updated to accommodate a News account type.
+ */
+function updateUIforMailAccount() {
+ // Show the `mail-primary-input` field row if not already visible.
+ let mailContainer = document
+ .querySelector(".mail-primary-input")
+ .closest(".address-row");
+ showAndFocusAddressRow(mailContainer.id);
+
+ // Hide the closing label.
+ mailContainer.querySelector(".remove-field-button").hidden = true;
+
+ // Hide the `news-primary-input` field row if no pills have been created.
+ let newsContainer = document
+ .querySelector(".news-primary-input")
+ .closest(".address-row");
+ if (newsContainer.querySelectorAll("mail-address-pill").length == 0) {
+ newsContainer.querySelector(".remove-field-button").click();
+ }
+
+ // Show the closing label.
+ newsContainer.querySelector(".remove-field-button").hidden = false;
+
+ // Prefer showing the buttons for mail-show-row-menuitem items.
+ for (let item of document.querySelectorAll(".mail-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, true);
+ }
+
+ for (let item of document.querySelectorAll(".news-show-row-menuitem")) {
+ showAddressRowMenuItemSetPreferButton(item, false);
+ }
+}
+
+/**
+ * Remove recipient pills from a specific addressing field based on full address
+ * matching. This is commonly used to clear previous Auto-CC/BCC recipients when
+ * loading a new identity.
+ *
+ * @param {object} msgCompFields - gMsgCompose.compFields, for helper functions.
+ * @param {string} recipientType - The type of recipients to remove,
+ * e.g. "addr_to" (recipient label id).
+ * @param {string} recipientsList - Comma-separated string containing recipients
+ * to be removed. May contain display names, and other commas therein. We only
+ * remove first exact match (full address).
+ */
+function awRemoveRecipients(msgCompFields, recipientType, recipientsList) {
+ if (!recipientType || !recipientsList) {
+ return;
+ }
+
+ let container;
+ switch (recipientType) {
+ case "addr_cc":
+ container = document.getElementById("ccAddrContainer");
+ break;
+ case "addr_bcc":
+ container = document.getElementById("bccAddrContainer");
+ break;
+ case "addr_reply":
+ container = document.getElementById("replyAddrContainer");
+ break;
+ case "addr_to":
+ container = document.getElementById("toAddrContainer");
+ break;
+ }
+
+ // Convert csv string of recipients to be deleted into full addresses array.
+ let recipientsArray = msgCompFields.splitRecipients(recipientsList, false);
+
+ // Remove first instance of specified recipients from specified container.
+ for (let recipientFullAddress of recipientsArray) {
+ let pill = container.querySelector(
+ `mail-address-pill[fullAddress="${recipientFullAddress}"]`
+ );
+ if (pill) {
+ pill.remove();
+ }
+ }
+
+ let addressRow = container.closest(`.address-row`);
+
+ // Remove entire address row if empty, no user input, and not type "addr_to".
+ if (
+ recipientType != "addr_to" &&
+ !container.querySelector(`mail-address-pill`) &&
+ !container.querySelector(`input[is="autocomplete-input"]`).value
+ ) {
+ addressRowSetVisibility(addressRow, false);
+ }
+
+ updateAriaLabelsOfAddressRow(addressRow);
+}
+
+/**
+ * Adds a batch of new rows matching recipientType and drops in the list of addresses.
+ *
+ * @param msgCompFields A nsIMsgCompFields object that is only used as a helper,
+ * it will not get the addresses appended.
+ * @param recipientType Type of recipient, e.g. "addr_to".
+ * @param recipientList A string of addresses to add.
+ */
+function awAddRecipients(msgCompFields, recipientType, recipientsList) {
+ if (!msgCompFields || !recipientsList) {
+ return;
+ }
+
+ addressRowAddRecipientsArray(
+ document.querySelector(
+ `.address-row[data-recipienttype="${recipientType}"]`
+ ),
+ msgCompFields.splitRecipients(recipientsList, false)
+ );
+}
+
+/**
+ * Adds a batch of new recipient pill matching recipientType and drops in the
+ * array of addresses.
+ *
+ * @param {Element} row - The row to add the addresses to.
+ * @param {string[]} addressArray - Recipient addresses (strings) to add.
+ * @param {boolean=false} select - If the newly generated pills should be
+ * selected.
+ */
+function addressRowAddRecipientsArray(row, addressArray, select = false) {
+ let addresses = [];
+ for (let addr of addressArray) {
+ addresses.push(...MailServices.headerParser.makeFromDisplayAddress(addr));
+ }
+
+ if (row.classList.contains("hidden")) {
+ showAndFocusAddressRow(row.id, true);
+ }
+
+ let recipientArea = document.getElementById("recipientsContainer");
+ let input = row.querySelector(".address-row-input");
+ for (let address of addresses) {
+ let pill = recipientArea.createRecipientPill(input, address);
+ if (select) {
+ pill.setAttribute("selected", "selected");
+ }
+ }
+
+ row
+ .querySelector(".address-container")
+ .classList.add("addressing-field-edited");
+
+ // Add the recipients to our spell check ignore list.
+ addRecipientsToIgnoreList(addressArray.join(", "));
+ updateAriaLabelsOfAddressRow(row);
+
+ if (row.id != "addressRowReply") {
+ onRecipientsChanged();
+ }
+}
+
+/**
+ * Find the autocomplete input when an address is dropped in the compose header.
+ *
+ * @param {XULElement} target - The element where an address was dropped.
+ * @param {string} recipient - The email address dragged by the user.
+ */
+function DropRecipient(target, recipient) {
+ let row;
+ if (target.classList.contains("address-row")) {
+ row = target;
+ } else if (target.dataset.addressRow) {
+ row = document.getElementById(target.dataset.addressRow);
+ } else {
+ row = target.closest(".address-row");
+ }
+ if (!row || row.classList.contains("address-row-raw")) {
+ return;
+ }
+
+ addressRowAddRecipientsArray(row, [recipient]);
+}
+
+// Returns the load context for the current window
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+/**
+ * Focus the next available address row's input. Otherwise, focus the "Subject"
+ * input.
+ *
+ * @param {Element} currentInput - The current input to search from.
+ */
+function focusNextAddressRow(currentInput) {
+ let addressRow = currentInput.closest(".address-row").nextElementSibling;
+ while (addressRow) {
+ if (focusAddressRowInput(addressRow)) {
+ return;
+ }
+ addressRow = addressRow.nextElementSibling;
+ }
+ focusSubjectInput();
+}
+
+/**
+ * Handle keydown events for other header input fields in the compose window.
+ * Only applies to rows created from mail.compose.other.header pref; no pills.
+ * Keep behaviour in sync with addressInputOnBeforeHandleKeyDown().
+ *
+ * @param {Event} event - The DOM keydown event.
+ */
+function otherHeaderInputOnKeyDown(event) {
+ let input = event.target;
+
+ switch (event.key) {
+ case " ":
+ // If the existing input value is empty string or whitespace only,
+ // prevent entering space and clear whitespace-only input text.
+ if (!input.value.trim()) {
+ event.preventDefault();
+ input.value = "";
+ }
+ break;
+
+ case "Enter":
+ // Break if modifier keys were used, to prevent hijacking unrelated
+ // keyboard shortcuts like Ctrl/Cmd+[Shift]+Enter for sending.
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
+ break;
+ }
+
+ // Enter was pressed: Focus the next available address row or subject.
+ // Prevent Enter from firing again on the element we move the focus to.
+ event.preventDefault();
+ focusNextAddressRow(input);
+ break;
+
+ case "Backspace":
+ case "Delete":
+ if (event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated deletion keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion in case of a non-repeated deletion keydown
+ // event, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ !event.repeat ||
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]") ||
+ event.altKey
+ ) {
+ // Break if it is not a long deletion keypress, input still has text,
+ // or cursor selection is not at position 0 while deleting whitespace,
+ // to allow regular text deletion before we remove the row.
+ // Also break for non-removable rows with hidden [x] button, and if Alt
+ // key is pressed, to avoid interfering with undo shortcut Alt+Backspace.
+ break;
+ }
+ // Prevent event and set flag to prevent further unwarranted deletion in
+ // the adjacent row, which will receive focus while the key is still down.
+ event.preventDefault();
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated
+ // deletion keydown event occurred, and it has an [x] button for removal.
+ hideAddressRowFromWithin(
+ input,
+ event.key == "Backspace" ? "previous" : "next"
+ );
+ break;
+ }
+}
+
+/**
+ * Handle keydown events for autocomplete address inputs in the compose window.
+ * Does not apply to rows created from mail.compose.other.header pref, which are
+ * handled with a subset of this function in otherHeaderInputOnKeyDown().
+ *
+ * @param {Event} event - The DOM keydown event.
+ */
+function addressInputOnBeforeHandleKeyDown(event) {
+ let input = event.target;
+
+ switch (event.key) {
+ case "a":
+ // Break if there's text in the input, if not Ctrl/Cmd+A, or for other
+ // modifiers, to not hijack our own (Ctrl/Cmd+Shift+A) or OS shortcuts.
+ if (
+ input.value ||
+ !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ break;
+ }
+
+ // Ctrl/Cmd+A on empty input: Select all pills of the current row.
+ // Prevent a pill keypress event when the focus moves on it.
+ event.preventDefault();
+
+ let lastPill = input
+ .closest(".address-container")
+ .querySelector("mail-address-pill:last-of-type");
+ let mailRecipientsArea = input.closest("mail-recipients-area");
+ if (lastPill) {
+ // Select all pills of current address row.
+ mailRecipientsArea.selectSiblingPills(lastPill);
+ lastPill.focus();
+ break;
+ }
+ // No pills in the current address row, select all pills in all rows.
+ let lastPillGlobal = mailRecipientsArea.querySelector(
+ "mail-address-pill:last-of-type"
+ );
+ if (lastPillGlobal) {
+ mailRecipientsArea.selectAllPills();
+ lastPillGlobal.focus();
+ }
+ break;
+
+ case " ":
+ case ",":
+ let selection = input.value.substring(
+ input.selectionStart,
+ input.selectionEnd
+ );
+
+ // If keydown would normally replace all of the current trimmed input,
+ // including if the current input is empty, then suppress the key and
+ // clear the input instead.
+ if (selection.includes(input.value.trim())) {
+ event.preventDefault();
+ input.value = "";
+ break;
+ }
+
+ // Otherwise, comma may trigger pill creation.
+ if (event.key !== ",") {
+ break;
+ }
+
+ let beforeComma;
+ let afterComma;
+ if (input.selectionEnd == input.selectionStart) {
+ // If there is no selected text, we will try to create a pill for the
+ // text prior to the typed comma.
+ // NOTE: This also captures auto complete suggestions that are not
+ // inline. E.g. suggestion popup is shown and the user selects one with
+ // the arrow keys.
+ beforeComma = input.value.substring(0, input.selectionEnd);
+ afterComma = input.value.substring(input.selectionEnd);
+ // Only create a pill for valid addresses.
+ if (!isValidAddress(beforeComma)) {
+ break;
+ }
+ } else if (
+ // There is an auto complete suggestion ...
+ input.controller.searchStatus ==
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH &&
+ input.controller.matchCount &&
+ // that is also shown inline (the end of the input is selected).
+ input.selectionEnd == input.value.length
+ // NOTE: This should exclude cases where no suggestion is selected (user
+ // presses "DownArrow" then "UpArrow" when the suggestion pops up), or
+ // if the suggestions were cancelled with "Esc", or the inline
+ // suggestion was cleared with "Backspace".
+ ) {
+ if (input.value[input.selectionStart] == ",") {
+ // Don't create the pill in the special case where the auto-complete
+ // suggestion starts with a comma.
+ break;
+ }
+ // Complete the suggestion as a pill.
+ beforeComma = input.value;
+ afterComma = "";
+ } else {
+ // If any other part of the text is selected, we treat it as normal.
+ break;
+ }
+
+ event.preventDefault();
+ input.value = beforeComma;
+ input.handleEnter(event);
+ // Keep any left over text in the input.
+ input.value = afterComma;
+ // Keep the cursor at the same position.
+ input.selectionStart = 0;
+ input.selectionEnd = 0;
+ break;
+
+ case "Home":
+ case "ArrowLeft":
+ case "Backspace":
+ if (
+ event.key == "Backspace" &&
+ event.repeat &&
+ gPreventRowDeletionKeysRepeat
+ ) {
+ // Prevent repeated backspace keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion if Home or ArrowLeft were pressed, or if it is
+ // a non-repeated Backspace keydown event, or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ event.altKey
+ ) {
+ // Break and allow the key's default behavior if the row has content,
+ // or the cursor is not at position 0, or the Alt modifier is pressed.
+ break;
+ }
+ // Navigate into pills if there are any, and if the input is empty or
+ // whitespace-only, and the cursor is at position 0, and the Alt key was
+ // not used (prevent undo via Alt+Backspace from deleting pills).
+ // We'll sanitize whitespace on blur.
+
+ // Prevent a pill keypress event when the focus moves on it, or prevent
+ // deletion in previous row after removing current row via long keydown.
+ event.preventDefault();
+
+ let targetPill = input
+ .closest(".address-container")
+ .querySelector(
+ "mail-address-pill" + (event.key == "Home" ? "" : ":last-of-type")
+ );
+ if (targetPill) {
+ if (event.repeat) {
+ // Prevent navigating into pills for repeated keydown from the middle
+ // of whitespace.
+ break;
+ }
+ input
+ .closest("mail-recipients-area")
+ .checkKeyboardSelected(event, targetPill);
+ // Prevent removing the current row after deleting the last pill with
+ // repeated deletion keydown.
+ gPreventRowDeletionKeysRepeat = true;
+ break;
+ }
+
+ // No pill found, so the address row is empty except whitespace.
+ // Check for long Backspace keyboard shortcut to remove the row.
+ if (
+ event.key != "Backspace" ||
+ !event.repeat ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]")
+ ) {
+ break;
+ }
+ // Set flag to prevent further unwarranted deletion in the previous row,
+ // which will receive focus while the key is still down. We have already
+ // prevented the event above.
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated
+ // Backspace keydown event occurred, and it has an [x] button for removal.
+ hideAddressRowFromWithin(input, "previous");
+ break;
+
+ case "Delete":
+ if (event.repeat && gPreventRowDeletionKeysRepeat) {
+ // Prevent repeated Delete keydown event if the flag is set.
+ event.preventDefault();
+ break;
+ }
+ // Enable repeated deletion in case of a non-repeated Delete keydown event,
+ // or if the flag is already false.
+ gPreventRowDeletionKeysRepeat = false;
+
+ if (
+ !event.repeat ||
+ input.value.trim() ||
+ input.selectionStart + input.selectionEnd ||
+ input
+ .closest(".address-container")
+ .querySelector("mail-address-pill") ||
+ input
+ .closest(".address-row")
+ .querySelector(".remove-field-button[hidden]")
+ ) {
+ // Break and allow the key's default behaviour if the address row has
+ // content, or the cursor is not at position 0, or the row is not
+ // removable.
+ break;
+ }
+ // Prevent the event and set flag to prevent further unwarranted deletion
+ // in the next row, which will receive focus while the key is still down.
+ event.preventDefault();
+ gPreventRowDeletionKeysRepeat = true;
+
+ // Hide the address row if it is empty except whitespace, repeated Delete
+ // keydown event occurred, cursor is at position 0, and it has an
+ // [x] button for removal.
+ hideAddressRowFromWithin(input, "next");
+ break;
+
+ case "Enter":
+ // Break if unrelated modifier keys are used. The toolkit hack for Mac
+ // will consume metaKey, and we'll exclude shiftKey after that.
+ if (event.ctrlKey || event.altKey) {
+ break;
+ }
+
+ // MacOS-only variation necessary to send messages via Cmd+[Shift]+Enter
+ // since autocomplete input fields prevent that by default (bug 1682147).
+ if (event.metaKey) {
+ // Cmd+[Shift]+Enter: Send message [later].
+ let sendCmd = event.shiftKey ? "cmd_sendLater" : "cmd_sendWithCheck";
+ goDoCommand(sendCmd);
+ break;
+ }
+
+ // Break if there's text in the address input, or if Shift modifier is
+ // used, to prevent hijacking shortcuts like Ctrl+Shift+Enter.
+ if (input.value.trim() || event.shiftKey) {
+ break;
+ }
+
+ // Enter on empty input: Focus the next available address row or subject.
+ // Prevent Enter from firing again on the element we move the focus to.
+ event.preventDefault();
+ focusNextAddressRow(input);
+ break;
+
+ case "Tab":
+ // Return if the Alt or Cmd modifiers were pressed, meaning the user is
+ // switching between windows and not tabbing out of the address input.
+ if (event.altKey || event.metaKey) {
+ break;
+ }
+ // Trigger the autocomplete controller only if we have a value,
+ // to prevent interfering with the natural change of focus on Tab.
+ if (input.value.trim()) {
+ // Prevent Tab from firing again on address input after pill creation.
+ event.preventDefault();
+
+ // Use the setTimeout only if the input field implements a forced
+ // autocomplete and we don't have any match as we might need to wait for
+ // the autocomplete suggestions to show up.
+ if (input.forceComplete && input.mController.matchCount == 0) {
+ // Prevent fast user input to become an error pill before
+ // autocompletion kicks in with its default timeout.
+ setTimeout(() => {
+ input.handleEnter(event);
+ }, input.timeout);
+ } else {
+ input.handleEnter(event);
+ }
+ }
+
+ // Handle Shift+Tab, but not Ctrl+Shift+Tab, which is handled by
+ // moveFocusToNeighbouringAreas.
+ if (event.shiftKey && !event.ctrlKey) {
+ event.preventDefault();
+ input.closest("mail-recipients-area").moveFocusToPreviousElement(input);
+ }
+ break;
+ }
+}
+
+/**
+ * Handle input events for all types of address inputs in the compose window.
+ *
+ * @param {Event} event - A DOM input event.
+ * @param {boolean} rawInput - A flag for plain text inputs created via
+ * mail.compose.other.header, which do not have autocompletion and pills.
+ */
+function addressInputOnInput(event, rawInput) {
+ let input = event.target;
+
+ if (
+ !input.value ||
+ (!input.value.trim() &&
+ input.selectionStart + input.selectionEnd == 0 &&
+ event.inputType == "deleteContentBackward")
+ ) {
+ // Temporarily disable repeated deletion to prevent premature
+ // removal of the current row if input text has just become empty or
+ // whitespace-only with cursor at position 0 from backwards deletion.
+ gPreventRowDeletionKeysRepeat = true;
+ }
+
+ if (rawInput) {
+ // For raw inputs, we are done.
+ return;
+ }
+ // Now handling only autocomplete inputs.
+
+ // Trigger onRecipientsChanged() for every input text change in order
+ // to properly update the "Send" button and trigger the save as draft
+ // prompt even before the creation of any pill.
+ onRecipientsChanged();
+
+ // Change the min size of the input field on input change only if the
+ // current width is smaller than 80% of its container's width
+ // to prevent overflow.
+ if (
+ input.clientWidth <
+ input.closest(".address-container").clientWidth * 0.8
+ ) {
+ document
+ .getElementById("recipientsContainer")
+ .resizeInputField(input, input.value.trim().length);
+ }
+}
+
+/**
+ * Add one or more <mail-address-pill> elements to the containing address row.
+ *
+ * @param {Element} input - Address input where "autocomplete-did-enter-text"
+ * was observed, and/or to whose containing address row pill(s) will be added.
+ * @param {boolean} [automatic=false] - Set to true if the change of recipients
+ * was invoked programmatically and should not be considered a change of
+ * message content.
+ */
+function recipientAddPills(input, automatic = false) {
+ if (!input.value.trim()) {
+ return;
+ }
+
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(input.value);
+ let recipientArea = document.getElementById("recipientsContainer");
+
+ for (let address of addresses) {
+ recipientArea.createRecipientPill(input, address);
+ }
+
+ // Add the just added recipient address(es) to the spellcheck ignore list.
+ addRecipientsToIgnoreList(input.value.trim());
+
+ // Reset the input element.
+ input.removeAttribute("nomatch");
+ input.setAttribute("size", 1);
+ input.value = "";
+
+ // We need to detach the autocomplete Controller to prevent the input
+ // to be filled with the previously selected address when the "blur" event
+ // gets triggered.
+ input.detachController();
+ // If it was detached, attach it again to enable autocomplete.
+ if (!input.controller.input) {
+ input.attachController();
+ }
+
+ // Prevent triggering some methods if the pill creation was done automatically
+ // for example during the move of an existing pill between addressing fields.
+ if (!automatic) {
+ input
+ .closest(".address-container")
+ .classList.add("addressing-field-edited");
+ onRecipientsChanged();
+ }
+
+ updateAriaLabelsOfAddressRow(input.closest(".address-row"));
+}
+
+/**
+ * Remove all <mail-address-pill> elements from the containing address row.
+ *
+ * @param {Element} row - The address row to clear.
+ */
+function addressRowClearPills(row) {
+ for (let pill of row.querySelectorAll(
+ ".address-container mail-address-pill"
+ )) {
+ pill.remove();
+ }
+ updateAriaLabelsOfAddressRow(row);
+}
+
+/**
+ * Handle focus event of address inputs: Force a focused styling on the closest
+ * address container of the currently focused input element.
+ *
+ * @param {Element} input - The address input element receiving focus.
+ */
+function addressInputOnFocus(input) {
+ input.closest(".address-container").setAttribute("focused", "true");
+}
+
+/**
+ * Handle blur event of address inputs: Remove focused styling from the closest
+ * address container and create address pills if valid recipients were written.
+ *
+ * @param {Element} input - The input element losing focus.
+ */
+function addressInputOnBlur(input) {
+ input.closest(".address-container").removeAttribute("focused");
+
+ // If the input is still the active element after blur (when switching to
+ // another window), return to prevent autocompletion and pillification
+ // and let the user continue editing the address later where he left.
+ if (document.activeElement == input) {
+ return;
+ }
+
+ // For other headers aka raw input, trim and we are done.
+ if (input.getAttribute("is") != "autocomplete-input") {
+ input.value = input.value.trim();
+ return;
+ }
+
+ let address = input.value.trim();
+ if (!address) {
+ // If input is empty or whitespace only, clear input to remove any leftover
+ // whitespace, reset the input size, and return.
+ input.value = "";
+ input.setAttribute("size", 1);
+ return;
+ }
+
+ if (input.forceComplete && input.mController.matchCount >= 1) {
+ // If input.forceComplete is true and there are autocomplete matches,
+ // we need to call the inbuilt Enter handler to force the input text
+ // to the best autocomplete match because we've set input._dontBlur.
+ input.mController.handleEnter(true);
+ return;
+ }
+
+ // Otherwise, try to parse the input text as comma-separated recipients and
+ // convert them into recipient pills.
+ let listNames = MimeParser.parseHeaderField(
+ address,
+ MimeParser.HEADER_ADDRESS
+ );
+ let isMailingList =
+ listNames.length > 0 &&
+ MailServices.ab.mailListNameExists(listNames[0].name);
+
+ if (
+ address &&
+ (isValidAddress(address) ||
+ isMailingList ||
+ input.classList.contains("news-input"))
+ ) {
+ recipientAddPills(input);
+ }
+
+ // Trim any remaining input for which we didn't create a pill.
+ if (input.value.trim()) {
+ input.value = input.value.trim();
+ }
+}
+
+/**
+ * Trigger the startEditing() method of the mail-address-pill element.
+ *
+ * @param {XULlement} element - The element from which the context menu was
+ * opened.
+ * @param {Event} event - The DOM event.
+ */
+function editAddressPill(element, event) {
+ document
+ .getElementById("recipientsContainer")
+ .startEditing(element.closest("mail-address-pill"), event);
+}
+
+/**
+ * Expands all the selected mailing list pills into their composite addresses.
+ *
+ * @param {XULlement} element - The element from which the context menu was
+ * opened.
+ */
+function expandList(element) {
+ let pill = element.closest("mail-address-pill");
+ if (pill.isMailList) {
+ let addresses = [];
+ for (let currentPill of pill.parentNode.querySelectorAll(
+ "mail-address-pill"
+ )) {
+ if (currentPill == pill) {
+ let dir = MailServices.ab.getDirectory(pill.listURI);
+ if (dir) {
+ for (let card of dir.childCards) {
+ addresses.push(makeMailboxObjectFromCard(card));
+ }
+ }
+ } else {
+ addresses.push(currentPill.fullAddress);
+ }
+ }
+ let row = pill.closest(".address-row");
+ addressRowClearPills(row);
+ addressRowAddRecipientsArray(row, addresses, false);
+ }
+}
+
+/**
+ * Handle the disabling of context menu items according to the types and count
+ * of selected pills.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+function onPillPopupShowing(event) {
+ let menu = event.target;
+ // Reset previously hidden menuitems.
+ for (let menuitem of menu.querySelectorAll(
+ ".pill-action-move, .pill-action-edit"
+ )) {
+ menuitem.hidden = false;
+ }
+
+ let recipientsContainer = document.getElementById("recipientsContainer");
+
+ // Check if the pill where the context menu was originated is not selected.
+ let pill = event.explicitOriginalTarget.closest("mail-address-pill");
+ if (!pill.hasAttribute("selected")) {
+ recipientsContainer.deselectAllPills();
+ pill.setAttribute("selected", "selected");
+ }
+
+ let allSelectedPills = recipientsContainer.getAllSelectedPills();
+ // If more than one pill is selected, hide the editing item.
+ if (recipientsContainer.getAllSelectedPills().length > 1) {
+ menu.querySelector("#editAddressPill").hidden = true;
+ }
+
+ // Update the recipient type in the menu label of #menu_selectAllSiblingPills.
+ let type = pill
+ .closest(".address-row")
+ .querySelector(".address-label-container > label").value;
+ document.l10n.setAttributes(
+ menu.querySelector("#menu_selectAllSiblingPills"),
+ "pill-action-select-all-sibling-pills",
+ { type }
+ );
+
+ // Hide the `Expand List` menuitem and the preceding menuseparator if not all
+ // selected pills are mailing lists.
+ let isNotMailingList = [...allSelectedPills].some(pill => !pill.isMailList);
+ menu.querySelector("#expandList").hidden = isNotMailingList;
+ menu.querySelector("#pillContextBeforeExpandListSeparator").hidden =
+ isNotMailingList;
+
+ // If any Newsgroup or Followup pill is selected, hide all move actions.
+ if (
+ recipientsContainer.querySelector(
+ ":is(#addressRowNewsgroups, #addressRowFollowup) " +
+ "mail-address-pill[selected]"
+ )
+ ) {
+ for (let menuitem of menu.querySelectorAll(".pill-action-move")) {
+ menuitem.hidden = true;
+ }
+ // Hide the menuseparator before the move items, as there's nothing below.
+ menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = true;
+ return;
+ }
+ // Show the menuseparator before the move items as no Newsgroup or Followup
+ // pill is selected.
+ menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = false;
+
+ let selectedType = "";
+ // Check if all selected pills are in the same address row.
+ for (let row of recipientsContainer.querySelectorAll(
+ ".address-row:not(.hidden)"
+ )) {
+ // Check if there's at least one selected pill in the address row.
+ let selectedPill = row.querySelector("mail-address-pill[selected]");
+ if (!selectedPill) {
+ continue;
+ }
+ // Return if we already have a selectedType: More than one type selected.
+ if (selectedType) {
+ return;
+ }
+ selectedType = row.dataset.recipienttype;
+ }
+
+ // All selected pills are of the same type, hide the type's move action.
+ switch (selectedType) {
+ case "addr_to":
+ menu.querySelector("#moveAddressPillTo").hidden = true;
+ break;
+
+ case "addr_cc":
+ menu.querySelector("#moveAddressPillCc").hidden = true;
+ break;
+
+ case "addr_bcc":
+ menu.querySelector("#moveAddressPillBcc").hidden = true;
+ break;
+ }
+}
+
+/**
+ * Show the specified address row and focus its input. If showing the address
+ * row is disabled, the focus is not changed.
+ *
+ * @param {string} rowId - The id of the row to show.
+ */
+function showAndFocusAddressRow(rowId) {
+ let row = document.getElementById(rowId);
+ if (addressRowSetVisibility(row, true)) {
+ row.querySelector(".address-row-input").focus();
+ }
+}
+
+/**
+ * Set the visibility of an address row (Cc, Bcc, etc.).
+ *
+ * @param {Element} row - The address row.
+ * @param {boolean} [show=true] - Whether to show the row or hide it.
+ *
+ * @returns {boolean} - Whether the visibility was set.
+ */
+function addressRowSetVisibility(row, show) {
+ let menuItem = document.getElementById(row.dataset.showSelfMenuitem);
+ if (show && menuItem.hasAttribute("disabled")) {
+ return false;
+ }
+
+ // Show/hide the row and hide/show the menuitem or button
+ row.classList.toggle("hidden", !show);
+ showAddressRowMenuItemSetVisibility(menuItem, !show);
+ return true;
+}
+
+/**
+ * Set the visibility of a menu item that shows an address row.
+ *
+ * @param {Element} menuItem - The menu item.
+ * @param {boolean} [show=true] - Whether to show the item or hide it.
+ */
+function showAddressRowMenuItemSetVisibility(menuItem, show) {
+ let buttonId = menuItem.dataset.buttonId;
+ let button = buttonId && document.getElementById(buttonId);
+ if (button && menuItem.dataset.preferButton == "true") {
+ button.hidden = !show;
+ // Make sure the menuItem is never shown.
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = !show;
+ if (button) {
+ button.hidden = true;
+ }
+ }
+
+ updateRecipientsVisibility();
+}
+
+/**
+ * Set whether a menu item that shows an address row should prefer being
+ * displayed as the button specified by its "data-button-id" attribute, if it
+ * has one.
+ *
+ * @param {Element} menuItem - The menu item.
+ * @param {boolean} preferButton - Whether to prefer showing the button rather
+ * than the menu item.
+ */
+function showAddressRowMenuItemSetPreferButton(menuItem, preferButton) {
+ let buttonId = menuItem.dataset.buttonId;
+ if (!buttonId || menuItem.dataset.preferButton == String(preferButton)) {
+ return;
+ }
+ let button = document.getElementById(buttonId);
+
+ menuItem.dataset.preferButton = preferButton;
+ if (preferButton) {
+ button.hidden = menuItem.hidden;
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = button.hidden;
+ button.hidden = true;
+ }
+
+ updateRecipientsVisibility();
+}
+
+/**
+ * Hide or show the menu button for the extra recipients based on the current
+ * hidden status of menuitems and buttons.
+ */
+function updateRecipientsVisibility() {
+ document.getElementById("extraAddressRowsMenuButton").hidden =
+ !document.querySelector("#extraAddressRowsMenu > :not([hidden])");
+
+ let buttonbox = document.getElementById("extraAddressRowsArea");
+ // Toggle the class to show/hide the pseudo element separator
+ // of the msgIdentity field.
+ buttonbox.classList.toggle(
+ "addressingWidget-separator",
+ !!buttonbox.querySelector("button:not([hidden])")
+ );
+}
+
+/**
+ * Hide the container row of a recipient (Cc, Bcc, etc.).
+ * The container can't be hidden if previously typed addresses are listed.
+ *
+ * @param {Element} element - A descendant element of the row to be hidden (or
+ * the row itself), usually the [x] label when triggered, or an empty address
+ * input upon Backspace or Del keydown.
+ * @param {("next"|"previous")} [focusType="next"] - How to move focus after
+ * hiding the address row: try to focus the input of an available next sibling
+ * row (for [x] or DEL) or previous sibling row (for BACKSPACE).
+ */
+function hideAddressRowFromWithin(element, focusType = "next") {
+ let addressRow = element.closest(".address-row");
+
+ // Prevent address row removal when sending (disable-on-send).
+ if (
+ addressRow
+ .querySelector(".address-container")
+ .classList.contains("disable-container")
+ ) {
+ return;
+ }
+
+ let pills = addressRow.querySelectorAll("mail-address-pill");
+ let isEdited = addressRow
+ .querySelector(".address-container")
+ .classList.contains("addressing-field-edited");
+
+ // Ask the user to confirm the removal of all the typed addresses if the field
+ // holds addressing pills and has been previously edited.
+ if (isEdited && pills.length) {
+ let fieldName = addressRow.querySelector(
+ ".address-label-container > label"
+ );
+ let confirmTitle = getComposeBundle().getFormattedString(
+ "confirmRemoveRecipientRowTitle2",
+ [fieldName.value]
+ );
+ let confirmBody = getComposeBundle().getFormattedString(
+ "confirmRemoveRecipientRowBody2",
+ [fieldName.value]
+ );
+ let confirmButton = getComposeBundle().getString(
+ "confirmRemoveRecipientRowButton"
+ );
+
+ let result = Services.prompt.confirmEx(
+ window,
+ confirmTitle,
+ confirmBody,
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL,
+ confirmButton,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (result == 1) {
+ return;
+ }
+ }
+
+ for (let pill of pills) {
+ pill.remove();
+ }
+
+ // Reset the original input.
+ let input = addressRow.querySelector(".address-row-input");
+ input.value = "";
+
+ addressRowSetVisibility(addressRow, false);
+
+ // Update the Send button only if the content was previously changed.
+ if (isEdited) {
+ onRecipientsChanged(true);
+ }
+ updateAriaLabelsOfAddressRow(addressRow);
+
+ // Move focus to the next focusable address input field.
+ let addressRowSibling =
+ focusType == "next"
+ ? getNextSibling(addressRow, ".address-row:not(.hidden)")
+ : getPreviousSibling(addressRow, ".address-row:not(.hidden)");
+
+ if (addressRowSibling) {
+ addressRowSibling.querySelector(".address-row-input").focus();
+ return;
+ }
+ // Otherwise move focus to the subject field or to the first available input.
+ let fallbackFocusElement =
+ focusType == "next"
+ ? document.getElementById("msgSubject")
+ : getNextSibling(addressRow, ".address-row:not(.hidden)").querySelector(
+ ".address-row-input"
+ );
+ fallbackFocusElement.focus();
+}
+
+/**
+ * Handle the click event on the close label of an address row.
+ *
+ * @param {Event} event - The DOM click event.
+ */
+function closeLabelOnClick(event) {
+ hideAddressRowFromWithin(event.target);
+}
+
+function extraAddressRowsMenuOpened() {
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .setAttribute("aria-expanded", "true");
+}
+
+function extraAddressRowsMenuClosed() {
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .setAttribute("aria-expanded", "false");
+}
+
+/**
+ * Show the menu for extra address rows (extraAddressRowsMenu).
+ */
+function openExtraAddressRowsMenu() {
+ let button = document.getElementById("extraAddressRowsMenuButton");
+ let menu = document.getElementById("extraAddressRowsMenu");
+ // NOTE: menu handlers handle the aria-expanded state of the button.
+ menu.openPopup(button, "after_end", 8, 0);
+}
diff --git a/comm/mail/components/compose/content/bigFileObserver.js b/comm/mail/components/compose/content/bigFileObserver.js
new file mode 100644
index 0000000000..f741af7afa
--- /dev/null
+++ b/comm/mail/components/compose/content/bigFileObserver.js
@@ -0,0 +1,368 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements */
+
+/* import-globals-from MsgComposeCommands.js */
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+var kUploadNotificationValue = "bigAttachmentUploading";
+var kPrivacyWarningNotificationValue = "bigAttachmentPrivacyWarning";
+
+var gBigFileObserver = {
+ bigFiles: [],
+ sessionHidden: false,
+ privacyWarned: false,
+
+ get hidden() {
+ return (
+ this.sessionHidden ||
+ !Services.prefs.getBoolPref("mail.cloud_files.enabled") ||
+ !Services.prefs.getBoolPref("mail.compose.big_attachments.notify") ||
+ Services.io.offline
+ );
+ },
+
+ hide(aPermanent) {
+ if (aPermanent) {
+ Services.prefs.setBoolPref("mail.compose.big_attachments.notify", false);
+ } else {
+ this.sessionHidden = true;
+ }
+ },
+
+ init() {
+ let bucket = document.getElementById("attachmentBucket");
+ bucket.addEventListener("attachments-added", this);
+ bucket.addEventListener("attachments-removed", this);
+ bucket.addEventListener("attachment-converted-to-regular", this);
+ bucket.addEventListener("attachment-uploading", this);
+ bucket.addEventListener("attachment-uploaded", this);
+ bucket.addEventListener("attachment-upload-failed", this);
+
+ this.sessionHidden = false;
+ this.privacyWarned = false;
+ this.bigFiles = [];
+ },
+
+ handleEvent(event) {
+ if (this.hidden) {
+ return;
+ }
+
+ switch (event.type) {
+ case "attachments-added":
+ this.bigFileTrackerAdd(event.detail);
+ break;
+ case "attachments-removed":
+ this.bigFileTrackerRemove(event.detail);
+ this.checkAndHidePrivacyNotification();
+ break;
+ case "attachment-converted-to-regular":
+ this.checkAndHidePrivacyNotification();
+ break;
+ case "attachment-uploading":
+ // Remove the currently uploading item from bigFiles, to remove the big
+ // file notification already during upload.
+ this.bigFileTrackerRemove([event.detail]);
+ this.updateUploadingNotification();
+ break;
+ case "attachment-upload-failed":
+ this.updateUploadingNotification();
+ break;
+ case "attachment-uploaded":
+ this.updateUploadingNotification();
+ if (this.uploadsInProgress == 0) {
+ this.showPrivacyNotification();
+ }
+ break;
+ default:
+ // Do not update the notification for other events.
+ return;
+ }
+
+ this.updateBigFileNotification();
+ },
+
+ bigFileTrackerAdd(aAttachments) {
+ let threshold =
+ Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") *
+ 1024;
+
+ for (let attachment of aAttachments) {
+ if (attachment.size >= threshold && !attachment.sendViaCloud) {
+ this.bigFiles.push(attachment);
+ }
+ }
+ },
+
+ bigFileTrackerRemove(aAttachments) {
+ for (let attachment of aAttachments) {
+ let index = this.bigFiles.findIndex(e => e.url == attachment.url);
+ if (index != -1) {
+ this.bigFiles.splice(index, 1);
+ }
+ }
+ },
+
+ formatString(key, replacements, plural) {
+ let str = getComposeBundle().getString(key);
+ if (plural !== undefined) {
+ str = PluralForm.get(plural, str);
+ }
+ if (replacements !== undefined) {
+ for (let i = 0; i < replacements.length; i++) {
+ str = str.replace("#" + (i + 1), replacements[i]);
+ }
+ }
+ return str;
+ },
+
+ updateBigFileNotification() {
+ let bigFileNotification =
+ gComposeNotification.getNotificationWithValue("bigAttachment");
+ if (this.bigFiles.length) {
+ if (bigFileNotification) {
+ bigFileNotification.label = this.formatString(
+ "bigFileDescription",
+ [this.bigFiles.length],
+ this.bigFiles.length
+ );
+ return;
+ }
+
+ let buttons = [
+ {
+ label: getComposeBundle().getString("learnMore.label"),
+ accessKey: getComposeBundle().getString("learnMore.accesskey"),
+ callback: this.openLearnMore.bind(this),
+ },
+ {
+ label: this.formatString("bigFileShare.label", []),
+ accessKey: this.formatString("bigFileShare.accesskey"),
+ callback: this.convertAttachments.bind(this),
+ },
+ {
+ label: this.formatString("bigFileAttach.label", []),
+ accessKey: this.formatString("bigFileAttach.accesskey"),
+ callback: this.hideBigFileNotification.bind(this),
+ },
+ ];
+
+ let msg = this.formatString(
+ "bigFileDescription",
+ [this.bigFiles.length],
+ this.bigFiles.length
+ );
+
+ bigFileNotification = gComposeNotification.appendNotification(
+ "bigAttachment",
+ {
+ label: msg,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+ } else if (bigFileNotification) {
+ gComposeNotification.removeNotification(bigFileNotification);
+ }
+ },
+
+ openLearnMore() {
+ let url = Services.prefs.getCharPref("mail.cloud_files.learn_more_url");
+ openContentTab(url);
+ return true;
+ },
+
+ convertAttachments() {
+ let account;
+ let accounts = cloudFileAccounts.configuredAccounts;
+
+ if (accounts.length == 1) {
+ account = accounts[0];
+ } else if (accounts.length > 1) {
+ // We once used Services.prompt.select for this UI, but it doesn't support displaying an
+ // icon for each item. The following code does the same thing with a replacement dialog.
+ let { PromptUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromptUtils.sys.mjs"
+ );
+
+ let names = accounts.map(i => cloudFileAccounts.getDisplayName(i));
+ let icons = accounts.map(i => i.iconURL);
+ let args = {
+ promptType: "select",
+ title: this.formatString("bigFileChooseAccount.title"),
+ text: this.formatString("bigFileChooseAccount.text"),
+ list: names,
+ icons,
+ selected: -1,
+ ok: false,
+ };
+
+ let propBag = PromptUtils.objectToPropBag(args);
+ openDialog(
+ "chrome://messenger/content/cloudfile/selectDialog.xhtml",
+ "_blank",
+ "centerscreen,chrome,modal,titlebar",
+ propBag
+ );
+ PromptUtils.propBagToObject(propBag, args);
+
+ if (args.ok) {
+ account = accounts[args.selected];
+ }
+ } else {
+ openPreferencesTab("paneCompose", "compositionAttachmentsCategory");
+ return true;
+ }
+
+ if (account) {
+ convertToCloudAttachment(this.bigFiles, account);
+ }
+
+ return false;
+ },
+
+ hideBigFileNotification() {
+ let never = {};
+ if (
+ Services.prompt.confirmCheck(
+ window,
+ this.formatString("bigFileHideNotification.title"),
+ this.formatString("bigFileHideNotification.text"),
+ this.formatString("bigFileHideNotification.check"),
+ never
+ )
+ ) {
+ this.hide(never.value);
+ return false;
+ }
+ return true;
+ },
+
+ updateUploadingNotification() {
+ // We will show the uploading notification for a minimum of 2.5 seconds
+ // seconds.
+ const kThreshold = 2500; // milliseconds
+
+ if (
+ !Services.prefs.getBoolPref(
+ "mail.compose.big_attachments.insert_notification"
+ )
+ ) {
+ return;
+ }
+
+ let activeUploads = this.uploadsInProgress;
+ let notification = gComposeNotification.getNotificationWithValue(
+ kUploadNotificationValue
+ );
+
+ if (activeUploads == 0) {
+ if (notification) {
+ // Check the timestamp that we stashed in the timeout field of the
+ // notification...
+ let now = Date.now();
+ if (now >= notification.timeout) {
+ gComposeNotification.removeNotification(notification);
+ } else {
+ setTimeout(function () {
+ gComposeNotification.removeNotification(notification);
+ }, notification.timeout - now);
+ }
+ }
+ return;
+ }
+
+ let message = this.formatString("cloudFileUploadingNotification");
+ message = PluralForm.get(activeUploads, message);
+
+ if (notification) {
+ notification.label = message;
+ return;
+ }
+
+ let showUploadButton = {
+ accessKey: this.formatString(
+ "stopShowingUploadingNotification.accesskey"
+ ),
+ label: this.formatString("stopShowingUploadingNotification.label"),
+ callback(aNotificationBar, aButton) {
+ Services.prefs.setBoolPref(
+ "mail.compose.big_attachments.insert_notification",
+ false
+ );
+ },
+ };
+ notification = gComposeNotification.appendNotification(
+ kUploadNotificationValue,
+ {
+ label: message,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ [showUploadButton]
+ );
+ notification.timeout = Date.now() + kThreshold;
+ },
+
+ hidePrivacyNotification() {
+ this.privacyWarned = false;
+ let notification = gComposeNotification.getNotificationWithValue(
+ kPrivacyWarningNotificationValue
+ );
+
+ if (notification) {
+ gComposeNotification.removeNotification(notification);
+ }
+ },
+
+ checkAndHidePrivacyNotification() {
+ if (
+ !gAttachmentBucket.itemChildren.find(
+ item => item.attachment && item.attachment.sendViaCloud
+ )
+ ) {
+ this.hidePrivacyNotification();
+ }
+ },
+
+ showPrivacyNotification() {
+ if (this.privacyWarned) {
+ return;
+ }
+ this.privacyWarned = true;
+
+ let notification = gComposeNotification.getNotificationWithValue(
+ kPrivacyWarningNotificationValue
+ );
+
+ if (notification) {
+ return;
+ }
+
+ let message = this.formatString("cloudFilePrivacyNotification");
+ gComposeNotification.appendNotification(
+ kPrivacyWarningNotificationValue,
+ {
+ label: message,
+ priority: gComposeNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ },
+
+ get uploadsInProgress() {
+ let items = [...document.getElementById("attachmentBucket").itemChildren];
+ return items.filter(e => e.uploading).length;
+ },
+};
+
+window.addEventListener(
+ "compose-window-init",
+ gBigFileObserver.init.bind(gBigFileObserver),
+ true
+);
diff --git a/comm/mail/components/compose/content/cloudAttachmentLinkManager.js b/comm/mail/components/compose/content/cloudAttachmentLinkManager.js
new file mode 100644
index 0000000000..9693f1aa8d
--- /dev/null
+++ b/comm/mail/components/compose/content/cloudAttachmentLinkManager.js
@@ -0,0 +1,758 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from MsgComposeCommands.js */
+
+let { MsgUtils } = ChromeUtils.import(
+ "resource:///modules/MimeMessageUtils.jsm"
+);
+
+var gCloudAttachmentLinkManager = {
+ init() {
+ this.cloudAttachments = [];
+
+ let bucket = document.getElementById("attachmentBucket");
+ bucket.addEventListener("attachments-removed", this);
+ bucket.addEventListener("attachment-converted-to-regular", this);
+ bucket.addEventListener("attachment-uploaded", this);
+ bucket.addEventListener("attachment-moved", this);
+ bucket.addEventListener("attachment-renamed", this);
+
+ // If we're restoring a draft that has some attachments,
+ // check to see if any of them are marked to be sent via
+ // cloud, and if so, add them to our list.
+ for (let i = 0; i < bucket.getRowCount(); ++i) {
+ let attachment = bucket.getItemAtIndex(i).attachment;
+ if (attachment && attachment.sendViaCloud) {
+ this.cloudAttachments.push(attachment);
+ }
+ }
+
+ gMsgCompose.RegisterStateListener(this);
+ },
+
+ NotifyComposeFieldsReady() {},
+ NotifyComposeBodyReady() {},
+ ComposeProcessDone() {},
+ SaveInFolderDone() {},
+
+ async handleEvent(event) {
+ let mailDoc = document.getElementById("messageEditor").contentDocument;
+
+ if (
+ event.type == "attachment-renamed" ||
+ event.type == "attachment-moved"
+ ) {
+ let cloudFileUpload = event.target.cloudFileUpload;
+ let items = [];
+
+ let list = mailDoc.getElementById("cloudAttachmentList");
+ if (list) {
+ items = list.getElementsByClassName("cloudAttachmentItem");
+ }
+
+ for (let item of items) {
+ // The original attachment is stored in the events detail property.
+ if (item.dataset.contentLocation == event.detail.contentLocation) {
+ item.replaceWith(await this._createNode(mailDoc, cloudFileUpload));
+ }
+ }
+ if (event.type == "attachment-moved") {
+ await this._updateServiceProviderLinks(mailDoc);
+ }
+ } else if (event.type == "attachment-uploaded") {
+ if (this.cloudAttachments.length == 0) {
+ this._insertHeader(mailDoc);
+ }
+
+ let cloudFileUpload = event.target.cloudFileUpload;
+ let attachment = event.target.attachment;
+ this.cloudAttachments.push(attachment);
+ await this._insertItem(mailDoc, cloudFileUpload);
+ } else if (
+ event.type == "attachments-removed" ||
+ event.type == "attachment-converted-to-regular"
+ ) {
+ let items = [];
+ let list = mailDoc.getElementById("cloudAttachmentList");
+ if (list) {
+ items = list.getElementsByClassName("cloudAttachmentItem");
+ }
+
+ let attachments = Array.isArray(event.detail)
+ ? event.detail
+ : [event.detail];
+ for (let attachment of attachments) {
+ // Remove the attachment from the message body.
+ if (list) {
+ for (let item of items) {
+ if (item.dataset.contentLocation == attachment.contentLocation) {
+ item.remove();
+ }
+ }
+ }
+
+ // Now, remove the attachment from our internal list.
+ let index = this.cloudAttachments.indexOf(attachment);
+ if (index != -1) {
+ this.cloudAttachments.splice(index, 1);
+ }
+ }
+
+ await this._updateAttachmentCount(mailDoc);
+ await this._updateServiceProviderLinks(mailDoc);
+
+ if (items.length == 0) {
+ if (list) {
+ list.remove();
+ }
+ this._removeRoot(mailDoc);
+ }
+ }
+ },
+
+ /**
+ * Removes the root node for an attachment list in an HTML email.
+ *
+ * @param {Document} aDocument - the document to remove the root node from
+ */
+ _removeRoot(aDocument) {
+ let header = aDocument.getElementById("cloudAttachmentListRoot");
+ if (header) {
+ header.remove();
+ }
+ },
+
+ /**
+ * Given some node, returns the textual HTML representation for the node
+ * and its children.
+ *
+ * @param {Document} aDocument - the document that the node is embedded in
+ * @param {DOMNode} aNode - the node to get the textual representation from
+ */
+ _getHTMLRepresentation(aDocument, aNode) {
+ let tmp = aDocument.createElement("p");
+ tmp.appendChild(aNode);
+ return tmp.innerHTML;
+ },
+
+ /**
+ * Returns the plain text equivalent of the given HTML markup, ready to be
+ * inserted into a compose editor.
+ *
+ * @param {string} aMarkup - the HTML markup that should be converted
+ */
+ _getTextRepresentation(aMarkup) {
+ return MsgUtils.convertToPlainText(aMarkup, true).replaceAll("\r\n", "\n");
+ },
+
+ /**
+ * Generates an appropriately styled link.
+ *
+ * @param {Document} aDocument - the document to append the link to - doesn't
+ * actually get appended, but is used to generate the anchor node
+ * @param {string} aContent - the textual content of the link
+ * @param {string} aHref - the HREF attribute for the generated link
+ * @param {string} aColor - the CSS color string for the link
+ */
+ _generateLink(aDocument, aContent, aHref, aColor) {
+ let link = aDocument.createElement("a");
+ link.href = aHref;
+ link.textContent = aContent;
+ link.style.cssText = `color: ${aColor} !important`;
+ return link;
+ },
+
+ _findInsertionPoint(aDocument) {
+ let mailBody = aDocument.querySelector("body");
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+
+ let childNodes = mailBody.childNodes;
+ let childToInsertAfter, childIndex;
+
+ // First, search for any text nodes that are immediate children of
+ // the body. If we find any, we'll insert after those.
+ for (childIndex = childNodes.length - 1; childIndex >= 0; childIndex--) {
+ if (childNodes[childIndex].nodeType == Node.TEXT_NODE) {
+ childToInsertAfter = childNodes[childIndex];
+ break;
+ }
+ }
+
+ if (childIndex != -1) {
+ selection.collapse(
+ childToInsertAfter,
+ childToInsertAfter.nodeValue ? childToInsertAfter.nodeValue.length : 0
+ );
+ if (
+ childToInsertAfter.nodeValue &&
+ childToInsertAfter.nodeValue.length > 0
+ ) {
+ editor.insertLineBreak();
+ }
+ editor.insertLineBreak();
+ return;
+ }
+
+ // If there's a signature, let's get a hold of it now.
+ let signature = mailBody.querySelector(".moz-signature");
+
+ // Are we replying?
+ let replyCitation = mailBody.querySelector(".moz-cite-prefix");
+ if (replyCitation) {
+ if (gCurrentIdentity && gCurrentIdentity.replyOnTop == 0) {
+ // Replying below quote - we'll select the point right before
+ // the signature. If there's no signature, we'll just use the
+ // last node.
+ if (signature && signature.previousSibling) {
+ selection.collapse(
+ mailBody,
+ Array.from(childNodes).indexOf(signature.previousSibling)
+ );
+ } else {
+ selection.collapse(mailBody, childNodes.length - 1);
+ editor.insertLineBreak();
+
+ if (!gMsgCompose.composeHTML) {
+ editor.insertLineBreak();
+ }
+
+ selection.collapse(mailBody, childNodes.length - 2);
+ }
+ } else if (replyCitation.previousSibling) {
+ // Replying above quote
+ let nodeIndex = Array.from(childNodes).indexOf(
+ replyCitation.previousSibling
+ );
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ selection.collapse(mailBody, nodeIndex);
+ } else {
+ editor.beginningOfDocument();
+ editor.insertLineBreak();
+ }
+ return;
+ }
+
+ // Are we forwarding?
+ let forwardBody = mailBody.querySelector(".moz-forward-container");
+ if (forwardBody) {
+ if (forwardBody.previousSibling) {
+ let nodeIndex = Array.from(childNodes).indexOf(
+ forwardBody.previousSibling
+ );
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ // If we're forwarding, insert just before the forward body.
+ selection.collapse(mailBody, nodeIndex);
+ } else {
+ // Just insert after a linebreak at the top.
+ editor.beginningOfDocument();
+ editor.insertLineBreak();
+ selection.collapse(mailBody, 1);
+ }
+ return;
+ }
+
+ // If we haven't figured it out at this point, let's see if there's a
+ // signature, and just insert before it.
+ if (signature && signature.previousSibling) {
+ let nodeIndex = Array.from(childNodes).indexOf(signature.previousSibling);
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ selection.collapse(mailBody, nodeIndex);
+ return;
+ }
+
+ // If we haven't figured it out at this point, let's just put it
+ // at the bottom of the message body. If the "bottom" is also the top,
+ // then we'll insert a linebreak just above it.
+ let nodeIndex = childNodes.length - 1;
+ if (nodeIndex <= 0) {
+ editor.insertLineBreak();
+ nodeIndex = 1;
+ }
+ selection.collapse(mailBody, nodeIndex);
+ },
+
+ /**
+ * Attempts to find any elements with an id in aIDs, and sets those elements
+ * id attribute to the empty string, freeing up the ids for later use.
+ *
+ * @param {Document} aDocument - the document to search for the elements
+ * @param {string[]} aIDs - an array of id strings
+ */
+ _resetNodeIDs(aDocument, aIDs) {
+ for (let id of aIDs) {
+ let node = aDocument.getElementById(id);
+ if (node) {
+ node.id = "";
+ }
+ }
+ },
+
+ /**
+ * Insert the header for the cloud attachment list, which we'll use to
+ * as an insertion point for the individual cloud attachments.
+ *
+ * @param {Document} aDocument - the document to insert the header into
+ */
+ _insertHeader(aDocument) {
+ // If there already exists a cloudAttachmentListRoot,
+ // cloudAttachmentListHeader, cloudAttachmentListFooter or
+ // cloudAttachmentList in the document, strip them of their IDs so that we
+ // don't conflict with them.
+ this._resetNodeIDs(aDocument, [
+ "cloudAttachmentListRoot",
+ "cloudAttachmentListHeader",
+ "cloudAttachmentList",
+ "cloudAttachmentListFooter",
+ ]);
+
+ let editor = GetCurrentEditor();
+ let selection = editor.selection;
+ let originalAnchor = selection.anchorNode;
+ let originalOffset = selection.anchorOffset;
+
+ // Save off the selection ranges so we can restore them later.
+ let ranges = [];
+ for (let i = 0; i < selection.rangeCount; i++) {
+ ranges.push(selection.getRangeAt(i));
+ }
+
+ this._findInsertionPoint(aDocument);
+
+ let root = editor.createElementWithDefaults("div");
+ let header = editor.createElementWithDefaults("div");
+ let list = editor.createElementWithDefaults("div");
+ let footer = editor.createElementWithDefaults("div");
+
+ if (gMsgCompose.composeHTML) {
+ root.style.padding = "15px";
+ root.style.backgroundColor = "#D9EDFF";
+
+ header.style.marginBottom = "15px";
+
+ list = editor.createElementWithDefaults("ul");
+ list.style.backgroundColor = "#FFFFFF";
+ list.style.padding = "15px";
+ list.style.listStyleType = "none";
+ list.display = "inline-block";
+ }
+
+ root.id = "cloudAttachmentListRoot";
+ header.id = "cloudAttachmentListHeader";
+ list.id = "cloudAttachmentList";
+ footer.id = "cloudAttachmentListFooter";
+
+ // It's really quite strange, but if we don't set
+ // the innerHTML of each element to be non-empty, then
+ // the nodes fail to be added to the compose window.
+ root.innerHTML = " ";
+ header.innerHTML = " ";
+ list.innerHTML = " ";
+ footer.innerHTML = " ";
+
+ root.appendChild(header);
+ root.appendChild(list);
+ root.appendChild(footer);
+ editor.insertElementAtSelection(root, false);
+ if (!root.previousSibling || root.previousSibling.localName == "span") {
+ root.parentNode.insertBefore(editor.document.createElement("br"), root);
+ }
+
+ // Remove the space, which would end up in the plain text converted
+ // version.
+ list.innerHTML = "";
+ selection.collapse(originalAnchor, originalOffset);
+
+ // Restore the selection ranges.
+ for (let range of ranges) {
+ selection.addRange(range);
+ }
+ },
+
+ /**
+ * Updates the count of how many attachments have been added
+ * in HTML emails.
+ *
+ * @param {Document} aDocument - the document that contains the header node
+ */
+ async _updateAttachmentCount(aDocument) {
+ let header = aDocument.getElementById("cloudAttachmentListHeader");
+ if (!header) {
+ return;
+ }
+
+ let entries = aDocument.querySelectorAll(
+ "#cloudAttachmentList > .cloudAttachmentItem"
+ );
+
+ header.textContent = await l10nCompose.formatValue(
+ "cloud-file-count-header",
+ {
+ count: entries.length,
+ }
+ );
+ },
+
+ /**
+ * Updates the service provider links in the footer.
+ *
+ * @param {Document} aDocument - the document that contains the footer node
+ */
+ async _updateServiceProviderLinks(aDocument) {
+ let footer = aDocument.getElementById("cloudAttachmentListFooter");
+ if (!footer) {
+ return;
+ }
+
+ let providers = [];
+ let entries = aDocument.querySelectorAll(
+ "#cloudAttachmentList > .cloudAttachmentItem"
+ );
+ for (let entry of entries) {
+ if (!entry.dataset.serviceUrl) {
+ continue;
+ }
+
+ let link_markup = this._generateLink(
+ aDocument,
+ entry.dataset.serviceName,
+ entry.dataset.serviceUrl,
+ "dark-grey"
+ ).outerHTML;
+
+ if (!providers.includes(link_markup)) {
+ providers.push(link_markup);
+ }
+ }
+
+ let content = "";
+ if (providers.length == 1) {
+ content = await l10nCompose.formatValue(
+ "cloud-file-service-provider-footer-single",
+ {
+ link: providers[0],
+ }
+ );
+ } else if (providers.length > 1) {
+ let lastLink = providers.pop();
+ let firstLinks = providers.join(", ");
+ content = await l10nCompose.formatValue(
+ "cloud-file-service-provider-footer-multiple",
+ {
+ firstLinks,
+ lastLink,
+ }
+ );
+ }
+
+ if (gMsgCompose.composeHTML) {
+ // eslint-disable-next-line no-unsanitized/property
+ footer.innerHTML = content;
+ } else {
+ footer.textContent = this._getTextRepresentation(content);
+ }
+ },
+
+ /**
+ * Insert the information for a cloud attachment.
+ *
+ * @param {Document} aDocument - the document to insert the item into
+ * @param {CloudFileTemplate} aCloudFileUpload - object with information about
+ * the uploaded file
+ */
+ async _insertItem(aDocument, aCloudFileUpload) {
+ let list = aDocument.getElementById("cloudAttachmentList");
+
+ if (!list) {
+ this._insertHeader(aDocument);
+ list = aDocument.getElementById("cloudAttachmentList");
+ }
+ list.appendChild(await this._createNode(aDocument, aCloudFileUpload));
+ await this._updateAttachmentCount(aDocument);
+ await this._updateServiceProviderLinks(aDocument);
+ },
+
+ /**
+ * @typedef CloudFileDate
+ * @property {integer} timestamp - milliseconds since epoch
+ * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat
+ */
+
+ /**
+ * @typedef CloudFileTemplate
+ * @property {string} serviceName - name of the upload service provider
+ * @property {string} serviceIcon - icon of the upload service provider
+ * @property {string} serviceUrl - web interface of the upload service provider
+ * @property {boolean} downloadPasswordProtected - link is password protected
+ * @property {integer} downloadLimit - download limit of the link
+ * @property {CloudFileDate} downloadExpiryDate - expiry date of the link
+ */
+
+ /**
+ * Create the link node for a cloud attachment.
+ *
+ * @param {Document} aDocument - the document to insert the item into
+ * @param {CloudFileTemplate} aCloudFileUpload - object with information about
+ * the uploaded file
+ * @param {boolean} composeHTML - override gMsgCompose.composeHTML
+ */
+ async _createNode(
+ aDocument,
+ aCloudFileUpload,
+ composeHTML = gMsgCompose.composeHTML
+ ) {
+ const iconSize = 32;
+ const locales = {
+ service: 0,
+ size: 1,
+ link: 2,
+ "password-protected-link": 3,
+ "expiry-date": 4,
+ "download-limit": 5,
+ "tooltip-password-protected-link": 6,
+ };
+
+ let l10n_values = await l10nCompose.formatValues([
+ { id: "cloud-file-template-service-name" },
+ { id: "cloud-file-template-size" },
+ { id: "cloud-file-template-link" },
+ { id: "cloud-file-template-password-protected-link" },
+ { id: "cloud-file-template-expiry-date" },
+ { id: "cloud-file-template-download-limit" },
+ { id: "cloud-file-tooltip-password-protected-link" },
+ ]);
+
+ let node = aDocument.createElement("li");
+ node.style.border = "1px solid #CDCDCD";
+ node.style.borderRadius = "5px";
+ node.style.marginTop = "10px";
+ node.style.marginBottom = "10px";
+ node.style.padding = "15px";
+ node.style.display = "grid";
+ node.style.gridTemplateColumns = "0fr 1fr 0fr 0fr";
+ node.style.alignItems = "center";
+
+ const statsRow = (name, content, contentLink) => {
+ let entry = aDocument.createElement("span");
+ entry.style.gridColumn = `2 / span 3`;
+ entry.style.fontSize = "small";
+
+ let description = aDocument.createElement("span");
+ description.style.color = "dark-grey";
+ description.textContent = `${l10n_values[locales[name]]} `;
+ entry.appendChild(description);
+
+ let value;
+ if (composeHTML && contentLink) {
+ value = this._generateLink(aDocument, content, contentLink, "#595959");
+ } else {
+ value = aDocument.createElement("span");
+ value.style.color = "#595959";
+ value.textContent = content;
+ }
+ value.classList.add(`cloudfile-${name}`);
+ entry.appendChild(value);
+
+ entry.appendChild(aDocument.createElement("br"));
+ return entry;
+ };
+
+ const serviceRow = () => {
+ let service = aDocument.createDocumentFragment();
+
+ let description = aDocument.createElement("span");
+ description.style.display = "none";
+ description.textContent = `${l10n_values[locales.service]} `;
+ service.appendChild(description);
+
+ let providerName = aDocument.createElement("span");
+ providerName.style.gridArea = "1 / 4";
+ providerName.style.color = "#595959";
+ providerName.style.fontSize = "small";
+ providerName.textContent = aCloudFileUpload.serviceName;
+ providerName.classList.add("cloudfile-service-name");
+ service.appendChild(providerName);
+
+ service.appendChild(aDocument.createElement("br"));
+ return service;
+ };
+
+ // If this message is send in plain text only, do not add a link to the file
+ // name.
+ let name = aDocument.createElement("span");
+ name.textContent = aCloudFileUpload.name;
+ if (composeHTML) {
+ name = this._generateLink(
+ aDocument,
+ aCloudFileUpload.name,
+ aCloudFileUpload.url,
+ "#0F7EDB"
+ );
+ name.setAttribute("moz-do-not-send", "true");
+ name.style.gridArea = "1 / 2";
+ }
+ name.classList.add("cloudfile-name");
+ node.appendChild(name);
+
+ let paperclip = aDocument.createElement("img");
+ paperclip.classList.add("paperClipIcon");
+ paperclip.style.gridArea = "1 / 1";
+ paperclip.alt = "";
+ paperclip.style.marginRight = "5px";
+ paperclip.width = `${iconSize}`;
+ paperclip.height = `${iconSize}`;
+ if (aCloudFileUpload.downloadPasswordProtected) {
+ paperclip.title = l10n_values[locales["tooltip-password-protected-link"]];
+ paperclip.src =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIfSURBVFhH7ZfLK0RRHMfvNd6PMV4Lj5UkO5bslJIdf4ClRw2TlY2yt2EhsZO9DYoFoiSvJBZkI6SsNMyIiLnH93vmXDF5HNe9pHzqM797fufMPb+Zc4Z7jC+QBnvgJryD93AddkH2eUop3IPiHXdgCfSEdLgLOdE+bIFFSl4zZxeRAl2HXzsn2IIZTCTAHPs4hsvhOlxz3rxRtt6GfRyzJlsucw1582zZehv2cUxEtlyGN6afkThuFa7EL7+H0wK03pek4q/xJwtYVv4YumurO+4V/3vgvwAvC5iHTfHL9zFV/Ah7J9tjE9s2r/K3YwWlD8IaREP+ExPCWBDJVl+gM3LEto0nBURHCiuNpBiflvLjqWcufDFfdVbo4ly1PVoC0xrAaz4qnLdiVjk1hVhArvDRFxuSYxQeFSAaGHzCbAuEIsf0URjtsithX3i1Cf18yewKn8kWyOu+OlWXuSpKnBRwpWKxioTXi7BCtr6Ak004BZvhJAwyAUZhb3Q0bwKxXmY+xVzyB8MNOgXwE/NrC0A+clXBDZV7iYkC7GK18AcvTZ0lOFGRE5NDWAtn4A28hdPQEToFcG1Jq4qERXAZ+DCaBXk+cIROAePQgh2whgk30SngAA7CVDgLq6Fr6P4M++Ec5PmPp6BhWAdzIA+m3BOO0C2AJ2GuMyfme0KQp6Ao5EmZf/fLDGFuI2oi+EEcUQm5JDywhpWc2MFGNIwn/WmcKhqF50UAAAAASUVORK5CYII=";
+ } else {
+ paperclip.src =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAVFJREFUWIXtl8FKw0AQhj8EbQ/p0Ut8AVEPgYLUB+i5L6J9E0Wtr1HPgl48WU8K1Tfw4LktxUAhHvZfiMXUbdhVhB0Yms78M/NldwkJuFsD6AMjYCYfASfKBbUd4BkoKvxJmiDWKA1/AXrAtrynmIUIshJ9DXgEmt/km8oVwHEIANu8u0LTleYhBMBUzZMVmkSaSQgAe9DW1d3L/wzAqW6jJpQ3+5cA3vbW1Vz3Np6BCBABIkAE+DWAmX7TUixdynm15Wf6jf5fa3Cq60K5qrraNuHrK1kbmJcGWJ8rB9DC4yvaq5odlmK7wBB4lw8Vs9ZRzdgHwLmaXa5RM1DNmQ+AA2ABfACZgz4DctXs+QAAuMLc0dsPEJk0BXDhazjAFnCnxjlmiTuYg5kAR4rl0twCmz4BLMQAs7RVH6kLzJ17H162fczhGmO+mqa6PqXGnn8CxMN0PcC9DrQAAAAASUVORK5CYII=";
+ }
+ node.appendChild(paperclip);
+
+ let serviceIcon = aDocument.createElement("img");
+ serviceIcon.classList.add("cloudfile-service-icon");
+ serviceIcon.style.gridArea = "1 / 3";
+ serviceIcon.alt = "";
+ serviceIcon.style.margin = "0 5px";
+ serviceIcon.width = `${iconSize}`;
+ serviceIcon.height = `${iconSize}`;
+ node.appendChild(serviceIcon);
+
+ if (aCloudFileUpload.serviceIcon) {
+ if (!/^(chrome|moz-extension):\/\//i.test(aCloudFileUpload.serviceIcon)) {
+ serviceIcon.src = aCloudFileUpload.serviceIcon;
+ } else {
+ try {
+ // Let's use the goodness from MsgComposeCommands.js since we're
+ // sitting right in a compose window.
+ serviceIcon.src = window.loadBlockedImage(
+ aCloudFileUpload.serviceIcon,
+ true
+ );
+ } catch (e) {
+ // Couldn't load the referenced image.
+ console.error(e);
+ }
+ }
+ }
+ node.appendChild(aDocument.createElement("br"));
+
+ node.appendChild(
+ statsRow("size", gMessenger.formatFileSize(aCloudFileUpload.size))
+ );
+
+ if (aCloudFileUpload.downloadExpiryDate) {
+ node.appendChild(
+ statsRow(
+ "expiry-date",
+ new Date(
+ aCloudFileUpload.downloadExpiryDate.timestamp
+ ).toLocaleString(
+ undefined,
+ aCloudFileUpload.downloadExpiryDate.format || {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "short",
+ }
+ )
+ )
+ );
+ }
+
+ if (aCloudFileUpload.downloadLimit) {
+ node.appendChild(
+ statsRow("download-limit", aCloudFileUpload.downloadLimit)
+ );
+ }
+
+ if (composeHTML || aCloudFileUpload.serviceUrl) {
+ node.appendChild(serviceRow());
+ }
+
+ let linkElementLocaleId = aCloudFileUpload.downloadPasswordProtected
+ ? "password-protected-link"
+ : "link";
+ node.appendChild(
+ statsRow(linkElementLocaleId, aCloudFileUpload.url, aCloudFileUpload.url)
+ );
+
+ // An extra line break is needed for the converted plain text version, if it
+ // should have a gap between its <li> elements.
+ if (composeHTML) {
+ node.appendChild(aDocument.createElement("br"));
+ }
+
+ // Generate the plain text version from the HTML. The used method needs a <ul>
+ // element wrapped around the <li> element to produce the correct content.
+ if (!composeHTML) {
+ let ul = aDocument.createElement("ul");
+ ul.appendChild(node);
+ node = aDocument.createElement("p");
+ node.textContent = this._getTextRepresentation(ul.outerHTML);
+ }
+
+ node.className = "cloudAttachmentItem";
+ node.dataset.contentLocation = aCloudFileUpload.url;
+ node.dataset.serviceName = aCloudFileUpload.serviceName;
+ node.dataset.serviceUrl = aCloudFileUpload.serviceUrl;
+ return node;
+ },
+
+ /**
+ * Event handler for when mail is sent. For mail that is being sent
+ * (and not saved!), find any cloudAttachmentList* nodes that we've created,
+ * and strip their IDs out. That way, if the receiving user replies by
+ * sending some BigFiles, we don't run into ID conflicts.
+ */
+ send(aEvent) {
+ let msgType = parseInt(aEvent.target.getAttribute("msgtype"));
+
+ if (
+ msgType == Ci.nsIMsgCompDeliverMode.Now ||
+ msgType == Ci.nsIMsgCompDeliverMode.Later ||
+ msgType == Ci.nsIMsgCompDeliverMode.Background
+ ) {
+ const kIDs = [
+ "cloudAttachmentListRoot",
+ "cloudAttachmentListHeader",
+ "cloudAttachmentList",
+ "cloudAttachmentListFooter",
+ ];
+ let mailDoc = document.getElementById("messageEditor").contentDocument;
+
+ for (let id of kIDs) {
+ let element = mailDoc.getElementById(id);
+ if (element) {
+ element.removeAttribute("id");
+ }
+ }
+ }
+ },
+};
+
+window.addEventListener(
+ "compose-window-init",
+ gCloudAttachmentLinkManager.init.bind(gCloudAttachmentLinkManager),
+ true
+);
+window.addEventListener(
+ "compose-send-message",
+ gCloudAttachmentLinkManager.send.bind(gCloudAttachmentLinkManager),
+ true
+);
diff --git a/comm/mail/components/compose/content/dialogs/EdAEAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEAttributes.js
new file mode 100644
index 0000000000..52b7e30fac
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAEAttributes.js
@@ -0,0 +1,973 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// HTML Attributes object for "Name" menulist
+var gHTMLAttr = {};
+
+// JS Events Attributes object for "Name" menulist
+var gJSAttr = {};
+
+// Core HTML attribute values //
+// This is appended to Name menulist when "_core" is attribute name
+var gCoreHTMLAttr = ["^id", "class", "title"];
+
+// Core event attribute values //
+// This is appended to all JS menulists
+// except those elements having "noJSEvents"
+// as a value in their gJSAttr array.
+var gCoreJSEvents = [
+ "onclick",
+ "ondblclick",
+ "onmousedown",
+ "onmouseup",
+ "onmouseover",
+ "onmousemove",
+ "onmouseout",
+ "-",
+ "onkeypress",
+ "onkeydown",
+ "onkeyup",
+];
+
+// Following are commonly-used strings
+
+// Also accept: sRGB: #RRGGBB //
+var gHTMLColors = [
+ "Aqua",
+ "Black",
+ "Blue",
+ "Fuchsia",
+ "Gray",
+ "Green",
+ "Lime",
+ "Maroon",
+ "Navy",
+ "Olive",
+ "Purple",
+ "Red",
+ "Silver",
+ "Teal",
+ "White",
+ "Yellow",
+];
+
+var gHAlign = ["left", "center", "right"];
+
+var gHAlignJustify = ["left", "center", "right", "justify"];
+
+var gHAlignTableContent = ["left", "center", "right", "justify", "char"];
+
+var gVAlignTable = ["top", "middle", "bottom", "baseline"];
+
+var gTarget = ["_blank", "_self", "_parent", "_top"];
+
+// ================ HTML Attributes ================ //
+/* For each element, there is an array of attributes,
+ whose name is the element name,
+ used to fill the "Attribute Name" menulist.
+ For each of those attributes, if they have a specific
+ set of values, those are listed in an array named:
+ "elementName_attName".
+
+ In each values string, the following characters
+ are signal to do input filtering:
+ "#" Allow only integer values
+ "%" Allow integer values or a number ending in "%"
+ "+" Allow integer values and allow "+" or "-" as first character
+ "!" Allow only one character
+ "^" The first character can be only be A-Z, a-z, hyphen, underscore, colon or period
+ "$" is an attribute required by HTML DTD
+*/
+
+/*
+ Most elements have the "dir" attribute,
+ so we use this value array
+ for all elements instead of specifying
+ separately for each element
+*/
+gHTMLAttr.all_dir = ["ltr", "rtl"];
+
+gHTMLAttr.a = [
+ "charset",
+ "type",
+ "name",
+ "href",
+ "^hreflang",
+ "target",
+ "rel",
+ "rev",
+ "!accesskey",
+ "shape", // with imagemap //
+ "coords", // with imagemap //
+ "#tabindex",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.a_target = gTarget;
+
+gHTMLAttr.a_rel = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.a_rev = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.a_shape = ["rect", "circle", "poly", "default"];
+
+gHTMLAttr.abbr = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.acronym = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.address = ["_core", "-", "^lang", "dir"];
+
+// this is deprecated //
+gHTMLAttr.applet = [
+ "codebase",
+ "archive",
+ "code",
+ "object",
+ "alt",
+ "name",
+ "%$width",
+ "%$height",
+ "align",
+ "#hspace",
+ "#vspace",
+ "-",
+ "_core",
+];
+
+gHTMLAttr.applet_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.area = [
+ "shape",
+ "coords",
+ "href",
+ "nohref",
+ "target",
+ "$alt",
+ "#tabindex",
+ "!accesskey",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.area_target = gTarget;
+
+gHTMLAttr.area_shape = ["rect", "circle", "poly", "default"];
+
+gHTMLAttr.area_nohref = ["nohref"];
+
+gHTMLAttr.b = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.base = ["href", "target"];
+
+gHTMLAttr.base_target = gTarget;
+
+// this is deprecated //
+gHTMLAttr.basefont = ["^id", "$size", "color", "face"];
+
+gHTMLAttr.basefont_color = gHTMLColors;
+
+gHTMLAttr.bdo = ["_core", "-", "^lang", "$dir"];
+
+gHTMLAttr.bdo_dir = ["ltr", "rtl"];
+
+gHTMLAttr.big = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.blockquote = ["cite", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.body = [
+ "background",
+ "bgcolor",
+ "text",
+ "link",
+ "vlink",
+ "alink",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.body_bgcolor = gHTMLColors;
+
+gHTMLAttr.body_text = gHTMLColors;
+
+gHTMLAttr.body_link = gHTMLColors;
+
+gHTMLAttr.body_vlink = gHTMLColors;
+
+gHTMLAttr.body_alink = gHTMLColors;
+
+gHTMLAttr.br = ["clear", "-", "_core"];
+
+gHTMLAttr.br_clear = ["none", "left", "all", "right"];
+
+gHTMLAttr.button = [
+ "name",
+ "value",
+ "$type",
+ "disabled",
+ "#tabindex",
+ "!accesskey",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.button_type = ["submit", "button", "reset"];
+
+gHTMLAttr.button_disabled = ["disabled"];
+
+gHTMLAttr.caption = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.caption_align = ["top", "bottom", "left", "right"];
+
+// this is deprecated //
+gHTMLAttr.center = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.cite = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.code = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.col = [
+ "#$span",
+ "%width",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "char",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.col_span = [
+ "1", // default
+];
+
+gHTMLAttr.col_align = gHAlignTableContent;
+
+gHTMLAttr.col_valign = ["top", "middle", "bottom", "baseline"];
+
+gHTMLAttr.colgroup = [
+ "#$span",
+ "%width",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.colgroup_span = [
+ "1", // default
+];
+
+gHTMLAttr.colgroup_align = gHAlignTableContent;
+
+gHTMLAttr.colgroup_valign = ["top", "middle", "bottom", "baseline"];
+
+gHTMLAttr.dd = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.del = ["cite", "datetime", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.dfn = ["_core", "-", "^lang", "dir"];
+
+// this is deprecated //
+gHTMLAttr.dir = ["compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.dir_compact = ["compact"];
+
+gHTMLAttr.div = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.div_align = gHAlignJustify;
+
+gHTMLAttr.dl = ["compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.dl_compact = ["compact"];
+
+gHTMLAttr.dt = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.em = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.fieldset = ["_core", "-", "^lang", "dir"];
+
+// this is deprecated //
+gHTMLAttr.font = ["+size", "color", "face", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.font_color = gHTMLColors;
+
+gHTMLAttr.form = [
+ "$action",
+ "$method",
+ "enctype",
+ "accept",
+ "name",
+ "accept-charset",
+ "target",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.form_method = ["get", "post"];
+
+gHTMLAttr.form_enctype = ["application/x-www-form-urlencoded"];
+
+gHTMLAttr.form_target = gTarget;
+
+gHTMLAttr.frame = [
+ "longdesc",
+ "name",
+ "src",
+ "#frameborder",
+ "#marginwidth",
+ "#marginheight",
+ "noresize",
+ "$scrolling",
+];
+
+gHTMLAttr.frame_frameborder = ["1", "0"];
+
+gHTMLAttr.frame_noresize = ["noresize"];
+
+gHTMLAttr.frame_scrolling = ["auto", "yes", "no"];
+
+gHTMLAttr.frameset = ["rows", "cols", "-", "_core"];
+
+gHTMLAttr.h1 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h1_align = gHAlignJustify;
+
+gHTMLAttr.h2 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h2_align = gHAlignJustify;
+
+gHTMLAttr.h3 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h3_align = gHAlignJustify;
+
+gHTMLAttr.h4 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h4_align = gHAlignJustify;
+
+gHTMLAttr.h5 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h5_align = gHAlignJustify;
+
+gHTMLAttr.h6 = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.h6_align = gHAlignJustify;
+
+gHTMLAttr.head = ["profile", "-", "^lang", "dir"];
+
+gHTMLAttr.hr = [
+ "align",
+ "noshade",
+ "#size",
+ "%width",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.hr_align = gHAlign;
+
+gHTMLAttr.hr_noshade = ["noshade"];
+
+gHTMLAttr.html = ["version", "-", "^lang", "dir"];
+
+gHTMLAttr.i = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.iframe = [
+ "longdesc",
+ "name",
+ "src",
+ "$frameborder",
+ "marginwidth",
+ "marginheight",
+ "$scrolling",
+ "align",
+ "%height",
+ "%width",
+ "-",
+ "_core",
+];
+
+gHTMLAttr.iframe_frameborder = ["1", "0"];
+
+gHTMLAttr.iframe_scrolling = ["auto", "yes", "no"];
+
+gHTMLAttr.iframe_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.img = [
+ "$src",
+ "$alt",
+ "longdesc",
+ "name",
+ "%height",
+ "%width",
+ "usemap",
+ "ismap",
+ "align",
+ "#border",
+ "#hspace",
+ "#vspace",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.img_ismap = ["ismap"];
+
+gHTMLAttr.img_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.input = [
+ "$type",
+ "name",
+ "value",
+ "checked",
+ "disabled",
+ "readonly",
+ "#size",
+ "#maxlength",
+ "src",
+ "alt",
+ "usemap",
+ "ismap",
+ "#tabindex",
+ "!accesskey",
+ "accept",
+ "align",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.input_type = [
+ "text",
+ "password",
+ "checkbox",
+ "radio",
+ "submit",
+ "reset",
+ "file",
+ "hidden",
+ "image",
+ "button",
+];
+
+gHTMLAttr.input_checked = ["checked"];
+
+gHTMLAttr.input_disabled = ["disabled"];
+
+gHTMLAttr.input_readonly = ["readonly"];
+
+gHTMLAttr.input_ismap = ["ismap"];
+
+gHTMLAttr.input_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.ins = ["cite", "datetime", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.isindex = ["prompt", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.kbd = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.label = ["for", "!accesskey", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.legend = ["!accesskey", "align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.legend_align = ["top", "bottom", "left", "right"];
+
+gHTMLAttr.li = ["type", "#value", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.li_type = ["disc", "square", "circle", "-", "1", "a", "A", "i", "I"];
+
+gHTMLAttr.link = [
+ "charset",
+ "href",
+ "^hreflang",
+ "type",
+ "rel",
+ "rev",
+ "media",
+ "target",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.link_target = gTarget;
+
+gHTMLAttr.link_rel = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.link_rev = [
+ "alternate",
+ "stylesheet",
+ "start",
+ "next",
+ "prev",
+ "contents",
+ "index",
+ "glossary",
+ "copyright",
+ "chapter",
+ "section",
+ "subsection",
+ "appendix",
+ "help",
+ "bookmark",
+];
+
+gHTMLAttr.map = ["$name", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.menu = ["compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.menu_compact = ["compact"];
+
+gHTMLAttr.meta = [
+ "http-equiv",
+ "name",
+ "$content",
+ "scheme",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.noframes = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.noscript = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.object = [
+ "declare",
+ "classid",
+ "codebase",
+ "data",
+ "type",
+ "codetype",
+ "archive",
+ "standby",
+ "%height",
+ "%width",
+ "usemap",
+ "name",
+ "#tabindex",
+ "align",
+ "#border",
+ "#hspace",
+ "#vspace",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.object_declare = ["declare"];
+
+gHTMLAttr.object_align = ["top", "middle", "bottom", "left", "right"];
+
+gHTMLAttr.ol = ["type", "compact", "#start", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.ol_type = ["1", "a", "A", "i", "I"];
+
+gHTMLAttr.ol_compact = ["compact"];
+
+gHTMLAttr.optgroup = ["disabled", "$label", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.optgroup_disabled = ["disabled"];
+
+gHTMLAttr.option = [
+ "selected",
+ "disabled",
+ "label",
+ "value",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.option_selected = ["selected"];
+
+gHTMLAttr.option_disabled = ["disabled"];
+
+gHTMLAttr.p = ["align", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.p_align = gHAlignJustify;
+
+gHTMLAttr.param = ["^id", "$name", "value", "$valuetype", "type"];
+
+gHTMLAttr.param_valuetype = ["data", "ref", "object"];
+
+gHTMLAttr.pre = ["%width", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.q = ["cite", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.s = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.samp = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.script = ["charset", "$type", "language", "src", "defer"];
+
+gHTMLAttr.script_defer = ["defer"];
+
+gHTMLAttr.select = [
+ "name",
+ "#size",
+ "multiple",
+ "disabled",
+ "#tabindex",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.select_multiple = ["multiple"];
+
+gHTMLAttr.select_disabled = ["disabled"];
+
+gHTMLAttr.small = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.span = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.strike = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.strong = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.style = ["$type", "media", "title", "-", "^lang", "dir"];
+
+gHTMLAttr.sub = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.sup = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.table = [
+ "summary",
+ "%width",
+ "#border",
+ "frame",
+ "rules",
+ "#cellspacing",
+ "#cellpadding",
+ "align",
+ "bgcolor",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.table_frame = [
+ "void",
+ "above",
+ "below",
+ "hsides",
+ "lhs",
+ "rhs",
+ "vsides",
+ "box",
+ "border",
+];
+
+gHTMLAttr.table_rules = ["none", "groups", "rows", "cols", "all"];
+
+// Note; This is alignment of the table,
+// not table contents, like all other table child elements
+gHTMLAttr.table_align = gHAlign;
+
+gHTMLAttr.table_bgcolor = gHTMLColors;
+
+gHTMLAttr.tbody = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.tbody_align = gHAlignTableContent;
+
+gHTMLAttr.tbody_valign = gVAlignTable;
+
+gHTMLAttr.td = [
+ "abbr",
+ "axis",
+ "headers",
+ "scope",
+ "$#rowspan",
+ "$#colspan",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "nowrap",
+ "bgcolor",
+ "%width",
+ "%height",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.td_scope = ["row", "col", "rowgroup", "colgroup"];
+
+gHTMLAttr.td_rowspan = [
+ "1", // default
+];
+
+gHTMLAttr.td_colspan = [
+ "1", // default
+];
+
+gHTMLAttr.td_align = gHAlignTableContent;
+
+gHTMLAttr.td_valign = gVAlignTable;
+
+gHTMLAttr.td_nowrap = ["nowrap"];
+
+gHTMLAttr.td_bgcolor = gHTMLColors;
+
+gHTMLAttr.textarea = [
+ "name",
+ "$#rows",
+ "$#cols",
+ "disabled",
+ "readonly",
+ "#tabindex",
+ "!accesskey",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.textarea_disabled = ["disabled"];
+
+gHTMLAttr.textarea_readonly = ["readonly"];
+
+gHTMLAttr.tfoot = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.tfoot_align = gHAlignTableContent;
+
+gHTMLAttr.tfoot_valign = gVAlignTable;
+
+gHTMLAttr.th = [
+ "abbr",
+ "axis",
+ "headers",
+ "scope",
+ "$#rowspan",
+ "$#colspan",
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "nowrap",
+ "bgcolor",
+ "%width",
+ "%height",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.th_scope = ["row", "col", "rowgroup", "colgroup"];
+
+gHTMLAttr.th_rowspan = [
+ "1", // default
+];
+
+gHTMLAttr.th_colspan = [
+ "1", // default
+];
+
+gHTMLAttr.th_align = gHAlignTableContent;
+
+gHTMLAttr.th_valign = gVAlignTable;
+
+gHTMLAttr.th_nowrap = ["nowrap"];
+
+gHTMLAttr.th_bgcolor = gHTMLColors;
+
+gHTMLAttr.thead = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.thead_align = gHAlignTableContent;
+
+gHTMLAttr.thead_valign = gVAlignTable;
+
+gHTMLAttr.title = ["^lang", "dir"];
+
+gHTMLAttr.tr = [
+ "align",
+ "!char",
+ "#charoff",
+ "valign",
+ "bgcolor",
+ "-",
+ "_core",
+ "-",
+ "^lang",
+ "dir",
+];
+
+gHTMLAttr.tr_align = gHAlignTableContent;
+
+gHTMLAttr.tr_valign = gVAlignTable;
+
+gHTMLAttr.tr_bgcolor = gHTMLColors;
+
+gHTMLAttr.tt = ["_core", "-", "^lang", "dir"];
+
+gHTMLAttr.u = ["_core", "-", "^lang", "dir"];
+gHTMLAttr.ul = ["type", "compact", "-", "_core", "-", "^lang", "dir"];
+
+gHTMLAttr.ul_type = ["disc", "square", "circle"];
+
+gHTMLAttr.ul_compact = ["compact"];
+
+// Prefix with "_" since this is reserved (it's stripped out)
+gHTMLAttr._var = ["_core", "-", "^lang", "dir"];
+
+// ================ JS Attributes ================ //
+// These are element specific even handlers.
+/* Most all elements use gCoreJSEvents, so those
+ are assumed except for those listed here with "noEvents"
+*/
+
+gJSAttr.a = ["onfocus", "onblur"];
+
+gJSAttr.area = ["onfocus", "onblur"];
+
+gJSAttr.body = ["onload", "onupload"];
+
+gJSAttr.button = ["onfocus", "onblur"];
+
+gJSAttr.form = ["onsubmit", "onreset"];
+
+gJSAttr.frameset = ["onload", "onunload"];
+
+gJSAttr.input = ["onfocus", "onblur", "onselect", "onchange"];
+
+gJSAttr.label = ["onfocus", "onblur"];
+
+gJSAttr.select = ["onfocus", "onblur", "onchange"];
+
+gJSAttr.textarea = ["onfocus", "onblur", "onselect", "onchange"];
+
+// Elements that don't have JSEvents:
+gJSAttr.font = ["noJSEvents"];
+
+gJSAttr.applet = ["noJSEvents"];
+
+gJSAttr.isindex = ["noJSEvents"];
+
+gJSAttr.iframe = ["noJSEvents"];
diff --git a/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js b/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js
new file mode 100644
index 0000000000..ca54fa16da
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdAdvancedEdit.js */
+/* import-globals-from EdDialogCommon.js */
+
+// build attribute list in tree form from element attributes
+function BuildCSSAttributeTable() {
+ var style = gElement.style;
+ if (style == undefined) {
+ dump("Inline styles undefined\n");
+ return;
+ }
+
+ var declLength = style.length;
+
+ if (declLength == undefined || declLength == 0) {
+ if (declLength == undefined) {
+ dump("Failed to query the number of inline style declarations\n");
+ }
+
+ return;
+ }
+
+ if (declLength > 0) {
+ for (var i = 0; i < declLength; ++i) {
+ var name = style.item(i);
+ var value = style.getPropertyValue(name);
+ AddTreeItem(name, value, "CSSAList", CSSAttrs);
+ }
+ }
+
+ ClearCSSInputWidgets();
+}
+
+function onChangeCSSAttribute() {
+ var name = TrimString(gDialog.AddCSSAttributeNameInput.value);
+ if (!name) {
+ return;
+ }
+
+ var value = TrimString(gDialog.AddCSSAttributeValueInput.value);
+
+ // First try to update existing attribute
+ // If not found, add new attribute
+ if (!UpdateExistingAttribute(name, value, "CSSAList") && value) {
+ AddTreeItem(name, value, "CSSAList", CSSAttrs);
+ }
+}
+
+function ClearCSSInputWidgets() {
+ gDialog.AddCSSAttributeTree.view.selection.clearSelection();
+ gDialog.AddCSSAttributeNameInput.value = "";
+ gDialog.AddCSSAttributeValueInput.value = "";
+ SetTextboxFocus(gDialog.AddCSSAttributeNameInput);
+}
+
+function onSelectCSSTreeItem() {
+ if (!gDoOnSelectTree) {
+ return;
+ }
+
+ var tree = gDialog.AddCSSAttributeTree;
+ if (tree && tree.view.selection.count) {
+ gDialog.AddCSSAttributeNameInput.value = GetTreeItemAttributeStr(
+ getSelectedItem(tree)
+ );
+ gDialog.AddCSSAttributeValueInput.value = GetTreeItemValueStr(
+ getSelectedItem(tree)
+ );
+ }
+}
+
+function onInputCSSAttributeName() {
+ var attName = TrimString(
+ gDialog.AddCSSAttributeNameInput.value
+ ).toLowerCase();
+ var newValue = "";
+
+ var existingValue = GetAndSelectExistingAttributeValue(attName, "CSSAList");
+ if (existingValue) {
+ newValue = existingValue;
+ }
+
+ gDialog.AddCSSAttributeValueInput.value = newValue;
+}
+
+function editCSSAttributeValue(targetCell) {
+ if (IsNotTreeHeader(targetCell)) {
+ gDialog.AddCSSAttributeValueInput.select();
+ }
+}
+
+function UpdateCSSAttributes() {
+ var CSSAList = document.getElementById("CSSAList");
+ var styleString = "";
+ for (var i = 0; i < CSSAList.children.length; i++) {
+ var item = CSSAList.children[i];
+ var name = GetTreeItemAttributeStr(item);
+ var value = GetTreeItemValueStr(item);
+ // this code allows users to be sloppy in typing in values, and enter
+ // things like "foo: " and "bar;". This will trim off everything after the
+ // respective character.
+ if (name.includes(":")) {
+ name = name.substring(0, name.lastIndexOf(":"));
+ }
+ if (value.includes(";")) {
+ value = value.substring(0, value.lastIndexOf(";"));
+ }
+ if (i == CSSAList.children.length - 1) {
+ // Last property.
+ styleString += name + ": " + value + ";";
+ } else {
+ styleString += name + ": " + value + "; ";
+ }
+ }
+ if (styleString) {
+ // Use editor transactions if modifying the element directly in the document
+ doRemoveAttribute("style");
+ doSetAttribute("style", styleString); // NOTE BUG 18894!!!
+ } else if (gElement.getAttribute("style")) {
+ doRemoveAttribute("style");
+ }
+}
+
+function RemoveCSSAttribute() {
+ // We only allow 1 selected item
+ if (gDialog.AddCSSAttributeTree.view.selection.count) {
+ // Remove the item from the tree
+ // We always rebuild complete "style" string,
+ // so no list of "removed" items
+ getSelectedItem(gDialog.AddCSSAttributeTree).remove();
+
+ ClearCSSInputWidgets();
+ }
+}
+
+function SelectCSSTree(index) {
+ gDoOnSelectTree = false;
+ try {
+ gDialog.AddCSSAttributeTree.selectedIndex = index;
+ } catch (e) {}
+ gDoOnSelectTree = true;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js
new file mode 100644
index 0000000000..127bfb858b
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js
@@ -0,0 +1,362 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdAdvancedEdit.js */
+/* import-globals-from EdDialogCommon.js */
+
+function BuildHTMLAttributeNameList() {
+ gDialog.AddHTMLAttributeNameInput.removeAllItems();
+
+ var elementName = gElement.localName;
+ var attNames = gHTMLAttr[elementName];
+
+ if (attNames && attNames.length) {
+ var menuitem;
+
+ for (var i = 0; i < attNames.length; i++) {
+ var name = attNames[i];
+
+ if (name == "_core") {
+ // Signal to append the common 'core' attributes.
+ for (var j = 0; j < gCoreHTMLAttr.length; j++) {
+ name = gCoreHTMLAttr[j];
+
+ // only filtering rule used for core attributes as of 8-20-01
+ // Add more rules if necessary.
+ if (name.includes("^")) {
+ name = name.replace(/\^/g, "");
+ menuitem = gDialog.AddHTMLAttributeNameInput.appendItem(name, name);
+ menuitem.setAttribute("limitFirstChar", "true");
+ } else {
+ gDialog.AddHTMLAttributeNameInput.appendItem(name, name);
+ }
+ }
+ } else if (name == "-") {
+ // Signal for separator
+ var popup = gDialog.AddHTMLAttributeNameInput.menupopup;
+ if (popup) {
+ var sep = document.createXULElement("menuseparator");
+ if (sep) {
+ popup.appendChild(sep);
+ }
+ }
+ } else {
+ // Get information about value filtering
+ let forceOneChar = name.includes("!");
+ let forceInteger = name.includes("#");
+ let forceSignedInteger = name.includes("+");
+ let forceIntOrPercent = name.includes("%");
+ let limitFirstChar = name.includes("^");
+ // let required = name.includes("$");
+
+ // Strip flag characters
+ name = name.replace(/[!^#%$+]/g, "");
+
+ menuitem = gDialog.AddHTMLAttributeNameInput.appendItem(name, name);
+ if (menuitem) {
+ // Signify "required" attributes by special style
+ // TODO: Don't do this until next version, when we add
+ // explanatory text and an 'Autofill Required Attributes' button
+ // if (required)
+ // menuitem.setAttribute("class", "menuitem-highlight-1");
+
+ // Set flags to filter value input
+ if (forceOneChar) {
+ menuitem.setAttribute("forceOneChar", "true");
+ }
+ if (limitFirstChar) {
+ menuitem.setAttribute("limitFirstChar", "true");
+ }
+ if (forceInteger) {
+ menuitem.setAttribute("forceInteger", "true");
+ }
+ if (forceSignedInteger) {
+ menuitem.setAttribute("forceSignedInteger", "true");
+ }
+ if (forceIntOrPercent) {
+ menuitem.setAttribute("forceIntOrPercent", "true");
+ }
+ }
+ }
+ }
+ }
+}
+
+// build attribute list in tree form from element attributes
+function BuildHTMLAttributeTable() {
+ var nodeMap = gElement.attributes;
+ var i;
+ if (nodeMap.length > 0) {
+ var added = false;
+ for (i = 0; i < nodeMap.length; i++) {
+ let name = nodeMap[i].name.trim().toLowerCase();
+ if (
+ CheckAttributeNameSimilarity(nodeMap[i].nodeName, HTMLAttrs) ||
+ name.startsWith("on") ||
+ name == "style"
+ ) {
+ continue; // repeated or non-HTML attribute, ignore this one and go to next
+ }
+ if (
+ !name.startsWith("_moz") &&
+ AddTreeItem(name, nodeMap[i].value, "HTMLAList", HTMLAttrs)
+ ) {
+ added = true;
+ }
+ }
+
+ if (added) {
+ SelectHTMLTree(0);
+ }
+ }
+}
+
+function ClearHTMLInputWidgets() {
+ gDialog.AddHTMLAttributeTree.view.selection.clearSelection();
+ gDialog.AddHTMLAttributeNameInput.value = "";
+ gDialog.AddHTMLAttributeValueInput.value = "";
+ SetTextboxFocus(gDialog.AddHTMLAttributeNameInput);
+}
+
+function onSelectHTMLTreeItem() {
+ if (!gDoOnSelectTree) {
+ return;
+ }
+
+ var tree = gDialog.AddHTMLAttributeTree;
+ if (tree && tree.view.selection.count) {
+ var inputName = TrimString(
+ gDialog.AddHTMLAttributeNameInput.value
+ ).toLowerCase();
+ var selectedItem = getSelectedItem(tree);
+ var selectedName =
+ selectedItem.firstElementChild.firstElementChild.getAttribute("label");
+
+ if (inputName == selectedName) {
+ // Already editing selected name - just update the value input
+ gDialog.AddHTMLAttributeValueInput.value =
+ GetTreeItemValueStr(selectedItem);
+ } else {
+ gDialog.AddHTMLAttributeNameInput.value = selectedName;
+
+ // Change value input based on new selected name
+ onInputHTMLAttributeName();
+ }
+ }
+}
+
+function onInputHTMLAttributeName() {
+ let attName = gDialog.AddHTMLAttributeNameInput.value.toLowerCase().trim();
+
+ // Clear value widget, but prevent triggering update in tree
+ gUpdateTreeValue = false;
+ gDialog.AddHTMLAttributeValueInput.value = "";
+ gUpdateTreeValue = true;
+
+ if (attName) {
+ // Get value list for current attribute name
+ var valueListName;
+
+ // Most elements have the "dir" attribute,
+ // so we have just one array for the allowed values instead
+ // requiring duplicate entries for each element in EdAEAttributes.js
+ if (attName == "dir") {
+ valueListName = "all_dir";
+ } else {
+ valueListName = gElement.localName + "_" + attName;
+ }
+
+ // Strip off leading "_" we sometimes use (when element name is reserved word)
+ if (valueListName.startsWith("_")) {
+ valueListName = valueListName.slice(1);
+ }
+
+ let useMenulist = false; // Editable menulist vs. input for the value.
+ var newValue = "";
+ if (valueListName in gHTMLAttr) {
+ var valueList = gHTMLAttr[valueListName];
+
+ let listLen = valueList.length;
+ useMenulist = listLen > 1;
+ if (listLen == 1) {
+ newValue = valueList[0];
+ }
+
+ // Note: For case where "value list" is actually just
+ // one (default) item, don't use menulist for that
+ if (useMenulist) {
+ gDialog.AddHTMLAttributeValueMenulist.removeAllItems();
+
+ // Rebuild the list
+ for (var i = 0; i < listLen; i++) {
+ if (valueList[i] == "-") {
+ // Signal for separator
+ var popup = gDialog.AddHTMLAttributeValueInput.menupopup;
+ if (popup) {
+ var sep = document.createXULElement("menuseparator");
+ if (sep) {
+ popup.appendChild(sep);
+ }
+ }
+ } else {
+ gDialog.AddHTMLAttributeValueMenulist.appendItem(
+ valueList[i],
+ valueList[i]
+ );
+ }
+ }
+ }
+ }
+ if (useMenulist) {
+ // Switch to using editable menulist instead of the input.
+ gDialog.AddHTMLAttributeValueMenulist.parentElement.collapsed = false;
+ gDialog.AddHTMLAttributeValueTextbox.parentElement.collapsed = true;
+ gDialog.AddHTMLAttributeValueInput =
+ gDialog.AddHTMLAttributeValueMenulist;
+ } else {
+ // No list: Use input instead of editable menulist.
+ gDialog.AddHTMLAttributeValueMenulist.parentElement.collapsed = true;
+ gDialog.AddHTMLAttributeValueTextbox.parentElement.collapsed = false;
+ gDialog.AddHTMLAttributeValueInput = gDialog.AddHTMLAttributeValueTextbox;
+ }
+
+ // If attribute already exists in tree, use associated value,
+ // else use default found above
+ var existingValue = GetAndSelectExistingAttributeValue(
+ attName,
+ "HTMLAList"
+ );
+ if (existingValue) {
+ newValue = existingValue;
+ }
+
+ gDialog.AddHTMLAttributeValueInput.value = newValue;
+
+ if (!existingValue) {
+ onInputHTMLAttributeValue();
+ }
+ }
+}
+
+function onInputHTMLAttributeValue() {
+ if (!gUpdateTreeValue) {
+ return;
+ }
+
+ var name = TrimString(gDialog.AddHTMLAttributeNameInput.value);
+ if (!name) {
+ return;
+ }
+
+ // Trim spaces only from left since we must allow spaces within the string
+ // (we always reset the input field's value below)
+ var value = TrimStringLeft(gDialog.AddHTMLAttributeValueInput.value);
+ if (value) {
+ // Do value filtering based on type of attribute
+ // (Do not use "forceInteger()" to avoid multiple
+ // resetting of input's value and flickering)
+ var selectedItem = gDialog.AddHTMLAttributeNameInput.selectedItem;
+
+ if (selectedItem) {
+ if (
+ selectedItem.getAttribute("forceOneChar") == "true" &&
+ value.length > 1
+ ) {
+ value = value.slice(0, 1);
+ }
+
+ if (selectedItem.getAttribute("forceIntOrPercent") == "true") {
+ // Allow integer with optional "%" as last character
+ var percent = TrimStringRight(value).slice(-1);
+ value = value.replace(/\D+/g, "");
+ if (percent == "%") {
+ value += percent;
+ }
+ } else if (selectedItem.getAttribute("forceInteger") == "true") {
+ value = value.replace(/\D+/g, "");
+ } else if (selectedItem.getAttribute("forceSignedInteger") == "true") {
+ // Allow integer with optional "+" or "-" as first character
+ var sign = value[0];
+ value = value.replace(/\D+/g, "");
+ if (sign == "+" || sign == "-") {
+ value = sign + value;
+ }
+ }
+
+ // Special case attributes
+ if (selectedItem.getAttribute("limitFirstChar") == "true") {
+ // Limit first character to letter, and all others to
+ // letters, numbers, and a few others
+ value = value
+ .replace(/^[^a-zA-Z\u0080-\uFFFF]/, "")
+ .replace(/[^a-zA-Z0-9_\.\-\:\u0080-\uFFFF]+/g, "");
+ }
+
+ // Update once only if it changed
+ if (value != gDialog.AddHTMLAttributeValueInput.value) {
+ gDialog.AddHTMLAttributeValueInput.value = value;
+ }
+ }
+ }
+
+ // Update value in the tree list
+ // If not found, add new attribute
+ if (!UpdateExistingAttribute(name, value, "HTMLAList") && value) {
+ AddTreeItem(name, value, "HTMLAList", HTMLAttrs);
+ }
+}
+
+function editHTMLAttributeValue(targetCell) {
+ if (IsNotTreeHeader(targetCell)) {
+ gDialog.AddHTMLAttributeValueInput.select();
+ }
+}
+
+// update the object with added and removed attributes
+function UpdateHTMLAttributes() {
+ var HTMLAList = document.getElementById("HTMLAList");
+ var i;
+
+ // remove removed attributes
+ for (i = 0; i < HTMLRAttrs.length; i++) {
+ var name = HTMLRAttrs[i];
+
+ if (gElement.hasAttribute(name)) {
+ doRemoveAttribute(name);
+ }
+ }
+
+ // Set added or changed attributes
+ for (i = 0; i < HTMLAList.children.length; i++) {
+ var item = HTMLAList.children[i];
+ doSetAttribute(GetTreeItemAttributeStr(item), GetTreeItemValueStr(item));
+ }
+}
+
+function RemoveHTMLAttribute() {
+ // We only allow 1 selected item
+ if (gDialog.AddHTMLAttributeTree.view.selection.count) {
+ var item = getSelectedItem(gDialog.AddHTMLAttributeTree);
+ var attr = GetTreeItemAttributeStr(item);
+
+ // remove the item from the attribute array
+ HTMLRAttrs[HTMLRAttrs.length] = attr;
+ RemoveNameFromAttArray(attr, HTMLAttrs);
+
+ // Remove the item from the tree
+ item.remove();
+
+ // Clear inputs and selected item in tree
+ ClearHTMLInputWidgets();
+ }
+}
+
+function SelectHTMLTree(index) {
+ gDoOnSelectTree = false;
+ try {
+ gDialog.AddHTMLAttributeTree.selectedIndex = index;
+ } catch (e) {}
+ gDoOnSelectTree = true;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js
new file mode 100644
index 0000000000..8f902b74cd
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js
@@ -0,0 +1,200 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdAdvancedEdit.js */
+/* import-globals-from EdDialogCommon.js */
+
+function BuildJSEAttributeNameList() {
+ gDialog.AddJSEAttributeNameList.removeAllItems();
+
+ // Get events specific to current element
+ var elementName = gElement.localName;
+ if (elementName in gJSAttr) {
+ var attNames = gJSAttr[elementName];
+ var i;
+ var popup;
+ var sep;
+
+ if (attNames && attNames.length) {
+ // Since we don't allow user-editable JS events yet (but we will soon)
+ // simply remove the JS tab to not allow adding JS events
+ if (attNames[0] == "noJSEvents") {
+ var tab = document.getElementById("tabJSE");
+ if (tab) {
+ tab.remove();
+ }
+
+ return;
+ }
+
+ for (i = 0; i < attNames.length; i++) {
+ gDialog.AddJSEAttributeNameList.appendItem(attNames[i], attNames[i]);
+ }
+
+ popup = gDialog.AddJSEAttributeNameList.firstElementChild;
+ if (popup) {
+ sep = document.createXULElement("menuseparator");
+ if (sep) {
+ popup.appendChild(sep);
+ }
+ }
+ }
+ }
+
+ // Always add core JS events unless we aborted above
+ for (i = 0; i < gCoreJSEvents.length; i++) {
+ if (gCoreJSEvents[i] == "-") {
+ if (!popup) {
+ popup = gDialog.AddJSEAttributeNameList.firstElementChild;
+ }
+
+ sep = document.createXULElement("menuseparator");
+
+ if (popup && sep) {
+ popup.appendChild(sep);
+ }
+ } else {
+ gDialog.AddJSEAttributeNameList.appendItem(
+ gCoreJSEvents[i],
+ gCoreJSEvents[i]
+ );
+ }
+ }
+
+ gDialog.AddJSEAttributeNameList.selectedIndex = 0;
+
+ // Use current name and value of first tree item if it exists
+ onSelectJSETreeItem();
+}
+
+// build attribute list in tree form from element attributes
+function BuildJSEAttributeTable() {
+ var nodeMap = gElement.attributes;
+ if (nodeMap.length > 0) {
+ var added = false;
+ for (var i = 0; i < nodeMap.length; i++) {
+ let name = nodeMap[i].nodeName.toLowerCase();
+ if (CheckAttributeNameSimilarity(nodeMap[i].nodeName, JSEAttrs)) {
+ // Repeated or non-JS handler, ignore this one and go to next.
+ continue;
+ }
+ if (!name.startsWith("on")) {
+ // Attribute isn't an event handler.
+ continue;
+ }
+ var value = gElement.getAttribute(nodeMap[i].nodeName);
+ if (AddTreeItem(name, value, "JSEAList", JSEAttrs)) {
+ // add item to tree
+ added = true;
+ }
+ }
+
+ // Select first item
+ if (added) {
+ gDialog.AddJSEAttributeTree.selectedIndex = 0;
+ }
+ }
+}
+
+function onSelectJSEAttribute() {
+ if (!gDoOnSelectTree) {
+ return;
+ }
+
+ gDialog.AddJSEAttributeValueInput.value = GetAndSelectExistingAttributeValue(
+ gDialog.AddJSEAttributeNameList.label,
+ "JSEAList"
+ );
+}
+
+function onSelectJSETreeItem() {
+ var tree = gDialog.AddJSEAttributeTree;
+ if (tree && tree.view.selection.count) {
+ // Select attribute name in list
+ gDialog.AddJSEAttributeNameList.value = GetTreeItemAttributeStr(
+ getSelectedItem(tree)
+ );
+
+ // Set value input to that in tree (no need to update this in the tree)
+ gUpdateTreeValue = false;
+ gDialog.AddJSEAttributeValueInput.value = GetTreeItemValueStr(
+ getSelectedItem(tree)
+ );
+ gUpdateTreeValue = true;
+ }
+}
+
+function onInputJSEAttributeValue() {
+ if (gUpdateTreeValue) {
+ var name = TrimString(gDialog.AddJSEAttributeNameList.label);
+ var value = TrimString(gDialog.AddJSEAttributeValueInput.value);
+
+ // Update value in the tree list
+ // Since we have a non-editable menulist,
+ // we MUST automatically add the event attribute if it doesn't exist
+ if (!UpdateExistingAttribute(name, value, "JSEAList") && value) {
+ AddTreeItem(name, value, "JSEAList", JSEAttrs);
+ }
+ }
+}
+
+function editJSEAttributeValue(targetCell) {
+ if (IsNotTreeHeader(targetCell)) {
+ gDialog.AddJSEAttributeValueInput.select();
+ }
+}
+
+function UpdateJSEAttributes() {
+ var JSEAList = document.getElementById("JSEAList");
+ var i;
+
+ // remove removed attributes
+ for (i = 0; i < JSERAttrs.length; i++) {
+ var name = JSERAttrs[i];
+
+ if (gElement.hasAttribute(name)) {
+ doRemoveAttribute(name);
+ }
+ }
+
+ // Add events
+ for (i = 0; i < JSEAList.children.length; i++) {
+ var item = JSEAList.children[i];
+
+ // set the event handler
+ doSetAttribute(GetTreeItemAttributeStr(item), GetTreeItemValueStr(item));
+ }
+}
+
+function RemoveJSEAttribute() {
+ // This differs from HTML and CSS panels:
+ // We reselect after removing, because there is not
+ // editable attribute name input, so we can't clear that
+ // like we do in other panels
+ var newIndex = gDialog.AddJSEAttributeTree.selectedIndex;
+
+ // We only allow 1 selected item
+ if (gDialog.AddJSEAttributeTree.view.selection.count) {
+ var item = getSelectedItem(gDialog.AddJSEAttributeTree);
+
+ // Name is the text of the treecell
+ var attr = GetTreeItemAttributeStr(item);
+
+ // remove the item from the attribute array
+ if (newIndex >= JSEAttrs.length - 1) {
+ newIndex--;
+ }
+
+ // remove the item from the attribute array
+ JSERAttrs[JSERAttrs.length] = attr;
+ RemoveNameFromAttArray(attr, JSEAttrs);
+
+ // Remove the item from the tree
+ item.remove();
+
+ // Reselect an item
+ gDialog.AddJSEAttributeTree.selectedIndex = newIndex;
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js
new file mode 100644
index 0000000000..5f2515c2f6
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js
@@ -0,0 +1,342 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdAEAttributes.js */
+/* import-globals-from EdAECSSAttributes.js */
+/* import-globals-from EdAEHTMLAttributes.js */
+/* import-globals-from EdAEJSEAttributes.js */
+/* import-globals-from EdDialogCommon.js */
+
+/** ************ GLOBALS */
+var gElement = null; // handle to actual element edited
+
+var HTMLAttrs = []; // html attributes
+var CSSAttrs = []; // css attributes
+var JSEAttrs = []; // js events
+
+var HTMLRAttrs = []; // removed html attributes
+var JSERAttrs = []; // removed js events
+
+/* Set false to allow changing selection in tree
+ without doing "onselect" handler actions
+*/
+var gDoOnSelectTree = true;
+var gUpdateTreeValue = true;
+
+/** ************ INITIALISATION && SETUP */
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+/**
+ * function : void Startup();
+ * parameters : none
+ * returns : none
+ * desc. : startup and initialisation, prepares dialog.
+ */
+function Startup() {
+ var editor = GetCurrentEditor();
+
+ // Element to edit is passed in
+ if (!editor || !window.arguments[1]) {
+ dump("Advanced Edit: No editor or element to edit not supplied\n");
+ window.close();
+ return;
+ }
+ // This is the return value for the parent,
+ // who only needs to know if OK was clicked
+ window.opener.AdvancedEditOK = false;
+
+ // The actual element edited (not a copy!)
+ gElement = window.arguments[1];
+
+ // place the tag name in the header
+ var tagLabel = document.getElementById("tagLabel");
+ tagLabel.setAttribute("value", "<" + gElement.localName + ">");
+
+ // Create dialog object to store controls for easy access
+ gDialog.AddHTMLAttributeNameInput = document.getElementById(
+ "AddHTMLAttributeNameInput"
+ );
+
+ gDialog.AddHTMLAttributeValueMenulist = document.getElementById(
+ "AddHTMLAttributeValueMenulist"
+ );
+ gDialog.AddHTMLAttributeValueTextbox = document.getElementById(
+ "AddHTMLAttributeValueTextbox"
+ );
+ gDialog.AddHTMLAttributeValueInput = gDialog.AddHTMLAttributeValueTextbox;
+
+ gDialog.AddHTMLAttributeTree = document.getElementById("HTMLATree");
+ gDialog.AddCSSAttributeNameInput = document.getElementById(
+ "AddCSSAttributeNameInput"
+ );
+ gDialog.AddCSSAttributeValueInput = document.getElementById(
+ "AddCSSAttributeValueInput"
+ );
+ gDialog.AddCSSAttributeTree = document.getElementById("CSSATree");
+ gDialog.AddJSEAttributeNameList = document.getElementById(
+ "AddJSEAttributeNameList"
+ );
+ gDialog.AddJSEAttributeValueInput = document.getElementById(
+ "AddJSEAttributeValueInput"
+ );
+ gDialog.AddJSEAttributeTree = document.getElementById("JSEATree");
+ gDialog.okButton = document.querySelector("dialog").getButton("accept");
+
+ // build the attribute trees
+ BuildHTMLAttributeTable();
+ BuildCSSAttributeTable();
+ BuildJSEAttributeTable();
+
+ // Build attribute name arrays for menulists
+ BuildJSEAttributeNameList();
+ BuildHTMLAttributeNameList();
+ // No menulists for CSS panel (yet)
+
+ // Set focus to Name editable menulist in HTML panel
+ SetTextboxFocus(gDialog.AddHTMLAttributeNameInput);
+
+ // size the dialog properly
+ window.sizeToContent();
+
+ SetWindowLocation();
+}
+
+/**
+ * function : bool onAccept ( void );
+ * parameters : none
+ * returns : boolean true to close the window
+ * desc. : event handler for ok button
+ */
+function onAccept() {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+ try {
+ // Update our gElement attributes
+ UpdateHTMLAttributes();
+ UpdateCSSAttributes();
+ UpdateJSEAttributes();
+ } catch (ex) {
+ dump(ex);
+ }
+ editor.endTransaction();
+
+ window.opener.AdvancedEditOK = true;
+ SaveWindowLocation();
+}
+
+// Helpers for removing and setting attributes
+// Use editor transactions if modifying the element already in the document
+// (Temporary element from a property dialog won't have a parent node)
+function doRemoveAttribute(attrib) {
+ try {
+ var editor = GetCurrentEditor();
+ if (gElement.parentNode) {
+ editor.removeAttribute(gElement, attrib);
+ } else {
+ gElement.removeAttribute(attrib);
+ }
+ } catch (ex) {}
+}
+
+function doSetAttribute(attrib, value) {
+ try {
+ var editor = GetCurrentEditor();
+ if (gElement.parentNode) {
+ editor.setAttribute(gElement, attrib, value);
+ } else {
+ gElement.setAttribute(attrib, value);
+ }
+ } catch (ex) {}
+}
+
+/**
+ * function : bool CheckAttributeNameSimilarity ( string attName, array attArray );
+ * parameters : attribute to look for, array of current attributes
+ * returns : true if attribute already exists, false if it does not
+ * desc. : checks to see if any other attributes by the same name as the arg supplied
+ * already exist.
+ */
+function CheckAttributeNameSimilarity(attName, attArray) {
+ for (var i = 0; i < attArray.length; i++) {
+ if (attName.toLowerCase() == attArray[i].toLowerCase()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * function : bool UpdateExistingAttribute ( string attName, string attValue, string treeChildrenId );
+ * parameters : attribute to look for, new value, ID of <treeChildren> node in XUL tree
+ * returns : true if attribute already exists in tree, false if it does not
+ * desc. : checks to see if any other attributes by the same name as the arg supplied
+ * already exist while setting the associated value if different from current value
+ */
+function UpdateExistingAttribute(attName, attValue, treeChildrenId) {
+ var treeChildren = document.getElementById(treeChildrenId);
+ if (!treeChildren) {
+ return false;
+ }
+
+ var name;
+ var i;
+ attName = TrimString(attName).toLowerCase();
+ attValue = TrimString(attValue);
+
+ for (i = 0; i < treeChildren.children.length; i++) {
+ var item = treeChildren.children[i];
+ name = GetTreeItemAttributeStr(item);
+ if (name.toLowerCase() == attName) {
+ // Set the text in the "value' column treecell
+ SetTreeItemValueStr(item, attValue);
+
+ // Select item just changed,
+ // but don't trigger the tree's onSelect handler
+ gDoOnSelectTree = false;
+ try {
+ selectTreeItem(treeChildren, item);
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * function : string GetAndSelectExistingAttributeValue ( string attName, string treeChildrenId );
+ * parameters : attribute to look for, ID of <treeChildren> node in XUL tree
+ * returns : value in from the tree or empty string if name not found
+ */
+function GetAndSelectExistingAttributeValue(attName, treeChildrenId) {
+ if (!attName) {
+ return "";
+ }
+
+ var treeChildren = document.getElementById(treeChildrenId);
+ var name;
+ var i;
+
+ for (i = 0; i < treeChildren.children.length; i++) {
+ var item = treeChildren.children[i];
+ name = GetTreeItemAttributeStr(item);
+ if (name.toLowerCase() == attName.toLowerCase()) {
+ // Select item in the tree
+ // but don't trigger the tree's onSelect handler
+ gDoOnSelectTree = false;
+ try {
+ selectTreeItem(treeChildren, item);
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ // Get the text in the "value' column treecell
+ return GetTreeItemValueStr(item);
+ }
+ }
+
+ // Attribute doesn't exist in tree, so remove selection
+ gDoOnSelectTree = false;
+ try {
+ treeChildren.parentNode.view.selection.clearSelection();
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ return "";
+}
+
+/* Tree structure:
+ <treeItem>
+ <treeRow>
+ <treeCell> // Name Cell
+ <treeCell // Value Cell
+*/
+function GetTreeItemAttributeStr(treeItem) {
+ if (treeItem) {
+ return TrimString(
+ treeItem.firstElementChild.firstElementChild.getAttribute("label")
+ );
+ }
+
+ return "";
+}
+
+function GetTreeItemValueStr(treeItem) {
+ if (treeItem) {
+ return TrimString(
+ treeItem.firstElementChild.lastElementChild.getAttribute("label")
+ );
+ }
+
+ return "";
+}
+
+function SetTreeItemValueStr(treeItem, value) {
+ if (treeItem && GetTreeItemValueStr(treeItem) != value) {
+ treeItem.firstElementChild.lastElementChild.setAttribute("label", value);
+ }
+}
+
+function IsNotTreeHeader(treeCell) {
+ if (treeCell) {
+ return treeCell.parentNode.parentNode.nodeName != "treehead";
+ }
+
+ return false;
+}
+
+function RemoveNameFromAttArray(attName, attArray) {
+ for (var i = 0; i < attArray.length; i++) {
+ if (attName.toLowerCase() == attArray[i].toLowerCase()) {
+ // Remove 1 array item
+ attArray.splice(i, 1);
+ break;
+ }
+ }
+}
+
+// adds a generalised treeitem.
+function AddTreeItem(name, value, treeChildrenId, attArray) {
+ attArray[attArray.length] = name;
+ var treeChildren = document.getElementById(treeChildrenId);
+ var treeitem = document.createXULElement("treeitem");
+ var treerow = document.createXULElement("treerow");
+
+ var attrCell = document.createXULElement("treecell");
+ attrCell.setAttribute("class", "propertylist");
+ attrCell.setAttribute("label", name);
+
+ var valueCell = document.createXULElement("treecell");
+ valueCell.setAttribute("class", "propertylist");
+ valueCell.setAttribute("label", value);
+
+ treerow.appendChild(attrCell);
+ treerow.appendChild(valueCell);
+ treeitem.appendChild(treerow);
+ treeChildren.appendChild(treeitem);
+
+ // Select item just added, but suppress calling the onSelect handler.
+ gDoOnSelectTree = false;
+ try {
+ selectTreeItem(treeChildren, treeitem);
+ } catch (e) {}
+ gDoOnSelectTree = true;
+
+ return treeitem;
+}
+
+function selectTreeItem(treeChildren, item) {
+ var index = treeChildren.parentNode.view.getIndexOfItem(item);
+ treeChildren.parentNode.view.selection.select(index);
+}
+
+function getSelectedItem(tree) {
+ if (tree.view.selection.count == 1) {
+ return tree.view.getItemAtIndex(tree.currentIndex);
+ }
+ return null;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml
new file mode 100644
index 0000000000..cfeff95b42
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml
@@ -0,0 +1,243 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- first checkin of the year 2000! -->
+<!-- Ben Goodger, 12:50AM, 01/00/00 NZST -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdAdvancedEdit.dtd">
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-width: 40em"
+ title="&WindowTitle.label;"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog id="advancedEditDlg">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <!-- element page functions -->
+ <script src="chrome://messenger/content/messengercompose/EdAEHTMLAttributes.js" />
+ <script src="chrome://messenger/content/messengercompose/EdAECSSAttributes.js" />
+ <script src="chrome://messenger/content/messengercompose/EdAEJSEAttributes.js" />
+ <script src="chrome://messenger/content/messengercompose/EdAEAttributes.js" />
+
+ <!-- global dialog functions -->
+ <script src="chrome://messenger/content/messengercompose/EdAdvancedEdit.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <hbox>
+ <label value="&currentattributesfor.label;" />
+ <label class="header" id="tagLabel" />
+ </hbox>
+
+ <separator class="thin" />
+
+ <tabbox flex="1">
+ <tabs>
+ <tab label="&tabHTML.label;" />
+ <tab label="&tabCSS.label;" />
+ <tab label="&tabJSE.label;" id="tabJSE" />
+ </tabs>
+ <tabpanels flex="1">
+ <!-- ============================================================== -->
+ <!-- HTML Attributes -->
+ <!-- ============================================================== -->
+ <vbox>
+ <tree
+ id="HTMLATree"
+ class="AttributesTree"
+ flex="1"
+ hidecolumnpicker="true"
+ seltype="single"
+ onselect="onSelectHTMLTreeItem();"
+ onclick="onSelectHTMLTreeItem();"
+ ondblclick="editHTMLAttributeValue(event.target);"
+ >
+ <treecols>
+ <treecol id="HTMLAttrCol" label="&tree.attributeHeader.label;" />
+ <splitter class="tree-splitter" />
+ <treecol id="HTMLValCol" label="&tree.valueHeader.label;" />
+ </treecols>
+ <treechildren id="HTMLAList" flex="1" />
+ </tree>
+ <hbox align="center">
+ <label value="&editAttribute.label;" />
+ <spacer flex="1" />
+ <button
+ label="&removeAttribute.label;"
+ oncommand="RemoveHTMLAttribute();"
+ />
+ </hbox>
+ <hbox>
+ <vbox flex="1">
+ <label
+ control="AddHTMLAttributeNameInput"
+ value="&AttName.label;"
+ />
+ <menulist
+ is="menulist-editable"
+ id="AddHTMLAttributeNameInput"
+ class="editorAdvancedEditableMenulist"
+ editable="true"
+ flex="1"
+ oninput="onInputHTMLAttributeName();"
+ oncommand="onInputHTMLAttributeName();"
+ />
+ </vbox>
+ <vbox flex="1">
+ <label
+ id="AddHTMLAttributeValueLabel"
+ control="AddHTMLAttributeValueInput"
+ value="&AttValue.label;"
+ />
+ <vbox flex="1">
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="AddHTMLAttributeValueTextbox"
+ type="text"
+ class="input-inline"
+ onchange="onInputHTMLAttributeValue();"
+ aria-labelledby="AddHTMLAttributeValueLabel"
+ />
+ </hbox>
+ <hbox flex="1" collapsed="true">
+ <menulist
+ is="menulist-editable"
+ id="AddHTMLAttributeValueMenulist"
+ editable="true"
+ flex="1"
+ oninput="onInputHTMLAttributeValue();"
+ oncommand="onInputHTMLAttributeValue();"
+ />
+ </hbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ <!-- ============================================================== -->
+ <!-- CSS Attributes -->
+ <!-- ============================================================== -->
+ <vbox>
+ <tree
+ id="CSSATree"
+ class="AttributesTree"
+ flex="1"
+ hidecolumnpicker="true"
+ seltype="single"
+ onselect="onSelectCSSTreeItem();"
+ onclick="onSelectCSSTreeItem();"
+ ondblclick="editCSSAttributeValue(event.target);"
+ >
+ <treecols>
+ <treecol id="CSSPropCol" label="&tree.propertyHeader.label;" />
+ <splitter class="tree-splitter" />
+ <treecol id="CSSValCol" label="&tree.valueHeader.label;" />
+ </treecols>
+ <treechildren id="CSSAList" flex="1" />
+ </tree>
+ <hbox align="center">
+ <label value="&editAttribute.label;" />
+ <spacer flex="1" />
+ <button
+ label="&removeAttribute.label;"
+ oncommand="RemoveCSSAttribute();"
+ />
+ </hbox>
+ <hbox>
+ <vbox flex="1">
+ <label
+ id="AddCSSAttributeNameLabel"
+ value="&PropertyName.label;"
+ />
+ <html:input
+ id="AddCSSAttributeNameInput"
+ type="text"
+ class="input-inline"
+ onchange="onInputCSSAttributeName();"
+ aria-labelledby="AddCSSAttributeNameLabel"
+ />
+ </vbox>
+ <vbox flex="1">
+ <label id="AddCSSAttributeValueLabel" value="&AttValue.label;" />
+ <html:input
+ id="AddCSSAttributeValueInput"
+ type="text"
+ class="input-inline"
+ onchange="onChangeCSSAttribute();"
+ aria-labelledby="AddCSSAttributeValueLabel"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ <!-- ============================================================== -->
+ <!-- JavaScript Event Handlers -->
+ <!-- ============================================================== -->
+ <vbox>
+ <tree
+ id="JSEATree"
+ class="AttributesTree"
+ flex="1"
+ hidecolumnpicker="true"
+ seltype="single"
+ onselect="onSelectJSETreeItem();"
+ onclick="onSelectJSETreeItem();"
+ ondblclick="editJSEAttributeValue(event.target);"
+ >
+ <treecols>
+ <treecol id="AttrCol" label="&tree.attributeHeader.label;" />
+ <splitter class="tree-splitter" />
+ <treecol id="HeaderCol" label="&tree.valueHeader.label;" />
+ </treecols>
+ <treechildren id="JSEAList" flex="1" />
+ </tree>
+ <hbox align="center">
+ <label value="&editAttribute.label;" />
+ <spacer flex="1" />
+ <button
+ label="&removeAttribute.label;"
+ oncommand="RemoveJSEAttribute()"
+ />
+ </hbox>
+ <hbox>
+ <vbox flex="1">
+ <label value="&AttName.label;" />
+ <menulist
+ id="AddJSEAttributeNameList"
+ oncommand="onSelectJSEAttribute();"
+ />
+ </vbox>
+ <vbox flex="1">
+ <label id="AddJSEAttributeValueLabel" value="&AttValue.label;" />
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="AddJSEAttributeValueInput"
+ type="text"
+ class="input-inline"
+ onchange="onInputJSEAttributeValue();"
+ aria-labelledby="AddJSEAttributeValueLabel"
+ />
+ </hbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </tabpanels>
+ </tabbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdColorPicker.js b/comm/mail/components/compose/content/dialogs/EdColorPicker.js
new file mode 100644
index 0000000000..ef03a1d10b
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorPicker.js
@@ -0,0 +1,290 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+var insertNew = true;
+var tagname = "TAG NAME";
+var gColor = "";
+var LastPickedColor = "";
+var ColorType = "Text";
+var TextType = false;
+var HighlightType = false;
+var TableOrCell = false;
+var LastPickedIsDefault = true;
+var NoDefault = false;
+var gColorObj;
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancelColor);
+
+function Startup() {
+ if (!window.arguments[1]) {
+ dump("EdColorPicker: Missing color object param\n");
+ return;
+ }
+
+ // window.arguments[1] is object to get initial values and return color data
+ gColorObj = window.arguments[1];
+ gColorObj.Cancel = false;
+
+ gDialog.ColorPicker = document.getElementById("ColorPicker");
+ gDialog.ColorInput = document.getElementById("ColorInput");
+ gDialog.LastPickedButton = document.getElementById("LastPickedButton");
+ gDialog.LastPickedColor = document.getElementById("LastPickedColor");
+ gDialog.CellOrTableGroup = document.getElementById("CellOrTableGroup");
+ gDialog.TableRadio = document.getElementById("TableRadio");
+ gDialog.CellRadio = document.getElementById("CellRadio");
+ gDialog.ColorSwatch = document.getElementById("ColorPickerSwatch");
+ gDialog.Ok = document.querySelector("dialog").getButton("accept");
+
+ // The type of color we are setting:
+ // text: Text, Link, ActiveLink, VisitedLink,
+ // or background: Page, Table, or Cell
+ if (gColorObj.Type) {
+ ColorType = gColorObj.Type;
+ // Get string for dialog title from passed-in type
+ // (note constraint on editor.properties string name)
+ let IsCSSPrefChecked = Services.prefs.getBoolPref("editor.use_css");
+
+ if (GetCurrentEditor()) {
+ if (ColorType == "Page" && IsCSSPrefChecked && IsHTMLEditor()) {
+ document.title = GetString("BlockColor");
+ } else {
+ document.title = GetString(ColorType + "Color");
+ }
+ }
+ }
+
+ gDialog.ColorInput.value = "";
+ var tmpColor;
+ var haveTableRadio = false;
+
+ switch (ColorType) {
+ case "Page":
+ tmpColor = gColorObj.PageColor;
+ if (tmpColor && tmpColor.toLowerCase() != "window") {
+ gColor = tmpColor;
+ }
+ break;
+ case "Table":
+ if (gColorObj.TableColor) {
+ gColor = gColorObj.TableColor;
+ }
+ break;
+ case "Cell":
+ if (gColorObj.CellColor) {
+ gColor = gColorObj.CellColor;
+ }
+ break;
+ case "TableOrCell":
+ TableOrCell = true;
+ document.getElementById("TableOrCellGroup").collapsed = false;
+ haveTableRadio = true;
+ if (gColorObj.SelectedType == "Cell") {
+ gColor = gColorObj.CellColor;
+ gDialog.CellOrTableGroup.selectedItem = gDialog.CellRadio;
+ gDialog.CellRadio.focus();
+ } else {
+ gColor = gColorObj.TableColor;
+ gDialog.CellOrTableGroup.selectedItem = gDialog.TableRadio;
+ gDialog.TableRadio.focus();
+ }
+ break;
+ case "Highlight":
+ HighlightType = true;
+ if (gColorObj.HighlightColor) {
+ gColor = gColorObj.HighlightColor;
+ }
+ break;
+ default:
+ // Any other type will change some kind of text,
+ TextType = true;
+ tmpColor = gColorObj.TextColor;
+ if (tmpColor && tmpColor.toLowerCase() != "windowtext") {
+ gColor = gColorObj.TextColor;
+ }
+ break;
+ }
+
+ // Set initial color in input field and in the colorpicker
+ SetCurrentColor(gColor);
+ gDialog.ColorPicker.value = gColor;
+
+ // Use last-picked colors passed in, or those persistent on dialog
+ if (TextType) {
+ if (!("LastTextColor" in gColorObj) || !gColorObj.LastTextColor) {
+ gColorObj.LastTextColor =
+ gDialog.LastPickedColor.getAttribute("LastTextColor");
+ }
+ LastPickedColor = gColorObj.LastTextColor;
+ } else if (HighlightType) {
+ if (!("LastHighlightColor" in gColorObj) || !gColorObj.LastHighlightColor) {
+ gColorObj.LastHighlightColor =
+ gDialog.LastPickedColor.getAttribute("LastHighlightColor");
+ }
+ LastPickedColor = gColorObj.LastHighlightColor;
+ } else {
+ if (
+ !("LastBackgroundColor" in gColorObj) ||
+ !gColorObj.LastBackgroundColor
+ ) {
+ gColorObj.LastBackgroundColor = gDialog.LastPickedColor.getAttribute(
+ "LastBackgroundColor"
+ );
+ }
+ LastPickedColor = gColorObj.LastBackgroundColor;
+ }
+
+ // Set method to detect clicking on OK button
+ // so we don't get fooled by changing "default" behavior
+ gDialog.Ok.setAttribute("onclick", "SetDefaultToOk()");
+
+ if (!LastPickedColor) {
+ // Hide the button, as there is no last color available.
+ gDialog.LastPickedButton.hidden = true;
+ } else {
+ gDialog.LastPickedColor.setAttribute(
+ "style",
+ "background-color: " + LastPickedColor
+ );
+
+ // Make "Last-picked" the default button, until the user selects a color.
+ gDialog.Ok.removeAttribute("default");
+ gDialog.LastPickedButton.setAttribute("default", "true");
+ }
+
+ // Caller can prevent user from submitting an empty, i.e., default color
+ NoDefault = gColorObj.NoDefault;
+ if (NoDefault) {
+ // Hide the "Default button -- user must pick a color
+ document.getElementById("DefaultColorButton").collapsed = true;
+ }
+
+ // Set focus to colorpicker if not set to table radio buttons above
+ if (!haveTableRadio) {
+ gDialog.ColorPicker.focus();
+ }
+
+ SetWindowLocation();
+}
+
+function SelectColor() {
+ var color = gDialog.ColorPicker.value;
+ if (color) {
+ SetCurrentColor(color);
+ }
+}
+
+function RemoveColor() {
+ SetCurrentColor("");
+ gDialog.ColorInput.focus();
+ SetDefaultToOk();
+}
+
+function SelectColorByKeypress(aEvent) {
+ if (aEvent.charCode == aEvent.DOM_VK_SPACE) {
+ SelectColor();
+ SetDefaultToOk();
+ }
+}
+
+function SelectLastPickedColor() {
+ SetCurrentColor(LastPickedColor);
+ if (onAccept()) {
+ // window.close();
+ return true;
+ }
+
+ return false;
+}
+
+function SetCurrentColor(color) {
+ // TODO: Validate color?
+ if (!color) {
+ color = "";
+ }
+ gColor = TrimString(color).toLowerCase();
+ if (gColor == "mixed") {
+ gColor = "";
+ }
+ gDialog.ColorInput.value = gColor;
+ SetColorSwatch();
+}
+
+function SetColorSwatch() {
+ gDialog.ColorSwatch.setAttribute(
+ "style",
+ `background-color: ${TrimString(gDialog.ColorInput.value) || "inherit"}`
+ );
+}
+
+function SetDefaultToOk() {
+ gDialog.LastPickedButton.removeAttribute("default");
+ gDialog.Ok.setAttribute("default", "true");
+ LastPickedIsDefault = false;
+}
+
+function ValidateData() {
+ if (LastPickedIsDefault) {
+ gColor = LastPickedColor;
+ } else {
+ gColor = gDialog.ColorInput.value;
+ }
+
+ gColor = TrimString(gColor).toLowerCase();
+
+ // TODO: Validate the color string!
+
+ if (NoDefault && !gColor) {
+ ShowInputErrorMessage(GetString("NoColorError"));
+ SetTextboxFocus(gDialog.ColorInput);
+ return false;
+ }
+ return true;
+}
+
+function onAccept(event) {
+ if (!ValidateData()) {
+ event.preventDefault();
+ return;
+ }
+
+ // Set return values and save in persistent color attributes
+ if (TextType) {
+ gColorObj.TextColor = gColor;
+ if (gColor.length > 0) {
+ gDialog.LastPickedColor.setAttribute("LastTextColor", gColor);
+ gColorObj.LastTextColor = gColor;
+ }
+ } else if (HighlightType) {
+ gColorObj.HighlightColor = gColor;
+ if (gColor.length > 0) {
+ gDialog.LastPickedColor.setAttribute("LastHighlightColor", gColor);
+ gColorObj.LastHighlightColor = gColor;
+ }
+ } else {
+ gColorObj.BackgroundColor = gColor;
+ if (gColor.length > 0) {
+ gDialog.LastPickedColor.setAttribute("LastBackgroundColor", gColor);
+ gColorObj.LastBackgroundColor = gColor;
+ }
+ // If table or cell requested, tell caller which element to set on
+ if (TableOrCell && gDialog.TableRadio.selected) {
+ gColorObj.Type = "Table";
+ }
+ }
+ SaveWindowLocation();
+}
+
+function onCancelColor() {
+ // Tells caller that user canceled
+ gColorObj.Cancel = true;
+ SaveWindowLocation();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml b/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml
new file mode 100644
index 0000000000..8576fc27da
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml
@@ -0,0 +1,103 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdColorPicker.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdColorPicker.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <hbox id="TableOrCellGroup" align="center" collapsed="true">
+ <label
+ control="CellOrTableGroup"
+ value="&background.label;"
+ accesskey="&background.accessKey;"
+ />
+ <radiogroup id="CellOrTableGroup" orient="horizontal">
+ <radio
+ id="TableRadio"
+ label="&table.label;"
+ accesskey="&table.accessKey;"
+ />
+ <radio
+ id="CellRadio"
+ label="&cell.label;"
+ accesskey="&cell.accessKey;"
+ />
+ </radiogroup>
+ </hbox>
+ <hbox align="center">
+ <label value="&chooseColor1.label;" />
+ <html:input
+ type="color"
+ id="ColorPicker"
+ onclick="SetDefaultToOk();"
+ ondblclick="if (onAccept()) { window.close(); }"
+ onkeypress="SelectColorByKeypress(event);"
+ onchange="SelectColor();"
+ />
+ <spacer flex="1" />
+ <button
+ id="LastPickedButton"
+ label="&lastPickedColor.label;"
+ accesskey="&lastPickedColor.accessKey;"
+ crop="right"
+ oncommand="SelectLastPickedColor();"
+ >
+ <spacer
+ id="LastPickedColor"
+ LastTextColor=""
+ LastBackgroundColor=""
+ persist="LastTextColor LastBackgroundColor"
+ />
+ </button>
+ </hbox>
+
+ <spacer class="spacer" />
+ <hbox align="center" flex="1">
+ <vbox>
+ <label
+ class="tip-caption"
+ value="&chooseColor2.label;"
+ accesskey="&chooseColor2.accessKey;"
+ control="ColorInput"
+ />
+ <label class="tip-caption" value="&setColorExample.label;" />
+ </vbox>
+ <html:input
+ id="ColorInput"
+ type="text"
+ style="width: 8em"
+ oninput="SetColorSwatch(); SetDefaultToOk();"
+ />
+ <label id="ColorPickerSwatch" />
+ <spacer flex="1" />
+ <button
+ id="DefaultColorButton"
+ label="&default.label;"
+ accesskey="&default.accessKey;"
+ oncommand="RemoveColor()"
+ />
+ </hbox>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdColorProps.js b/comm/mail/components/compose/content/dialogs/EdColorProps.js
new file mode 100644
index 0000000000..c2635912d5
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorProps.js
@@ -0,0 +1,476 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ Behavior notes:
+ Radio buttons select "UseDefaultColors" vs. "UseCustomColors" modes.
+ If any color attribute is set in the body, mode is "Custom Colors",
+ even if 1 or more (but not all) are actually null (= "use default")
+ When in "Custom Colors" mode, all colors will be set on body tag,
+ even if they are just default colors, to assure compatible colors in page.
+ User cannot select "use default" for individual colors
+*/
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+var gBodyElement;
+var prefs;
+var gBackgroundImage;
+
+// Initialize in case we can't get them from prefs???
+var defaultTextColor = "#000000";
+var defaultLinkColor = "#000099";
+var defaultActiveColor = "#000099";
+var defaultVisitedColor = "#990099";
+var defaultBackgroundColor = "#FFFFFF";
+const styleStr = "style";
+const textStr = "text";
+const linkStr = "link";
+const vlinkStr = "vlink";
+const alinkStr = "alink";
+const bgcolorStr = "bgcolor";
+const backgroundStr = "background";
+const cssColorStr = "color";
+const cssBackgroundColorStr = "background-color";
+const cssBackgroundImageStr = "background-image";
+const colorStyle = cssColorStr + ": ";
+const backColorStyle = cssBackgroundColorStr + ": ";
+const backImageStyle = "; " + cssBackgroundImageStr + ": url(";
+
+var customTextColor;
+var customLinkColor;
+var customActiveColor;
+var customVisitedColor;
+var customBackgroundColor;
+var previewBGColor;
+
+// dialog initialization code
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ gDialog.ColorPreview = document.getElementById("ColorPreview");
+ gDialog.NormalText = document.getElementById("NormalText");
+ gDialog.LinkText = document.getElementById("LinkText");
+ gDialog.ActiveLinkText = document.getElementById("ActiveLinkText");
+ gDialog.VisitedLinkText = document.getElementById("VisitedLinkText");
+ gDialog.PageColorGroup = document.getElementById("PageColorGroup");
+ gDialog.DefaultColorsRadio = document.getElementById("DefaultColorsRadio");
+ gDialog.CustomColorsRadio = document.getElementById("CustomColorsRadio");
+ gDialog.BackgroundImageInput = document.getElementById(
+ "BackgroundImageInput"
+ );
+
+ try {
+ gBodyElement = editor.rootElement;
+ } catch (e) {}
+
+ if (!gBodyElement) {
+ dump("Failed to get BODY element!\n");
+ window.close();
+ }
+
+ // Set element we will edit
+ globalElement = gBodyElement.cloneNode(false);
+
+ // Initialize default colors from browser prefs
+ var browserColors = GetDefaultBrowserColors();
+ if (browserColors) {
+ // Use author's browser pref colors passed into dialog
+ defaultTextColor = browserColors.TextColor;
+ defaultLinkColor = browserColors.LinkColor;
+ defaultActiveColor = browserColors.ActiveLinkColor;
+ defaultVisitedColor = browserColors.VisitedLinkColor;
+ defaultBackgroundColor = browserColors.BackgroundColor;
+ }
+
+ // We only need to test for this once per dialog load
+ gHaveDocumentUrl = GetDocumentBaseUrl();
+
+ InitDialog();
+
+ gDialog.PageColorGroup.focus();
+
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ // Get image from document
+ gBackgroundImage = GetHTMLOrCSSStyleValue(
+ globalElement,
+ backgroundStr,
+ cssBackgroundImageStr
+ );
+ if (/url\((.*)\)/.test(gBackgroundImage)) {
+ gBackgroundImage = RegExp.$1;
+ }
+
+ if (gBackgroundImage) {
+ // Shorten data URIs for display.
+ shortenImageData(gBackgroundImage, gDialog.BackgroundImageInput);
+ gDialog.ColorPreview.setAttribute(
+ styleStr,
+ backImageStyle + gBackgroundImage + ");"
+ );
+ }
+
+ SetRelativeCheckbox();
+
+ customTextColor = GetHTMLOrCSSStyleValue(globalElement, textStr, cssColorStr);
+ customTextColor = ConvertRGBColorIntoHEXColor(customTextColor);
+ customLinkColor = globalElement.getAttribute(linkStr);
+ customActiveColor = globalElement.getAttribute(alinkStr);
+ customVisitedColor = globalElement.getAttribute(vlinkStr);
+ customBackgroundColor = GetHTMLOrCSSStyleValue(
+ globalElement,
+ bgcolorStr,
+ cssBackgroundColorStr
+ );
+ customBackgroundColor = ConvertRGBColorIntoHEXColor(customBackgroundColor);
+
+ var haveCustomColor =
+ customTextColor ||
+ customLinkColor ||
+ customVisitedColor ||
+ customActiveColor ||
+ customBackgroundColor;
+
+ // Set default color explicitly for any that are missing
+ // PROBLEM: We are using "windowtext" and "window" for the Windows OS
+ // default color values. This works with CSS in preview window,
+ // but we should NOT use these as values for HTML attributes!
+
+ if (!customTextColor) {
+ customTextColor = defaultTextColor;
+ }
+ if (!customLinkColor) {
+ customLinkColor = defaultLinkColor;
+ }
+ if (!customActiveColor) {
+ customActiveColor = defaultActiveColor;
+ }
+ if (!customVisitedColor) {
+ customVisitedColor = defaultVisitedColor;
+ }
+ if (!customBackgroundColor) {
+ customBackgroundColor = defaultBackgroundColor;
+ }
+
+ if (haveCustomColor) {
+ // If any colors are set, then check the "Custom" radio button
+ gDialog.PageColorGroup.selectedItem = gDialog.CustomColorsRadio;
+ UseCustomColors();
+ } else {
+ gDialog.PageColorGroup.selectedItem = gDialog.DefaultColorsRadio;
+ UseDefaultColors();
+ }
+}
+
+function GetColorAndUpdate(ColorWellID) {
+ // Only allow selecting when in custom mode
+ if (!gDialog.CustomColorsRadio.selected) {
+ return;
+ }
+
+ var colorWell = document.getElementById(ColorWellID);
+ if (!colorWell) {
+ return;
+ }
+
+ // Don't allow a blank color, i.e., using the "default"
+ var colorObj = {
+ NoDefault: true,
+ Type: "",
+ TextColor: 0,
+ PageColor: 0,
+ Cancel: false,
+ };
+
+ switch (ColorWellID) {
+ case "textCW":
+ colorObj.Type = "Text";
+ colorObj.TextColor = customTextColor;
+ break;
+ case "linkCW":
+ colorObj.Type = "Link";
+ colorObj.TextColor = customLinkColor;
+ break;
+ case "activeCW":
+ colorObj.Type = "ActiveLink";
+ colorObj.TextColor = customActiveColor;
+ break;
+ case "visitedCW":
+ colorObj.Type = "VisitedLink";
+ colorObj.TextColor = customVisitedColor;
+ break;
+ case "backgroundCW":
+ colorObj.Type = "Page";
+ colorObj.PageColor = customBackgroundColor;
+ break;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ colorObj
+ );
+
+ // User canceled the dialog
+ if (colorObj.Cancel) {
+ return;
+ }
+
+ var color = "";
+ switch (ColorWellID) {
+ case "textCW":
+ color = customTextColor = colorObj.TextColor;
+ break;
+ case "linkCW":
+ color = customLinkColor = colorObj.TextColor;
+ break;
+ case "activeCW":
+ color = customActiveColor = colorObj.TextColor;
+ break;
+ case "visitedCW":
+ color = customVisitedColor = colorObj.TextColor;
+ break;
+ case "backgroundCW":
+ color = customBackgroundColor = colorObj.BackgroundColor;
+ break;
+ }
+
+ setColorWell(ColorWellID, color);
+ SetColorPreview(ColorWellID, color);
+}
+
+function SetColorPreview(ColorWellID, color) {
+ switch (ColorWellID) {
+ case "textCW":
+ gDialog.NormalText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "linkCW":
+ gDialog.LinkText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "activeCW":
+ gDialog.ActiveLinkText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "visitedCW":
+ gDialog.VisitedLinkText.setAttribute(styleStr, colorStyle + color);
+ break;
+ case "backgroundCW":
+ // Must combine background color and image style values
+ var styleValue = backColorStyle + color;
+ if (gBackgroundImage) {
+ styleValue += ";" + backImageStyle + gBackgroundImage + ");";
+ }
+
+ gDialog.ColorPreview.setAttribute(styleStr, styleValue);
+ previewBGColor = color;
+ break;
+ }
+}
+
+function UseCustomColors() {
+ SetElementEnabledById("TextButton", true);
+ SetElementEnabledById("LinkButton", true);
+ SetElementEnabledById("ActiveLinkButton", true);
+ SetElementEnabledById("VisitedLinkButton", true);
+ SetElementEnabledById("BackgroundButton", true);
+ SetElementEnabledById("Text", true);
+ SetElementEnabledById("Link", true);
+ SetElementEnabledById("Active", true);
+ SetElementEnabledById("Visited", true);
+ SetElementEnabledById("Background", true);
+
+ SetColorPreview("textCW", customTextColor);
+ SetColorPreview("linkCW", customLinkColor);
+ SetColorPreview("activeCW", customActiveColor);
+ SetColorPreview("visitedCW", customVisitedColor);
+ SetColorPreview("backgroundCW", customBackgroundColor);
+
+ setColorWell("textCW", customTextColor);
+ setColorWell("linkCW", customLinkColor);
+ setColorWell("activeCW", customActiveColor);
+ setColorWell("visitedCW", customVisitedColor);
+ setColorWell("backgroundCW", customBackgroundColor);
+}
+
+function UseDefaultColors() {
+ SetColorPreview("textCW", defaultTextColor);
+ SetColorPreview("linkCW", defaultLinkColor);
+ SetColorPreview("activeCW", defaultActiveColor);
+ SetColorPreview("visitedCW", defaultVisitedColor);
+ SetColorPreview("backgroundCW", defaultBackgroundColor);
+
+ // Setting to blank color will remove color from buttons,
+ setColorWell("textCW", "");
+ setColorWell("linkCW", "");
+ setColorWell("activeCW", "");
+ setColorWell("visitedCW", "");
+ setColorWell("backgroundCW", "");
+
+ // Disable color buttons and labels
+ SetElementEnabledById("TextButton", false);
+ SetElementEnabledById("LinkButton", false);
+ SetElementEnabledById("ActiveLinkButton", false);
+ SetElementEnabledById("VisitedLinkButton", false);
+ SetElementEnabledById("BackgroundButton", false);
+ SetElementEnabledById("Text", false);
+ SetElementEnabledById("Link", false);
+ SetElementEnabledById("Active", false);
+ SetElementEnabledById("Visited", false);
+ SetElementEnabledById("Background", false);
+}
+
+function chooseFile() {
+ // Get a local image file, converted into URL format
+ GetLocalFileURL("img").then(fileURL => {
+ // Always try to relativize local file URLs
+ if (gHaveDocumentUrl) {
+ fileURL = MakeRelativeUrl(fileURL);
+ }
+
+ gDialog.BackgroundImageInput.value = fileURL;
+
+ SetRelativeCheckbox();
+ ValidateAndPreviewImage(true);
+ SetTextboxFocus(gDialog.BackgroundImageInput);
+ });
+}
+
+function ChangeBackgroundImage() {
+ // Don't show error message for image while user is typing
+ ValidateAndPreviewImage(false);
+ SetRelativeCheckbox();
+}
+
+function ValidateAndPreviewImage(ShowErrorMessage) {
+ // First make a string with just background color
+ var styleValue = backColorStyle + previewBGColor + ";";
+
+ var retVal = true;
+ var image = TrimString(gDialog.BackgroundImageInput.value);
+ if (image) {
+ if (isImageDataShortened(image)) {
+ gBackgroundImage = restoredImageData(gDialog.BackgroundImageInput);
+ } else {
+ gBackgroundImage = image;
+
+ // Display must use absolute URL if possible
+ var displayImage = gHaveDocumentUrl ? MakeAbsoluteUrl(image) : image;
+ styleValue += backImageStyle + displayImage + ");";
+ }
+ } else {
+ gBackgroundImage = null;
+ }
+
+ // Set style on preview (removes image if not valid)
+ gDialog.ColorPreview.setAttribute(styleStr, styleValue);
+
+ // Note that an "empty" string is valid
+ return retVal;
+}
+
+function ValidateData() {
+ var editor = GetCurrentEditor();
+ try {
+ // Colors values are updated as they are picked, no validation necessary
+ if (gDialog.DefaultColorsRadio.selected) {
+ editor.removeAttributeOrEquivalent(globalElement, textStr, true);
+ globalElement.removeAttribute(linkStr);
+ globalElement.removeAttribute(vlinkStr);
+ globalElement.removeAttribute(alinkStr);
+ editor.removeAttributeOrEquivalent(globalElement, bgcolorStr, true);
+ } else {
+ // Do NOT accept the CSS "WindowsOS" color strings!
+ // Problem: We really should try to get the actual color values
+ // from windows, but I don't know how to do that!
+ var tmpColor = customTextColor.toLowerCase();
+ if (tmpColor != "windowtext") {
+ editor.setAttributeOrEquivalent(
+ globalElement,
+ textStr,
+ customTextColor,
+ true
+ );
+ } else {
+ editor.removeAttributeOrEquivalent(globalElement, textStr, true);
+ }
+
+ tmpColor = customBackgroundColor.toLowerCase();
+ if (tmpColor != "window") {
+ editor.setAttributeOrEquivalent(
+ globalElement,
+ bgcolorStr,
+ customBackgroundColor,
+ true
+ );
+ } else {
+ editor.removeAttributeOrEquivalent(globalElement, bgcolorStr, true);
+ }
+
+ globalElement.setAttribute(linkStr, customLinkColor);
+ globalElement.setAttribute(vlinkStr, customVisitedColor);
+ globalElement.setAttribute(alinkStr, customActiveColor);
+ }
+
+ if (ValidateAndPreviewImage(true)) {
+ // A valid image may be null for no image
+ if (gBackgroundImage) {
+ globalElement.setAttribute(backgroundStr, gBackgroundImage);
+ } else {
+ editor.removeAttributeOrEquivalent(globalElement, backgroundStr, true);
+ }
+
+ return true;
+ }
+ } catch (e) {}
+ return false;
+}
+
+function onAccept(event) {
+ // If it's a file, convert to a data URL.
+ if (gBackgroundImage && /^file:/i.test(gBackgroundImage)) {
+ let nsFile = Services.io
+ .newURI(gBackgroundImage)
+ .QueryInterface(Ci.nsIFileURL).file;
+ if (nsFile.exists()) {
+ let reader = new FileReader();
+ reader.addEventListener("load", function () {
+ gBackgroundImage = reader.result;
+ gDialog.BackgroundImageInput.value = reader.result;
+ if (onAccept(event)) {
+ window.close();
+ }
+ });
+ File.createFromNsIFile(nsFile).then(file => {
+ reader.readAsDataURL(file);
+ });
+ event.preventDefault(); // Don't close just yet...
+ return false;
+ }
+ }
+ if (ValidateData()) {
+ // Copy attributes to element we are changing
+ try {
+ GetCurrentEditor().cloneAttributes(gBodyElement, globalElement);
+ } catch (e) {}
+
+ SaveWindowLocation();
+ return true; // do close the window
+ }
+ event.preventDefault();
+ return false;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml b/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml
new file mode 100644
index 0000000000..633b1639d9
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml
@@ -0,0 +1,211 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edColorPropertiesDTD SYSTEM "chrome://messenger/locale/messengercompose/EditorColorProperties.dtd">
+%edColorPropertiesDTD;
+<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd">
+%composeEditorOverlayDTD;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdColorProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset align="start">
+ <html:legend>&pageColors.label;</html:legend>
+ <radiogroup id="PageColorGroup">
+ <radio
+ id="DefaultColorsRadio"
+ label="&defaultColorsRadio.label;"
+ oncommand="UseDefaultColors()"
+ accesskey="&defaultColorsRadio.accessKey;"
+ tooltiptext="&defaultColorsRadio.tooltip;"
+ />
+ <radio
+ id="CustomColorsRadio"
+ label="&customColorsRadio.label;"
+ oncommand="UseCustomColors()"
+ accesskey="&customColorsRadio.accessKey;"
+ tooltiptext="&customColorsRadio.tooltip;"
+ />
+ </radiogroup>
+ <hbox class="indent">
+ <hbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Text"
+ control="TextButton"
+ value="&normalText.label;&colon.character;"
+ accesskey="&normalText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Link"
+ flex="1"
+ control="LinkButton"
+ value="&linkText.label;&colon.character;"
+ accesskey="&linkText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Active"
+ flex="1"
+ control="ActiveLinkButton"
+ value="&activeLinkText.label;&colon.character;"
+ accesskey="&activeLinkText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Visited"
+ flex="1"
+ control="VisitedLinkButton"
+ value="&visitedLinkText.label;&colon.character;"
+ accesskey="&visitedLinkText.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="Background"
+ flex="1"
+ control="BackgroundButton"
+ value="&background.label;"
+ accesskey="&background.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <button
+ id="TextButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('textCW');"
+ >
+ <spacer id="textCW" class="color-well" />
+ </button>
+ <button
+ id="LinkButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('linkCW');"
+ >
+ <spacer id="linkCW" class="color-well" />
+ </button>
+ <button
+ id="ActiveLinkButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('activeCW');"
+ >
+ <spacer id="activeCW" class="color-well" />
+ </button>
+ <button
+ id="VisitedLinkButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('visitedCW');"
+ >
+ <spacer id="visitedCW" class="color-well" />
+ </button>
+ <button
+ id="BackgroundButton"
+ class="color-button"
+ oncommand="GetColorAndUpdate('backgroundCW');"
+ >
+ <spacer id="backgroundCW" class="color-well" />
+ </button>
+ </vbox>
+ </hbox>
+ <vbox id="ColorPreview">
+ <spacer flex="1" />
+ <label class="larger" id="NormalText" value="&normalText.label;" />
+ <spacer flex="1" />
+ <label class="larger" id="LinkText" value="&linkText.label;" />
+ <spacer flex="1" />
+ <label
+ class="larger"
+ id="ActiveLinkText"
+ value="&activeLinkText.label;"
+ />
+ <spacer flex="1" />
+ <label
+ class="larger"
+ id="VisitedLinkText"
+ value="&visitedLinkText.label;"
+ />
+ <spacer flex="1" />
+ </vbox>
+ <spacer flex="1" />
+ </hbox>
+ <spacer class="spacer" />
+ </html:fieldset>
+ <spacer class="spacer" />
+ <label
+ control="BackgroundImageInput"
+ value="&backgroundImage.label;"
+ tooltiptext="&backgroundImage.tooltip;"
+ accesskey="&backgroundImage.accessKey;"
+ />
+ <tooltip id="shortenedDataURI">
+ <label value="&backgroundImage.shortenedDataURI;" />
+ </tooltip>
+ <html:input
+ id="BackgroundImageInput"
+ type="text"
+ class="uri-element input-inline"
+ onchange="ChangeBackgroundImage()"
+ aria-label="&backgroundImage.tooltip;"
+ />
+ <hbox align="center">
+ <checkbox
+ id="MakeRelativeCheckbox"
+ for="BackgroundImageInput"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ id="ChooseFile"
+ oncommand="chooseFile()"
+ label="&chooseFileButton.label;"
+ accesskey="&chooseFileButton.accessKey;"
+ />
+ </hbox>
+ <spacer class="smallspacer" />
+ <hbox>
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton"
+ oncommand="onAdvancedEdit();"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdConvertToTable.js b/comm/mail/components/compose/content/dialogs/EdConvertToTable.js
new file mode 100644
index 0000000000..e7f19cff67
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdConvertToTable.js
@@ -0,0 +1,325 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+var gIndex;
+var gCommaIndex = "0";
+var gSpaceIndex = "1";
+var gOtherIndex = "2";
+
+// dialog initialization code
+function Startup() {
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+
+ gDialog.sepRadioGroup = document.getElementById("SepRadioGroup");
+ gDialog.sepCharacterInput = document.getElementById("SepCharacterInput");
+ gDialog.deleteSepCharacter = document.getElementById("DeleteSepCharacter");
+ gDialog.collapseSpaces = document.getElementById("CollapseSpaces");
+
+ // We persist the user's separator character
+ gDialog.sepCharacterInput.value =
+ gDialog.sepRadioGroup.getAttribute("character");
+
+ gIndex = gDialog.sepRadioGroup.getAttribute("index");
+
+ switch (gIndex) {
+ case gCommaIndex:
+ default:
+ gDialog.sepRadioGroup.selectedItem = document.getElementById("comma");
+ break;
+ case gSpaceIndex:
+ gDialog.sepRadioGroup.selectedItem = document.getElementById("space");
+ break;
+ case gOtherIndex:
+ gDialog.sepRadioGroup.selectedItem = document.getElementById("other");
+ break;
+ }
+
+ // Set initial enable state on character input and "collapse" checkbox
+ SelectCharacter(gIndex);
+
+ SetWindowLocation();
+}
+
+function InputSepCharacter() {
+ var str = gDialog.sepCharacterInput.value;
+
+ // Limit input to 1 character
+ if (str.length > 1) {
+ str = str.slice(0, 1);
+ }
+
+ // We can never allow tag or entity delimiters for separator character
+ if (str == "<" || str == ">" || str == "&" || str == ";" || str == " ") {
+ str = "";
+ }
+
+ gDialog.sepCharacterInput.value = str;
+}
+
+function SelectCharacter(radioGroupIndex) {
+ gIndex = radioGroupIndex;
+ SetElementEnabledById("SepCharacterInput", gIndex == gOtherIndex);
+ SetElementEnabledById("CollapseSpaces", gIndex == gSpaceIndex);
+}
+
+/* eslint-disable complexity */
+function onAccept() {
+ var sepCharacter = "";
+ switch (gIndex) {
+ case gCommaIndex:
+ sepCharacter = ",";
+ break;
+ case gSpaceIndex:
+ sepCharacter = " ";
+ break;
+ case gOtherIndex:
+ sepCharacter = gDialog.sepCharacterInput.value.slice(0, 1);
+ break;
+ }
+
+ var editor = GetCurrentEditor();
+ var str;
+ try {
+ str = editor.outputToString(
+ "text/html",
+ kOutputLFLineBreak | kOutputSelectionOnly
+ );
+ } catch (e) {}
+ if (!str) {
+ SaveWindowLocation();
+ return;
+ }
+
+ // Replace nbsp with spaces:
+ str = str.replace(/\u00a0/g, " ");
+
+ // Strip out </p> completely
+ str = str.replace(/\s*<\/p>\s*/g, "");
+
+ // Trim whitespace adjacent to <p> and <br> tags
+ // and replace <p> with <br>
+ // (which will be replaced with </tr> below)
+ str = str.replace(/\s*<p>\s*|\s*<br>\s*/g, "<br>");
+
+ // Trim leading <br>s
+ str = str.replace(/^(<br>)+/, "");
+
+ // Trim trailing <br>s
+ str = str.replace(/(<br>)+$/, "");
+
+ // Reduce multiple internal <br> to just 1
+ // TODO: Maybe add a checkbox to let user decide
+ // str = str.replace(/(<br>)+/g, "<br>");
+
+ // Trim leading and trailing spaces
+ str = str.trim();
+
+ // Remove all tag contents so we don't replace
+ // separator character within tags
+ // Also converts lists to something useful
+ var stack = [];
+ var start;
+ var end;
+ var searchStart = 0;
+ var listSeparator = "";
+ var listItemSeparator = "";
+ var endList = false;
+
+ do {
+ start = str.indexOf("<", searchStart);
+
+ if (start >= 0) {
+ end = str.indexOf(">", start + 1);
+ if (end > start) {
+ let tagContent = str.slice(start + 1, end).trim();
+
+ if (/^ol|^ul|^dl/.test(tagContent)) {
+ // Replace list tag with <BR> to start new row
+ // at beginning of second or greater list tag
+ str = str.slice(0, start) + listSeparator + str.slice(end + 1);
+ if (listSeparator == "") {
+ listSeparator = "<br>";
+ }
+
+ // Reset for list item separation into cells
+ listItemSeparator = "";
+ } else if (/^li|^dt|^dd/.test(tagContent)) {
+ // Start a new row if this is first item after the ending the last list
+ if (endList) {
+ listItemSeparator = "<br>";
+ }
+
+ // Start new cell at beginning of second or greater list items
+ str = str.slice(0, start) + listItemSeparator + str.slice(end + 1);
+
+ if (endList || listItemSeparator == "") {
+ listItemSeparator = sepCharacter;
+ }
+
+ endList = false;
+ } else {
+ // Find end tags
+ endList = /^\/ol|^\/ul|^\/dl/.test(tagContent);
+ if (endList || /^\/li|^\/dt|^\/dd/.test(tagContent)) {
+ // Strip out tag
+ str = str.slice(0, start) + str.slice(end + 1);
+ } else {
+ // Not a list-related tag: Store tag contents in an array
+ stack.push(tagContent);
+
+ // Keep the "<" and ">" while removing from source string
+ start++;
+ str = str.slice(0, start) + str.slice(end);
+ }
+ }
+ }
+ searchStart = start + 1;
+ }
+ } while (start >= 0);
+
+ // Replace separator characters with table cells
+ var replaceString;
+ if (gDialog.deleteSepCharacter.checked) {
+ replaceString = "";
+ } else {
+ // Don't delete separator character,
+ // so include it at start of string to replace
+ replaceString = sepCharacter;
+ }
+
+ replaceString += "<td>";
+
+ if (sepCharacter.length > 0) {
+ var tempStr = sepCharacter;
+ var regExpChars = ".!@#$%^&*-+[]{}()|\\/";
+ if (regExpChars.includes(sepCharacter)) {
+ tempStr = "\\" + sepCharacter;
+ }
+
+ if (gIndex == gSpaceIndex) {
+ // If checkbox is checked,
+ // one or more adjacent spaces are one separator
+ if (gDialog.collapseSpaces.checked) {
+ tempStr = "\\s+";
+ } else {
+ tempStr = "\\s";
+ }
+ }
+ var pattern = new RegExp(tempStr, "g");
+ str = str.replace(pattern, replaceString);
+ }
+
+ // Put back tag contents that we removed above
+ searchStart = 0;
+ var stackIndex = 0;
+ do {
+ start = str.indexOf("<", searchStart);
+ end = start + 1;
+ if (start >= 0 && str.charAt(end) == ">") {
+ // We really need a FIFO stack!
+ str = str.slice(0, end) + stack[stackIndex++] + str.slice(end);
+ }
+ searchStart = end;
+ } while (start >= 0);
+
+ // End table row and start another for each br or p
+ str = str.replace(/\s*<br>\s*/g, "</tr>\n<tr><td>");
+
+ // Add the table tags and the opening and closing tr/td tags
+ // Default table attributes should be same as those used in nsHTMLEditor::CreateElementWithDefaults()
+ // (Default width="100%" is used in EdInsertTable.js)
+ str =
+ '<table border="1" width="100%" cellpadding="2" cellspacing="2">\n<tr><td>' +
+ str +
+ "</tr>\n</table>\n";
+
+ editor.beginTransaction();
+
+ // Delete the selection -- makes it easier to find where table will insert
+ var nodeBeforeTable = null;
+ var nodeAfterTable = null;
+ try {
+ editor.deleteSelection(editor.eNone, editor.eStrip);
+
+ var anchorNodeBeforeInsert = editor.selection.anchorNode;
+ var offset = editor.selection.anchorOffset;
+ if (anchorNodeBeforeInsert.nodeType == Node.TEXT_NODE) {
+ // Text was split. Table should be right after the first or before
+ nodeBeforeTable = anchorNodeBeforeInsert.previousSibling;
+ nodeAfterTable = anchorNodeBeforeInsert;
+ } else {
+ // Table should be inserted right after node pointed to by selection
+ if (offset > 0) {
+ nodeBeforeTable = anchorNodeBeforeInsert.childNodes.item(offset - 1);
+ }
+
+ nodeAfterTable = anchorNodeBeforeInsert.childNodes.item(offset);
+ }
+
+ editor.insertHTML(str);
+ } catch (e) {}
+
+ var table = null;
+ if (nodeAfterTable) {
+ var previous = nodeAfterTable.previousSibling;
+ if (previous && previous.nodeName.toLowerCase() == "table") {
+ table = previous;
+ }
+ }
+ if (!table && nodeBeforeTable) {
+ var next = nodeBeforeTable.nextSibling;
+ if (next && next.nodeName.toLowerCase() == "table") {
+ table = next;
+ }
+ }
+
+ if (table) {
+ // Fixup table only if pref is set
+ var firstRow;
+ try {
+ if (Services.prefs.getBoolPref("editor.table.maintain_structure")) {
+ editor.normalizeTable(table);
+ }
+
+ firstRow = editor.getFirstRow(table);
+ } catch (e) {}
+
+ // Put caret in first cell
+ if (firstRow) {
+ var node2 = firstRow.firstChild;
+ do {
+ if (
+ node2.nodeName.toLowerCase() == "td" ||
+ node2.nodeName.toLowerCase() == "th"
+ ) {
+ try {
+ editor.selection.collapse(node2, 0);
+ } catch (e) {}
+ break;
+ }
+ node2 = node2.nextSibling;
+ } while (node2);
+ }
+ }
+
+ editor.endTransaction();
+
+ // Save persisted attributes
+ gDialog.sepRadioGroup.setAttribute("index", gIndex);
+ if (gIndex == gOtherIndex) {
+ gDialog.sepRadioGroup.setAttribute("character", sepCharacter);
+ }
+
+ SaveWindowLocation();
+}
+/* eslint-enable complexity */
diff --git a/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml b/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml
new file mode 100644
index 0000000000..6f2d9ad5b1
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml
@@ -0,0 +1,86 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdConvertToTable.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup()"
+ lightweightthemes="true"
+ style="min-width: 20em"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <!--- Element-specific methods -->
+ <script src="chrome://messenger/content/messengercompose/EdConvertToTable.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+ <description class="wrap" flex="1">&instructions1.label;</description>
+ <description class="wrap" flex="1">&instructions2.label;</description>
+ <radiogroup
+ id="SepRadioGroup"
+ persist="index character"
+ index="0"
+ character=""
+ >
+ <radio
+ id="comma"
+ label="&commaRadio.label;"
+ oncommand="SelectCharacter('0');"
+ />
+ <radio
+ id="space"
+ label="&spaceRadio.label;"
+ oncommand="SelectCharacter('1');"
+ />
+ <hbox>
+ <spacer class="radio-spacer" />
+ <checkbox
+ id="CollapseSpaces"
+ label="&collapseSpaces.label;"
+ checked="true"
+ persist="checked"
+ tooltiptext="&collapseSpaces.tooltip;"
+ />
+ </hbox>
+ <hbox align="center">
+ <radio
+ id="other"
+ label="&otherRadio.label;"
+ oncommand="SelectCharacter('2');"
+ />
+ <html:input
+ id="SepCharacterInput"
+ type="text"
+ aria-labelledby="other"
+ class="narrow input-inline"
+ oninput="InputSepCharacter()"
+ />
+ </hbox>
+ </radiogroup>
+ <spacer class="spacer" />
+ <checkbox
+ id="DeleteSepCharacter"
+ label="&deleteCharCheck.label;"
+ persist="checked"
+ />
+ <spacer class="spacer" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdDialogCommon.js b/comm/mail/components/compose/content/dialogs/EdDialogCommon.js
new file mode 100644
index 0000000000..ce377e4bbf
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdDialogCommon.js
@@ -0,0 +1,679 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Each editor window must include this file
+
+/* import-globals-from ../editorUtilities.js */
+/* globals InitDialog, ChangeLinkLocation, ValidateData */
+
+// Object to attach commonly-used widgets (all dialogs should use this)
+var gDialog = {};
+
+var gHaveDocumentUrl = false;
+var gValidationError = false;
+
+// Use for 'defaultIndex' param in InitPixelOrPercentMenulist
+const gPixel = 0;
+const gPercent = 1;
+
+const gMaxPixels = 100000; // Used for image size, borders, spacing, and padding
+// Gecko code uses 1000 for maximum rowspan, colspan
+// Also, editing performance is really bad above this
+const gMaxRows = 1000;
+const gMaxColumns = 1000;
+const gMaxTableSize = 1000000; // Width or height of table or cells
+
+// A XUL element with id="location" for managing
+// dialog location relative to parent window
+var gLocation;
+
+// The element being edited - so AdvancedEdit can have access to it
+var globalElement;
+
+/* Validate contents of an input field
+ *
+ * inputWidget The 'input' element for the the attribute's value
+ * listWidget The 'menulist' XUL element for choosing "pixel" or "percent"
+ * May be null when no pixel/percent is used.
+ * minVal minimum allowed for input widget's value
+ * maxVal maximum allowed for input widget's value
+ * (when "listWidget" is used, maxVal is used for "pixel" maximum,
+ * 100% is assumed if "percent" is the user's choice)
+ * element The DOM element that we set the attribute on. May be null.
+ * attName Name of the attribute to set. May be null or ignored if "element" is null
+ * mustHaveValue If true, error dialog is displayed if "value" is empty string
+ *
+ * This calls "ValidateNumberRange()", which puts up an error dialog to inform the user.
+ * If error, we also:
+ * Shift focus and select contents of the inputWidget,
+ * Switch to appropriate panel of tabbed dialog if user implements "SwitchToValidate()",
+ * and/or will expand the dialog to full size if "More / Fewer" feature is implemented
+ *
+ * Returns the "value" as a string, or "" if error or input contents are empty
+ * The global "gValidationError" variable is set true if error was found
+ */
+function ValidateNumber(
+ inputWidget,
+ listWidget,
+ minVal,
+ maxVal,
+ element,
+ attName,
+ mustHaveValue
+) {
+ if (!inputWidget) {
+ gValidationError = true;
+ return "";
+ }
+
+ // Global error return value
+ gValidationError = false;
+ var maxLimit = maxVal;
+ var isPercent = false;
+
+ var numString = TrimString(inputWidget.value);
+ if (numString || mustHaveValue) {
+ if (listWidget) {
+ isPercent = listWidget.selectedIndex == 1;
+ }
+ if (isPercent) {
+ maxLimit = 100;
+ }
+
+ // This method puts up the error message
+ numString = ValidateNumberRange(numString, minVal, maxLimit, mustHaveValue);
+ if (!numString) {
+ // Switch to appropriate panel for error reporting
+ SwitchToValidatePanel();
+
+ // Error - shift to offending input widget
+ SetTextboxFocus(inputWidget);
+ gValidationError = true;
+ } else {
+ if (isPercent) {
+ numString += "%";
+ }
+ if (element) {
+ GetCurrentEditor().setAttributeOrEquivalent(
+ element,
+ attName,
+ numString,
+ true
+ );
+ }
+ }
+ } else if (element) {
+ GetCurrentEditor().removeAttributeOrEquivalent(element, attName, true);
+ }
+ return numString;
+}
+
+/* Validate contents of an input field
+ *
+ * value number to validate
+ * minVal minimum allowed for input widget's value
+ * maxVal maximum allowed for input widget's value
+ * (when "listWidget" is used, maxVal is used for "pixel" maximum,
+ * 100% is assumed if "percent" is the user's choice)
+ * mustHaveValue If true, error dialog is displayed if "value" is empty string
+ *
+ * If inputWidget's value is outside of range, or is empty when "mustHaveValue" = true,
+ * an error dialog is popuped up to inform the user. The focus is shifted
+ * to the inputWidget.
+ *
+ * Returns the "value" as a string, or "" if error or input contents are empty
+ * The global "gValidationError" variable is set true if error was found
+ */
+function ValidateNumberRange(value, minValue, maxValue, mustHaveValue) {
+ // Initialize global error flag
+ gValidationError = false;
+ value = TrimString(String(value));
+
+ // We don't show error for empty string unless caller wants to
+ if (!value && !mustHaveValue) {
+ return "";
+ }
+
+ var numberStr = "";
+
+ if (value.length > 0) {
+ // Extract just numeric characters
+ var number = Number(value.replace(/\D+/g, ""));
+ if (number >= minValue && number <= maxValue) {
+ // Return string version of the number
+ return String(number);
+ }
+ numberStr = String(number);
+ }
+
+ var message = "";
+
+ if (numberStr.length > 0) {
+ // We have a number from user outside of allowed range
+ message = GetString("ValidateRangeMsg");
+ message = message.replace(/%n%/, numberStr);
+ message += "\n ";
+ }
+ message += GetString("ValidateNumberMsg");
+
+ // Replace variable placeholders in message with number values
+ message = message.replace(/%min%/, minValue).replace(/%max%/, maxValue);
+ ShowInputErrorMessage(message);
+
+ // Return an empty string to indicate error
+ gValidationError = true;
+ return "";
+}
+
+function SetTextboxFocusById(id) {
+ SetTextboxFocus(document.getElementById(id));
+}
+
+function SetTextboxFocus(input) {
+ if (input) {
+ input.focus();
+ }
+}
+
+function ShowInputErrorMessage(message) {
+ Services.prompt.alert(window, GetString("InputError"), message);
+ window.focus();
+}
+
+// Get the text appropriate to parent container
+// to determine what a "%" value is referring to.
+// elementForAtt is element we are actually setting attributes on
+// (a temporary copy of element in the doc to allow canceling),
+// but elementInDoc is needed to find parent context in document
+function GetAppropriatePercentString(elementForAtt, elementInDoc) {
+ var editor = GetCurrentEditor();
+ try {
+ var name = elementForAtt.nodeName.toLowerCase();
+ if (name == "td" || name == "th") {
+ return GetString("PercentOfTable");
+ }
+
+ // Check if element is within a table cell
+ if (editor.getElementOrParentByTagName("td", elementInDoc)) {
+ return GetString("PercentOfCell");
+ }
+ return GetString("PercentOfWindow");
+ } catch (e) {
+ return "";
+ }
+}
+
+function ClearListbox(listbox) {
+ if (listbox) {
+ listbox.clearSelection();
+ while (listbox.hasChildNodes()) {
+ listbox.lastChild.remove();
+ }
+ }
+}
+
+function forceInteger(elementID) {
+ var editField = document.getElementById(elementID);
+ if (!editField) {
+ return;
+ }
+
+ var stringIn = editField.value;
+ if (stringIn && stringIn.length > 0) {
+ // Strip out all nonnumeric characters
+ stringIn = stringIn.replace(/\D+/g, "");
+ if (!stringIn) {
+ stringIn = "";
+ }
+
+ // Write back only if changed
+ if (stringIn != editField.value) {
+ editField.value = stringIn;
+ }
+ }
+}
+
+function InitPixelOrPercentMenulist(
+ elementForAtt,
+ elementInDoc,
+ attribute,
+ menulistID,
+ defaultIndex
+) {
+ if (!defaultIndex) {
+ defaultIndex = gPixel;
+ }
+
+ // var size = elementForAtt.getAttribute(attribute);
+ var size = GetHTMLOrCSSStyleValue(elementForAtt, attribute, attribute);
+ var menulist = document.getElementById(menulistID);
+ var pixelItem;
+ var percentItem;
+
+ if (!menulist) {
+ dump("NO MENULIST found for ID=" + menulistID + "\n");
+ return size;
+ }
+
+ menulist.removeAllItems();
+ pixelItem = menulist.appendItem(GetString("Pixels"));
+
+ if (!pixelItem) {
+ return 0;
+ }
+
+ percentItem = menulist.appendItem(
+ GetAppropriatePercentString(elementForAtt, elementInDoc)
+ );
+ if (size && size.length > 0) {
+ // Search for a "%" or "px"
+ if (size.includes("%")) {
+ // Strip out the %
+ size = size.substr(0, size.indexOf("%"));
+ if (percentItem) {
+ menulist.selectedItem = percentItem;
+ }
+ } else {
+ if (size.includes("px")) {
+ // Strip out the px
+ size = size.substr(0, size.indexOf("px"));
+ }
+ menulist.selectedItem = pixelItem;
+ }
+ } else {
+ menulist.selectedIndex = defaultIndex;
+ }
+
+ return size;
+}
+
+function onAdvancedEdit() {
+ // First validate data from widgets in the "simpler" property dialog
+ if (ValidateData()) {
+ // Set true if OK is clicked in the Advanced Edit dialog
+ window.AdvancedEditOK = false;
+ // Open the AdvancedEdit dialog, passing in the element to be edited
+ // (the copy named "globalElement")
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable=yes",
+ "",
+ globalElement
+ );
+ window.focus();
+ if (window.AdvancedEditOK) {
+ // Copy edited attributes to the dialog widgets:
+ InitDialog();
+ }
+ }
+}
+
+function getColor(ColorPickerID) {
+ var colorPicker = document.getElementById(ColorPickerID);
+ var color;
+ if (colorPicker) {
+ // Extract color from colorPicker and assign to colorWell.
+ color = colorPicker.getAttribute("color");
+ if (color && color == "") {
+ return null;
+ }
+ // Clear color so next if it's called again before
+ // color picker is actually used, we dedect the "don't set color" state
+ colorPicker.setAttribute("color", "");
+ }
+
+ return color;
+}
+
+function setColorWell(ColorWellID, color) {
+ var colorWell = document.getElementById(ColorWellID);
+ if (colorWell) {
+ if (!color || color == "") {
+ // Don't set color (use default)
+ // Trigger change to not show color swatch
+ colorWell.setAttribute("default", "true");
+ // Style in CSS sets "background-color",
+ // but color won't clear unless we do this:
+ colorWell.removeAttribute("style");
+ } else {
+ colorWell.removeAttribute("default");
+ // Use setAttribute so colorwell can be a XUL element, such as button
+ colorWell.setAttribute("style", "background-color:" + color);
+ }
+ }
+}
+
+function SwitchToValidatePanel() {
+ // no default implementation
+ // Only EdTableProps.js currently implements this
+}
+
+/**
+ * @returns {Promise} URL spec of the file chosen, or null
+ */
+function GetLocalFileURL(filterType) {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ var fileType = "html";
+
+ if (filterType == "img") {
+ fp.init(window, GetString("SelectImageFile"), Ci.nsIFilePicker.modeOpen);
+ fp.appendFilters(Ci.nsIFilePicker.filterImages);
+ fileType = "image";
+ } else if (filterType.startsWith("html")) {
+ // Current usage of this is in Link dialog,
+ // where we always want HTML first
+ fp.init(window, GetString("OpenHTMLFile"), Ci.nsIFilePicker.modeOpen);
+
+ // When loading into Composer, direct user to prefer HTML files and text files,
+ // so we call separately to control the order of the filter list
+ fp.appendFilters(Ci.nsIFilePicker.filterHTML);
+ fp.appendFilters(Ci.nsIFilePicker.filterText);
+
+ // Link dialog also allows linking to images
+ if (filterType.includes("img", 1)) {
+ fp.appendFilters(Ci.nsIFilePicker.filterImages);
+ }
+ }
+ // Default or last filter is "All Files"
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ // set the file picker's current directory to last-opened location saved in prefs
+ SetFilePickerDirectory(fp, fileType);
+
+ return new Promise(resolve => {
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ resolve(null);
+ return;
+ }
+ SaveFilePickerDirectory(fp, fileType);
+ resolve(fp.fileURL.spec);
+ });
+ });
+}
+
+function SetWindowLocation() {
+ gLocation = document.getElementById("location");
+ if (gLocation) {
+ const screenX = Math.max(
+ 0,
+ Math.min(
+ window.opener.screenX + Number(gLocation.getAttribute("offsetX")),
+ screen.availWidth - window.outerWidth
+ )
+ );
+ const screenY = Math.max(
+ 0,
+ Math.min(
+ window.opener.screenY + Number(gLocation.getAttribute("offsetY")),
+ screen.availHeight - window.outerHeight
+ )
+ );
+ window.moveTo(screenX, screenY);
+ }
+}
+
+function SaveWindowLocation() {
+ if (gLocation) {
+ gLocation.setAttribute("offsetX", window.screenX - window.opener.screenX);
+ gLocation.setAttribute("offsetY", window.screenY - window.opener.screenY);
+ }
+}
+
+function onCancel() {
+ SaveWindowLocation();
+}
+
+function SetRelativeCheckbox(checkbox) {
+ if (!checkbox) {
+ checkbox = document.getElementById("MakeRelativeCheckbox");
+ if (!checkbox) {
+ return;
+ }
+ }
+
+ var editor = GetCurrentEditor();
+ // Mail never allows relative URLs, so hide the checkbox
+ if (editor && editor.flags & Ci.nsIEditor.eEditorMailMask) {
+ checkbox.collapsed = true;
+ return;
+ }
+
+ var input = document.getElementById(checkbox.getAttribute("for"));
+ if (!input) {
+ return;
+ }
+
+ var url = TrimString(input.value);
+ var urlScheme = GetScheme(url);
+
+ // Check it if url is relative (no scheme).
+ checkbox.checked = url.length > 0 && !urlScheme;
+
+ // Now do checkbox enabling:
+ var enable = false;
+
+ var docUrl = GetDocumentBaseUrl();
+ var docScheme = GetScheme(docUrl);
+
+ if (url && docUrl && docScheme) {
+ if (urlScheme) {
+ // Url is absolute
+ // If we can make a relative URL, then enable must be true!
+ // (this lets the smarts of MakeRelativeUrl do all the hard work)
+ enable = GetScheme(MakeRelativeUrl(url)).length == 0;
+ } else if (url[0] == "#") {
+ // Url is relative
+ // Check if url is a named anchor
+ // but document doesn't have a filename
+ // (it's probably "index.html" or "index.htm",
+ // but we don't want to allow a malformed URL)
+ var docFilename = GetFilename(docUrl);
+ enable = docFilename.length > 0;
+ } else {
+ // Any other url is assumed
+ // to be ok to try to make absolute
+ enable = true;
+ }
+ }
+
+ SetElementEnabled(checkbox, enable);
+}
+
+// oncommand handler for the Relativize checkbox in EditorOverlay.xhtml
+function MakeInputValueRelativeOrAbsolute(checkbox) {
+ var input = document.getElementById(checkbox.getAttribute("for"));
+ if (!input) {
+ return;
+ }
+
+ var docUrl = GetDocumentBaseUrl();
+ if (!docUrl) {
+ // Checkbox should be disabled if not saved,
+ // but keep this error message in case we change that
+ Services.prompt.alert(window, "", GetString("SaveToUseRelativeUrl"));
+ window.focus();
+ } else {
+ // Note that "checked" is opposite of its last state,
+ // which determines what we want to do here
+ if (checkbox.checked) {
+ input.value = MakeRelativeUrl(input.value);
+ } else {
+ input.value = MakeAbsoluteUrl(input.value);
+ }
+
+ // Reset checkbox to reflect url state
+ SetRelativeCheckbox(checkbox);
+ }
+}
+
+var IsBlockParent = [
+ "applet",
+ "blockquote",
+ "body",
+ "center",
+ "dd",
+ "div",
+ "form",
+ "li",
+ "noscript",
+ "object",
+ "td",
+ "th",
+];
+
+var NotAnInlineParent = [
+ "col",
+ "colgroup",
+ "dl",
+ "dir",
+ "menu",
+ "ol",
+ "table",
+ "tbody",
+ "tfoot",
+ "thead",
+ "tr",
+ "ul",
+];
+
+function FillLinkMenulist(linkMenulist, headingsArray) {
+ var editor = GetCurrentEditor();
+ try {
+ var treeWalker = editor.document.createTreeWalker(
+ editor.document,
+ 1,
+ null,
+ true
+ );
+ var headingList = [];
+ var anchorList = []; // for sorting
+ var anchorMap = {}; // for weeding out duplicates and making heading anchors unique
+ var anchor;
+ var i;
+ for (
+ var element = treeWalker.nextNode();
+ element;
+ element = treeWalker.nextNode()
+ ) {
+ // grab headings
+ // Skip headings that already have a named anchor as their first child
+ // (this may miss nearby anchors, but at least we don't insert another
+ // under the same heading)
+ if (
+ HTMLHeadingElement.isInstance(element) &&
+ element.textContent &&
+ !(
+ HTMLAnchorElement.isInstance(element.firstChild) &&
+ element.firstChild.name
+ )
+ ) {
+ headingList.push(element);
+ }
+
+ // grab named anchors
+ if (HTMLAnchorElement.isInstance(element) && element.name) {
+ anchor = "#" + element.name;
+ if (!(anchor in anchorMap)) {
+ anchorList.push({ anchor, sortkey: anchor.toLowerCase() });
+ anchorMap[anchor] = true;
+ }
+ }
+
+ // grab IDs
+ if (element.id) {
+ anchor = "#" + element.id;
+ if (!(anchor in anchorMap)) {
+ anchorList.push({ anchor, sortkey: anchor.toLowerCase() });
+ anchorMap[anchor] = true;
+ }
+ }
+ }
+ // add anchor for headings
+ for (i = 0; i < headingList.length; i++) {
+ var heading = headingList[i];
+
+ // Use just first 40 characters, don't add "...",
+ // and replace whitespace with "_" and strip non-word characters
+ anchor =
+ "#" +
+ ConvertToCDATAString(
+ TruncateStringAtWordEnd(heading.textContent, 40, false)
+ );
+
+ // Append "_" to any name already in the list
+ while (anchor in anchorMap) {
+ anchor += "_";
+ }
+ anchorList.push({ anchor, sortkey: anchor.toLowerCase() });
+ anchorMap[anchor] = true;
+
+ // Save nodes in an array so we can create anchor node under it later
+ headingsArray[anchor] = heading;
+ }
+ let menuItems = [];
+ if (anchorList.length) {
+ // case insensitive sort
+ anchorList.sort((a, b) => {
+ if (a.sortkey < b.sortkey) {
+ return -1;
+ }
+ if (a.sortkey > b.sortkey) {
+ return 1;
+ }
+ return 0;
+ });
+ for (i = 0; i < anchorList.length; i++) {
+ menuItems.push(createMenuItem(anchorList[i].anchor));
+ }
+ } else {
+ // Don't bother with named anchors in Mail.
+ if (editor && editor.flags & Ci.nsIEditor.eEditorMailMask) {
+ linkMenulist.removeAttribute("enablehistory");
+ return;
+ }
+ let item = createMenuItem(GetString("NoNamedAnchorsOrHeadings"));
+ item.setAttribute("disabled", "true");
+ menuItems.push(item);
+ }
+ window.addEventListener("contextmenu", event => {
+ if (document.getElementById("datalist-menuseparator")) {
+ return;
+ }
+ let menuseparator = document.createXULElement("menuseparator");
+ menuseparator.setAttribute("id", "datalist-menuseparator");
+ document.getElementById("textbox-contextmenu").appendChild(menuseparator);
+ for (let menuitem of menuItems) {
+ document.getElementById("textbox-contextmenu").appendChild(menuitem);
+ }
+ });
+ } catch (e) {}
+}
+
+function createMenuItem(label) {
+ var menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", label);
+ menuitem.addEventListener("click", event => {
+ gDialog.hrefInput.value = label;
+ ChangeLinkLocation();
+ });
+ return menuitem;
+}
+
+// Shared by Image and Link dialogs for the "Choose" button for links
+function chooseLinkFile() {
+ GetLocalFileURL("html, img").then(fileURL => {
+ // Always try to relativize local file URLs
+ if (gHaveDocumentUrl) {
+ fileURL = MakeRelativeUrl(fileURL);
+ }
+
+ gDialog.hrefInput.value = fileURL;
+
+ // Do stuff specific to a particular dialog
+ // (This is defined separately in Image and Link dialogs)
+ ChangeLinkLocation();
+ });
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdDictionary.js b/comm/mail/components/compose/content/dialogs/EdDictionary.js
new file mode 100644
index 0000000000..a79a01469c
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdDictionary.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gSpellChecker;
+var gWordToAdd;
+
+function Startup() {
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+ // Get the SpellChecker shell
+ if ("gSpellChecker" in window.opener && window.opener.gSpellChecker) {
+ gSpellChecker = window.opener.gSpellChecker;
+ }
+
+ if (!gSpellChecker) {
+ dump("SpellChecker not found!!!\n");
+ window.close();
+ return;
+ }
+ // The word to add word is passed as the 2nd extra parameter in window.openDialog()
+ gWordToAdd = window.arguments[1];
+
+ gDialog.WordInput = document.getElementById("WordInput");
+ gDialog.DictionaryList = document.getElementById("DictionaryList");
+
+ gDialog.WordInput.value = gWordToAdd;
+ FillDictionaryList();
+
+ // Select the supplied word if it is already in the list
+ SelectWordToAddInList();
+ SetTextboxFocus(gDialog.WordInput);
+}
+
+function ValidateWordToAdd() {
+ gWordToAdd = TrimString(gDialog.WordInput.value);
+ if (gWordToAdd.length > 0) {
+ return true;
+ }
+ return false;
+}
+
+function SelectWordToAddInList() {
+ for (var i = 0; i < gDialog.DictionaryList.getRowCount(); i++) {
+ var wordInList = gDialog.DictionaryList.getItemAtIndex(i);
+ if (wordInList && gWordToAdd == wordInList.label) {
+ gDialog.DictionaryList.selectedIndex = i;
+ break;
+ }
+ }
+}
+
+function AddWord() {
+ if (ValidateWordToAdd()) {
+ try {
+ gSpellChecker.AddWordToDictionary(gWordToAdd);
+ } catch (e) {
+ dump(
+ "Exception occurred in gSpellChecker.AddWordToDictionary\nWord to add probably already existed\n"
+ );
+ }
+
+ // Rebuild the dialog list
+ FillDictionaryList();
+
+ SelectWordToAddInList();
+ gDialog.WordInput.value = "";
+ }
+}
+
+function RemoveWord() {
+ var selIndex = gDialog.DictionaryList.selectedIndex;
+ if (selIndex >= 0) {
+ var word = gDialog.DictionaryList.selectedItem.label;
+
+ // Remove word from list
+ gDialog.DictionaryList.selectedItem.remove();
+
+ // Remove from dictionary
+ try {
+ // Not working: BUG 43348
+ gSpellChecker.RemoveWordFromDictionary(word);
+ } catch (e) {
+ dump("Failed to remove word from dictionary\n");
+ }
+
+ ResetSelectedItem(selIndex);
+ }
+}
+
+function FillDictionaryList() {
+ var selIndex = gDialog.DictionaryList.selectedIndex;
+
+ // Clear the current contents of the list
+ ClearListbox(gDialog.DictionaryList);
+
+ // Get the list from the spell checker
+ gSpellChecker.GetPersonalDictionary();
+
+ var haveList = false;
+
+ // Get words until an empty string is returned
+ do {
+ var word = gSpellChecker.GetPersonalDictionaryWord();
+ if (word != "") {
+ gDialog.DictionaryList.appendItem(word, "");
+ haveList = true;
+ }
+ } while (word != "");
+
+ // XXX: BUG 74467: If list is empty, it doesn't layout to full height correctly
+ // (ignores "rows" attribute) (bug is latered, so we are fixing here for now)
+ if (!haveList) {
+ gDialog.DictionaryList.appendItem("", "");
+ }
+
+ ResetSelectedItem(selIndex);
+}
+
+function ResetSelectedItem(index) {
+ var lastIndex = gDialog.DictionaryList.getRowCount() - 1;
+ if (index > lastIndex) {
+ index = lastIndex;
+ }
+
+ // If we didn't have a selected item,
+ // set it to the first item
+ if (index == -1 && lastIndex >= 0) {
+ index = 0;
+ }
+
+ gDialog.DictionaryList.selectedIndex = index;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml b/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml
new file mode 100644
index 0000000000..c5c33212a9
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml
@@ -0,0 +1,88 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorPersonalDictionary.dtd">
+<window
+ id="dictionaryDlg"
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog
+ buttonlabelaccept="&CloseButton.label;"
+ buttonaccesskeyaccept="&CloseButton.accessKey;"
+ buttons="accept"
+ >
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDictionary.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <hbox flex="1">
+ <div xmlns="http://www.w3.org/1999/xhtml" class="grid-two-column">
+ <div class="flex-items-center grid-item-span-row">
+ <xul:label
+ id="WordInputLabel"
+ value="&wordEditField.label;"
+ control="WordInput"
+ accesskey="&wordEditField.accessKey;"
+ />
+ </div>
+ <div>
+ <input
+ id="WordInput"
+ type="text"
+ style="width: 14.5em"
+ aria-labelledby="WordInputLabel"
+ />
+ </div>
+ <div>
+ <xul:button
+ id="AddWord"
+ oncommand="AddWord()"
+ label="&AddButton.label;"
+ accesskey="&AddButton.accessKey;"
+ />
+ </div>
+ <div class="flex-items-center grid-item-span-row">
+ <xul:label
+ value="&DictionaryList.label;"
+ control="DictionaryList"
+ accesskey="&DictionaryList.accessKey;"
+ />
+ </div>
+ <div>
+ <xul:richlistbox
+ id="DictionaryList"
+ style="width: 15em; height: 10em"
+ />
+ </div>
+ <div>
+ <xul:button
+ id="RemoveWord"
+ oncommand="RemoveWord()"
+ label="&RemoveButton.label;"
+ accesskey="&RemoveButton.accessKey;"
+ />
+ </div>
+ </div>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdHLineProps.js b/comm/mail/components/compose/content/dialogs/EdHLineProps.js
new file mode 100644
index 0000000000..4a5393d1dc
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdHLineProps.js
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var tagName = "hr";
+var gHLineElement;
+var width;
+var height;
+var align;
+var shading;
+const gMaxHRSize = 1000; // This is hard-coded in nsHTMLHRElement::StringToAttribute()
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+ try {
+ // Get the selected horizontal line
+ gHLineElement = editor.getSelectedElement(tagName);
+ } catch (e) {}
+
+ if (!gHLineElement) {
+ // We should never be here if not editing an existing HLine
+ window.close();
+ return;
+ }
+ gDialog.heightInput = document.getElementById("height");
+ gDialog.widthInput = document.getElementById("width");
+ gDialog.leftAlign = document.getElementById("leftAlign");
+ gDialog.centerAlign = document.getElementById("centerAlign");
+ gDialog.rightAlign = document.getElementById("rightAlign");
+ gDialog.alignGroup = gDialog.rightAlign.radioGroup;
+ gDialog.shading = document.getElementById("3dShading");
+ gDialog.pixelOrPercentMenulist = document.getElementById(
+ "pixelOrPercentMenulist"
+ );
+
+ // Make a copy to use for AdvancedEdit and onSaveDefault
+ globalElement = gHLineElement.cloneNode(false);
+
+ // Initialize control values based on existing attributes
+ InitDialog();
+
+ // SET FOCUS TO FIRST CONTROL
+ SetTextboxFocus(gDialog.widthInput);
+
+ // Resize window
+ window.sizeToContent();
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ // Just to be confusing, "size" is used instead of height because it does
+ // not accept % values, only pixels
+ var height = GetHTMLOrCSSStyleValue(globalElement, "size", "height");
+ if (height.includes("px")) {
+ height = height.substr(0, height.indexOf("px"));
+ }
+ if (!height) {
+ height = 2; // Default value
+ }
+
+ // We will use "height" here and in UI
+ gDialog.heightInput.value = height;
+
+ // Get the width attribute of the element, stripping out "%"
+ // This sets contents of menulist (adds pixel and percent menuitems elements)
+ gDialog.widthInput.value = InitPixelOrPercentMenulist(
+ globalElement,
+ gHLineElement,
+ "width",
+ "pixelOrPercentMenulist"
+ );
+
+ var marginLeft = GetHTMLOrCSSStyleValue(
+ globalElement,
+ "align",
+ "margin-left"
+ ).toLowerCase();
+ var marginRight = GetHTMLOrCSSStyleValue(
+ globalElement,
+ "align",
+ "margin-right"
+ ).toLowerCase();
+ align = marginLeft + " " + marginRight;
+ gDialog.leftAlign.checked = align == "left left" || align == "0px auto";
+ gDialog.centerAlign.checked =
+ align == "center center" || align == "auto auto" || align == " ";
+ gDialog.rightAlign.checked = align == "right right" || align == "auto 0px";
+
+ if (gDialog.centerAlign.checked) {
+ gDialog.alignGroup.selectedItem = gDialog.centerAlign;
+ } else if (gDialog.rightAlign.checked) {
+ gDialog.alignGroup.selectedItem = gDialog.rightAlign;
+ } else {
+ gDialog.alignGroup.selectedItem = gDialog.leftAlign;
+ }
+
+ gDialog.shading.checked = !globalElement.hasAttribute("noshade");
+}
+
+function onSaveDefault() {
+ // "false" means set attributes on the globalElement,
+ // not the real element being edited
+ if (ValidateData()) {
+ var alignInt;
+ if (align == "left") {
+ alignInt = 0;
+ } else if (align == "right") {
+ alignInt = 2;
+ } else {
+ alignInt = 1;
+ }
+ Services.prefs.setIntPref("editor.hrule.align", alignInt);
+
+ var percent;
+ var widthInt;
+ var heightInt;
+
+ if (width) {
+ if (width.includes("%")) {
+ percent = true;
+ widthInt = Number(width.substr(0, width.indexOf("%")));
+ } else {
+ percent = false;
+ widthInt = Number(width);
+ }
+ } else {
+ percent = true;
+ widthInt = Number(100);
+ }
+
+ heightInt = height ? Number(height) : 2;
+
+ Services.prefs.setIntPref("editor.hrule.width", widthInt);
+ Services.prefs.setBoolPref("editor.hrule.width_percent", percent);
+ Services.prefs.setIntPref("editor.hrule.height", heightInt);
+ Services.prefs.setBoolPref("editor.hrule.shading", shading);
+
+ // Write the prefs out NOW!
+ Services.prefs.savePrefFile(null);
+ }
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ // Height is always pixels
+ height = ValidateNumber(
+ gDialog.heightInput,
+ null,
+ 1,
+ gMaxHRSize,
+ globalElement,
+ "size",
+ false
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ width = ValidateNumber(
+ gDialog.widthInput,
+ gDialog.pixelOrPercentMenulist,
+ 1,
+ gMaxPixels,
+ globalElement,
+ "width",
+ false
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ align = "left";
+ if (gDialog.centerAlign.selected) {
+ // Don't write out default attribute
+ align = "";
+ } else if (gDialog.rightAlign.selected) {
+ align = "right";
+ }
+ if (align) {
+ globalElement.setAttribute("align", align);
+ } else {
+ try {
+ GetCurrentEditor().removeAttributeOrEquivalent(
+ globalElement,
+ "align",
+ true
+ );
+ } catch (e) {}
+ }
+
+ if (gDialog.shading.checked) {
+ shading = true;
+ globalElement.removeAttribute("noshade");
+ } else {
+ shading = false;
+ globalElement.setAttribute("noshade", "noshade");
+ }
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ // Copy attributes from the globalElement to the document element
+ try {
+ GetCurrentEditor().cloneAttributes(gHLineElement, globalElement);
+ } catch (e) {}
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml b/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml
new file mode 100644
index 0000000000..21fa52147c
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml
@@ -0,0 +1,131 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edHLineProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorHLineProperties.dtd">
+%edHLineProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <!--- Element-specific methods -->
+ <script src="chrome://messenger/content/messengercompose/EdHLineProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset>
+ <html:legend>&dimensionsBox.label;</html:legend>
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ id="widthLabel"
+ control="width"
+ value="&widthEditField.label;"
+ accesskey="&widthEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="width"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="widthLabel"
+ />
+ </html:td>
+ <html:td>
+ <menulist id="pixelOrPercentMenulist" />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="heightLabel"
+ control="height"
+ value="&heightEditField.label;"
+ accesskey="&heightEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="height"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="heightLabel"
+ />
+ </html:td>
+ <html:td>
+ <label value="&pixelsPopup.value;" />
+ </html:td>
+ </html:tr>
+ </html:table>
+ <checkbox
+ id="3dShading"
+ label="&threeDShading.label;"
+ accesskey="&threeDShading.accessKey;"
+ />
+ </html:fieldset>
+ <html:fieldset>
+ <html:legend>&alignmentBox.label;</html:legend>
+ <radiogroup id="alignmentGroup" orient="horizontal">
+ <spacer class="spacer" />
+ <radio
+ id="leftAlign"
+ label="&leftRadio.label;"
+ accesskey="&leftRadio.accessKey;"
+ />
+ <radio
+ id="centerAlign"
+ label="&centerRadio.label;"
+ accesskey="&centerRadio.accessKey;"
+ />
+ <radio
+ id="rightAlign"
+ label="&rightRadio.label;"
+ accesskey="&rightRadio.accessKey;"
+ />
+ </radiogroup>
+ </html:fieldset>
+ <spacer class="spacer" />
+ <hbox>
+ <button
+ id="SaveDefault"
+ label="&saveSettings.label;"
+ accesskey="&saveSettings.accessKey;"
+ oncommand="onSaveDefault()"
+ tooltiptext="&saveSettings.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton"
+ oncommand="onAdvancedEdit();"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdImageDialog.js b/comm/mail/components/compose/content/dialogs/EdImageDialog.js
new file mode 100644
index 0000000000..91e558cd50
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageDialog.js
@@ -0,0 +1,639 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ Note: We encourage non-empty alt text for images inserted into a page.
+ When there's no alt text, we always write 'alt=""' as the attribute, since "alt" is a required attribute.
+ We allow users to not have alt text by checking a "Don't use alterate text" radio button,
+ and we don't accept spaces as valid alt text. A space used to be required to avoid the error message
+ if user didn't enter alt text, but is unnecessary now that we no longer annoy the user
+ with the error dialog if alt="" is present on an img element.
+ We trim all spaces at the beginning and end of user's alt text
+*/
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gInsertNewImage = true;
+var gDoAltTextError = false;
+var gConstrainOn = false;
+// Note used in current version, but these are set correctly
+// and could be used to reset width and height used for constrain ratio
+var gConstrainWidth = 0;
+var gConstrainHeight = 0;
+var imageElement;
+var gImageMap = 0;
+var gCanRemoveImageMap = false;
+var gRemoveImageMap = false;
+var gImageMapDisabled = false;
+var gActualWidth = "";
+var gActualHeight = "";
+var gOriginalSrc = "";
+var gTimerID;
+var gValidateTab;
+var gInsertNewIMap;
+
+// These must correspond to values in EditorDialog.css for each theme
+// (unfortunately, setting "style" attribute here doesn't work!)
+var gPreviewImageWidth = 80;
+var gPreviewImageHeight = 50;
+
+// dialog initialization code
+
+function ImageStartup() {
+ gDialog.tabBox = document.getElementById("TabBox");
+ gDialog.tabLocation = document.getElementById("imageLocationTab");
+ gDialog.tabDimensions = document.getElementById("imageDimensionsTab");
+ gDialog.tabBorder = document.getElementById("imageBorderTab");
+ gDialog.srcInput = document.getElementById("srcInput");
+ gDialog.titleInput = document.getElementById("titleInput");
+ gDialog.altTextInput = document.getElementById("altTextInput");
+ gDialog.altTextRadioGroup = document.getElementById("altTextRadioGroup");
+ gDialog.altTextRadio = document.getElementById("altTextRadio");
+ gDialog.noAltTextRadio = document.getElementById("noAltTextRadio");
+ gDialog.actualSizeRadio = document.getElementById("actualSizeRadio");
+ gDialog.constrainCheckbox = document.getElementById("constrainCheckbox");
+ gDialog.widthInput = document.getElementById("widthInput");
+ gDialog.heightInput = document.getElementById("heightInput");
+ gDialog.widthUnitsMenulist = document.getElementById("widthUnitsMenulist");
+ gDialog.heightUnitsMenulist = document.getElementById("heightUnitsMenulist");
+ gDialog.imagelrInput = document.getElementById("imageleftrightInput");
+ gDialog.imagetbInput = document.getElementById("imagetopbottomInput");
+ gDialog.border = document.getElementById("border");
+ gDialog.alignTypeSelect = document.getElementById("alignTypeSelect");
+ gDialog.PreviewWidth = document.getElementById("PreviewWidth");
+ gDialog.PreviewHeight = document.getElementById("PreviewHeight");
+ gDialog.PreviewImage = document.getElementById("preview-image");
+ gDialog.PreviewImage.addEventListener("load", PreviewImageLoaded);
+ gDialog.OkButton = document.querySelector("dialog").getButton("accept");
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitImage() {
+ // Set the controls to the image's attributes
+ var src = globalElement.getAttribute("src");
+
+ // For image insertion the 'src' attribute is null.
+ if (src) {
+ // Shorten data URIs for display.
+ shortenImageData(src, gDialog.srcInput);
+ }
+
+ // Set "Relativize" checkbox according to current URL state
+ SetRelativeCheckbox();
+
+ // Force loading of image from its source and show preview image
+ LoadPreviewImage();
+
+ gDialog.titleInput.value = globalElement.getAttribute("title");
+
+ var hasAltText = globalElement.hasAttribute("alt");
+ var altText = globalElement.getAttribute("alt");
+ gDialog.altTextInput.value = altText;
+ if (altText || (!hasAltText && globalElement.hasAttribute("src"))) {
+ gDialog.altTextRadioGroup.selectedItem = gDialog.altTextRadio;
+ } else if (hasAltText) {
+ gDialog.altTextRadioGroup.selectedItem = gDialog.noAltTextRadio;
+ }
+ SetAltTextDisabled(
+ gDialog.altTextRadioGroup.selectedItem == gDialog.noAltTextRadio
+ );
+
+ // setup the height and width widgets
+ var width = InitPixelOrPercentMenulist(
+ globalElement,
+ gInsertNewImage ? null : imageElement,
+ "width",
+ "widthUnitsMenulist",
+ gPixel
+ );
+ var height = InitPixelOrPercentMenulist(
+ globalElement,
+ gInsertNewImage ? null : imageElement,
+ "height",
+ "heightUnitsMenulist",
+ gPixel
+ );
+
+ // Set actual radio button if both set values are the same as actual
+ SetSizeWidgets(width, height);
+
+ gDialog.widthInput.value = gConstrainWidth = width || gActualWidth || "";
+ gDialog.heightInput.value = gConstrainHeight = height || gActualHeight || "";
+
+ // set spacing editfields
+ gDialog.imagelrInput.value = globalElement.getAttribute("hspace");
+ gDialog.imagetbInput.value = globalElement.getAttribute("vspace");
+
+ // dialog.border.value = globalElement.getAttribute("border");
+ var bv = GetHTMLOrCSSStyleValue(globalElement, "border", "border-top-width");
+ if (bv.includes("px")) {
+ // Strip out the px
+ bv = bv.substr(0, bv.indexOf("px"));
+ } else if (bv == "thin") {
+ bv = "1";
+ } else if (bv == "medium") {
+ bv = "3";
+ } else if (bv == "thick") {
+ bv = "5";
+ }
+ gDialog.border.value = bv;
+
+ // Get alignment setting
+ var align = globalElement.getAttribute("align");
+ if (align) {
+ align = align.toLowerCase();
+ }
+
+ switch (align) {
+ case "top":
+ case "middle":
+ case "right":
+ case "left":
+ gDialog.alignTypeSelect.value = align;
+ break;
+ default:
+ // Default or "bottom"
+ gDialog.alignTypeSelect.value = "bottom";
+ }
+
+ // Get image map for image
+ gImageMap = GetImageMap();
+
+ doOverallEnabling();
+ doDimensionEnabling();
+}
+
+function SetSizeWidgets(width, height) {
+ if (
+ !(width || height) ||
+ (gActualWidth &&
+ gActualHeight &&
+ width == gActualWidth &&
+ height == gActualHeight)
+ ) {
+ gDialog.actualSizeRadio.radioGroup.selectedItem = gDialog.actualSizeRadio;
+ }
+
+ if (!gDialog.actualSizeRadio.selected) {
+ // Decide if user's sizes are in the same ratio as actual sizes
+ if (gActualWidth && gActualHeight) {
+ if (gActualWidth > gActualHeight) {
+ gDialog.constrainCheckbox.checked =
+ Math.round((gActualHeight * width) / gActualWidth) == height;
+ } else {
+ gDialog.constrainCheckbox.checked =
+ Math.round((gActualWidth * height) / gActualHeight) == width;
+ }
+ }
+ }
+}
+
+// Disable alt text input when "Don't use alt" radio is checked
+function SetAltTextDisabled(disable) {
+ gDialog.altTextInput.disabled = disable;
+}
+
+function GetImageMap() {
+ var usemap = globalElement.getAttribute("usemap");
+ if (usemap) {
+ gCanRemoveImageMap = true;
+ let mapname = usemap.substr(1);
+ try {
+ return GetCurrentEditor().document.querySelector(
+ '[name="' + mapname + '"]'
+ );
+ } catch (e) {}
+ } else {
+ gCanRemoveImageMap = false;
+ }
+
+ return null;
+}
+
+function chooseFile() {
+ if (gTimerID) {
+ clearTimeout(gTimerID);
+ }
+
+ // Put focus into the input field
+ SetTextboxFocus(gDialog.srcInput);
+
+ GetLocalFileURL("img").then(fileURL => {
+ // Always try to relativize local file URLs
+ if (gHaveDocumentUrl) {
+ fileURL = MakeRelativeUrl(fileURL);
+ }
+
+ gDialog.srcInput.value = fileURL;
+
+ SetRelativeCheckbox();
+ doOverallEnabling();
+ LoadPreviewImage();
+ });
+}
+
+function PreviewImageLoaded() {
+ if (gDialog.PreviewImage) {
+ // Image loading has completed -- we can get actual width
+ gActualWidth = gDialog.PreviewImage.naturalWidth;
+ gActualHeight = gDialog.PreviewImage.naturalHeight;
+
+ if (gActualWidth && gActualHeight) {
+ // Use actual size or scale to fit preview if either dimension is too large
+ var width = gActualWidth;
+ var height = gActualHeight;
+ if (gActualWidth > gPreviewImageWidth) {
+ width = gPreviewImageWidth;
+ height = gActualHeight * (gPreviewImageWidth / gActualWidth);
+ }
+ if (height > gPreviewImageHeight) {
+ height = gPreviewImageHeight;
+ width = gActualWidth * (gPreviewImageHeight / gActualHeight);
+ }
+ gDialog.PreviewImage.width = width;
+ gDialog.PreviewImage.height = height;
+
+ gDialog.PreviewWidth.setAttribute("value", gActualWidth);
+ gDialog.PreviewHeight.setAttribute("value", gActualHeight);
+
+ document.getElementById("imagePreview").hidden = false;
+
+ SetSizeWidgets(gDialog.widthInput.value, gDialog.heightInput.value);
+ }
+
+ if (gDialog.actualSizeRadio.selected) {
+ SetActualSize();
+ }
+
+ window.sizeToContent();
+ }
+}
+
+function LoadPreviewImage() {
+ var imageSrc = gDialog.srcInput.value.trim();
+ if (!imageSrc) {
+ return;
+ }
+ if (isImageDataShortened(imageSrc)) {
+ imageSrc = restoredImageData(gDialog.srcInput);
+ }
+
+ try {
+ // Remove the image URL from image cache so it loads fresh
+ // (if we don't do this, loads after the first will always use image cache
+ // and we won't see image edit changes or be able to get actual width and height)
+
+ // We must have an absolute URL to preview it or remove it from the cache
+ imageSrc = MakeAbsoluteUrl(imageSrc);
+
+ if (GetScheme(imageSrc)) {
+ let uri = Services.io.newURI(imageSrc);
+ if (uri) {
+ let imgCache = Cc["@mozilla.org/image/cache;1"].getService(
+ Ci.imgICache
+ );
+
+ // This returns error if image wasn't in the cache; ignore that
+ imgCache.removeEntry(uri);
+ }
+ }
+ } catch (e) {}
+
+ gDialog.PreviewImage.addEventListener("load", PreviewImageLoaded, true);
+ gDialog.PreviewImage.src = imageSrc;
+}
+
+function SetActualSize() {
+ gDialog.widthInput.value = gActualWidth ? gActualWidth : "";
+ gDialog.widthUnitsMenulist.selectedIndex = 0;
+ gDialog.heightInput.value = gActualHeight ? gActualHeight : "";
+ gDialog.heightUnitsMenulist.selectedIndex = 0;
+ doDimensionEnabling();
+}
+
+function ChangeImageSrc() {
+ if (gTimerID) {
+ clearTimeout(gTimerID);
+ }
+
+ gTimerID = setTimeout(LoadPreviewImage, 800);
+
+ SetRelativeCheckbox();
+ doOverallEnabling();
+}
+
+function doDimensionEnabling() {
+ // Enabled unless "Actual Size" is selected
+ var enable = !gDialog.actualSizeRadio.selected;
+
+ // BUG 74145: After input field is disabled,
+ // setting it enabled causes blinking caret to appear
+ // even though focus isn't set to it.
+ SetElementEnabledById("heightInput", enable);
+ SetElementEnabledById("heightLabel", enable);
+ SetElementEnabledById("heightUnitsMenulist", enable);
+
+ SetElementEnabledById("widthInput", enable);
+ SetElementEnabledById("widthLabel", enable);
+ SetElementEnabledById("widthUnitsMenulist", enable);
+
+ var constrainEnable =
+ enable &&
+ gDialog.widthUnitsMenulist.selectedIndex == 0 &&
+ gDialog.heightUnitsMenulist.selectedIndex == 0;
+
+ SetElementEnabledById("constrainCheckbox", constrainEnable);
+}
+
+function doOverallEnabling() {
+ var enabled = TrimString(gDialog.srcInput.value) != "";
+
+ SetElementEnabled(gDialog.OkButton, enabled);
+ SetElementEnabledById("AdvancedEditButton1", enabled);
+ SetElementEnabledById("imagemapLabel", enabled);
+ SetElementEnabledById("removeImageMap", gCanRemoveImageMap);
+}
+
+function ToggleConstrain() {
+ // If just turned on, save the current width and height as basis for constrain ratio
+ // Thus clicking on/off lets user say "Use these values as aspect ration"
+ if (
+ gDialog.constrainCheckbox.checked &&
+ !gDialog.constrainCheckbox.disabled &&
+ gDialog.widthUnitsMenulist.selectedIndex == 0 &&
+ gDialog.heightUnitsMenulist.selectedIndex == 0
+ ) {
+ gConstrainWidth = Number(TrimString(gDialog.widthInput.value));
+ gConstrainHeight = Number(TrimString(gDialog.heightInput.value));
+ }
+}
+
+function constrainProportions(srcID, destID) {
+ var srcElement = document.getElementById(srcID);
+ if (!srcElement) {
+ return;
+ }
+
+ var destElement = document.getElementById(destID);
+ if (!destElement) {
+ return;
+ }
+
+ // always force an integer (whether we are constraining or not)
+ forceInteger(srcID);
+
+ if (
+ !gActualWidth ||
+ !gActualHeight ||
+ !(gDialog.constrainCheckbox.checked && !gDialog.constrainCheckbox.disabled)
+ ) {
+ return;
+ }
+
+ // double-check that neither width nor height is in percent mode; bail if so!
+ if (
+ gDialog.widthUnitsMenulist.selectedIndex != 0 ||
+ gDialog.heightUnitsMenulist.selectedIndex != 0
+ ) {
+ return;
+ }
+
+ // This always uses the actual width and height ratios
+ // which is kind of funky if you change one number without the constrain
+ // and then turn constrain on and change a number
+ // I prefer the old strategy (below) but I can see some merit to this solution
+ if (srcID == "widthInput") {
+ destElement.value = Math.round(
+ (srcElement.value * gActualHeight) / gActualWidth
+ );
+ } else {
+ destElement.value = Math.round(
+ (srcElement.value * gActualWidth) / gActualHeight
+ );
+ }
+
+ /*
+ // With this strategy, the width and height ratio
+ // can be reset to whatever the user entered.
+ if (srcID == "widthInput") {
+ destElement.value = Math.round( srcElement.value * gConstrainHeight / gConstrainWidth );
+ } else {
+ destElement.value = Math.round( srcElement.value * gConstrainWidth / gConstrainHeight );
+ }
+ */
+}
+
+function removeImageMap() {
+ gRemoveImageMap = true;
+ gCanRemoveImageMap = false;
+ SetElementEnabledById("removeImageMap", false);
+}
+
+function SwitchToValidatePanel() {
+ if (
+ gDialog.tabBox &&
+ gValidateTab &&
+ gDialog.tabBox.selectedTab != gValidateTab
+ ) {
+ gDialog.tabBox.selectedTab = gValidateTab;
+ }
+}
+
+// Get data from widgets, validate, and set for the global element
+// accessible to AdvancedEdit() [in EdDialogCommon.js]
+function ValidateImage() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ return false;
+ }
+
+ gValidateTab = gDialog.tabLocation;
+ if (!gDialog.srcInput.value) {
+ Services.prompt.alert(
+ window,
+ GetString("Alert"),
+ GetString("MissingImageError")
+ );
+ SwitchToValidatePanel();
+ gDialog.srcInput.focus();
+ return false;
+ }
+
+ // We must convert to "file:///" or "http://" format else image doesn't load!
+ let src = gDialog.srcInput.value.trim();
+
+ if (isImageDataShortened(src)) {
+ src = restoredImageData(gDialog.srcInput);
+ } else {
+ var checkbox = document.getElementById("MakeRelativeCheckbox");
+ try {
+ if (checkbox && !checkbox.checked) {
+ src = Services.uriFixup.createFixupURI(
+ src,
+ Ci.nsIURIFixup.FIXUP_FLAG_NONE
+ ).spec;
+ }
+ } catch (e) {}
+
+ globalElement.setAttribute("src", src);
+ }
+
+ let title = gDialog.titleInput.value.trim();
+ if (title) {
+ globalElement.setAttribute("title", title);
+ } else {
+ globalElement.removeAttribute("title");
+ }
+
+ // Force user to enter Alt text only if "Alternate text" radio is checked
+ // Don't allow just spaces in alt text
+ var alt = "";
+ var useAlt = gDialog.altTextRadioGroup.selectedItem == gDialog.altTextRadio;
+ if (useAlt) {
+ alt = TrimString(gDialog.altTextInput.value);
+ }
+
+ if (alt || !useAlt) {
+ globalElement.setAttribute("alt", alt);
+ } else if (!gDoAltTextError) {
+ globalElement.removeAttribute("alt");
+ } else {
+ Services.prompt.alert(window, GetString("Alert"), GetString("NoAltText"));
+ SwitchToValidatePanel();
+ gDialog.altTextInput.focus();
+ return false;
+ }
+
+ var width = "";
+ var height = "";
+
+ gValidateTab = gDialog.tabDimensions;
+ if (!gDialog.actualSizeRadio.selected) {
+ // Get user values for width and height
+ width = ValidateNumber(
+ gDialog.widthInput,
+ gDialog.widthUnitsMenulist,
+ 1,
+ gMaxPixels,
+ globalElement,
+ "width",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ height = ValidateNumber(
+ gDialog.heightInput,
+ gDialog.heightUnitsMenulist,
+ 1,
+ gMaxPixels,
+ globalElement,
+ "height",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ // We always set the width and height attributes, even if same as actual.
+ // This speeds up layout of pages since sizes are known before image is loaded
+ if (!width) {
+ width = gActualWidth;
+ }
+ if (!height) {
+ height = gActualHeight;
+ }
+
+ // Remove existing width and height only if source changed
+ // and we couldn't obtain actual dimensions
+ var srcChanged = src != gOriginalSrc;
+ if (width) {
+ editor.setAttributeOrEquivalent(globalElement, "width", width, true);
+ } else if (srcChanged) {
+ editor.removeAttributeOrEquivalent(globalElement, "width", true);
+ }
+
+ if (height) {
+ editor.setAttributeOrEquivalent(globalElement, "height", height, true);
+ } else if (srcChanged) {
+ editor.removeAttributeOrEquivalent(globalElement, "height", true);
+ }
+
+ // spacing attributes
+ gValidateTab = gDialog.tabBorder;
+ ValidateNumber(
+ gDialog.imagelrInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "hspace",
+ false,
+ true,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.imagetbInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "vspace",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // note this is deprecated and should be converted to stylesheets
+ ValidateNumber(
+ gDialog.border,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "border",
+ false,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // Default or setting "bottom" means don't set the attribute
+ // Note that the attributes "left" and "right" are opposite
+ // of what we use in the UI, which describes where the TEXT wraps,
+ // not the image location (which is what the HTML describes)
+ switch (gDialog.alignTypeSelect.value) {
+ case "top":
+ case "middle":
+ case "right":
+ case "left":
+ editor.setAttributeOrEquivalent(
+ globalElement,
+ "align",
+ gDialog.alignTypeSelect.value,
+ true
+ );
+ break;
+ default:
+ try {
+ editor.removeAttributeOrEquivalent(globalElement, "align", true);
+ } catch (e) {}
+ }
+
+ return true;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js b/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js
new file mode 100644
index 0000000000..9c41679c15
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gMsgCompProcessLink = false;
+var gMsgCompInputElement = null;
+var gMsgCompPrevInputValue = null;
+var gMsgCompPrevMozDoNotSendAttribute;
+var gMsgCompAttachSourceElement = null;
+
+function OnLoadDialog() {
+ gMsgCompAttachSourceElement = document.getElementById("AttachSourceToMail");
+ var editor = GetCurrentEditor();
+ if (
+ gMsgCompAttachSourceElement &&
+ editor &&
+ editor.flags & Ci.nsIEditor.eEditorMailMask
+ ) {
+ SetRelativeCheckbox = function () {
+ SetAttachCheckbox();
+ };
+ // initialize the AttachSourceToMail checkbox
+ gMsgCompAttachSourceElement.hidden = false;
+
+ switch (document.querySelector("dialog").id) {
+ case "imageDlg":
+ gMsgCompInputElement = gDialog.srcInput;
+ gMsgCompProcessLink = false;
+ break;
+ case "linkDlg":
+ gMsgCompInputElement = gDialog.hrefInput;
+ gMsgCompProcessLink = true;
+ break;
+ }
+ if (gMsgCompInputElement) {
+ SetAttachCheckbox();
+ gMsgCompPrevMozDoNotSendAttribute =
+ globalElement.getAttribute("moz-do-not-send");
+ }
+ }
+}
+addEventListener("load", OnLoadDialog, false);
+
+function OnAcceptDialog() {
+ // Auto-convert file URLs to data URLs. If we're in the link properties
+ // dialog convert only when requested - for the image dialog do it always.
+ if (
+ /^file:/i.test(gMsgCompInputElement.value.trim()) &&
+ (gMsgCompAttachSourceElement.checked || !gMsgCompProcessLink)
+ ) {
+ var dataURI = GenerateDataURL(gMsgCompInputElement.value.trim());
+ gMsgCompInputElement.value = dataURI;
+ gMsgCompAttachSourceElement.checked = true;
+ }
+ DoAttachSourceCheckbox();
+}
+document.addEventListener("dialogaccept", OnAcceptDialog, true);
+
+function SetAttachCheckbox() {
+ var resetCheckbox = false;
+ var mozDoNotSend = globalElement.getAttribute("moz-do-not-send");
+
+ // In case somebody played with the advanced property and changed the moz-do-not-send attribute
+ if (mozDoNotSend != gMsgCompPrevMozDoNotSendAttribute) {
+ gMsgCompPrevMozDoNotSendAttribute = mozDoNotSend;
+ resetCheckbox = true;
+ }
+
+ // Has the URL changed
+ if (
+ gMsgCompInputElement &&
+ gMsgCompInputElement.value != gMsgCompPrevInputValue
+ ) {
+ gMsgCompPrevInputValue = gMsgCompInputElement.value;
+ resetCheckbox = true;
+ }
+
+ if (gMsgCompInputElement && resetCheckbox) {
+ // Here is the rule about how to set the checkbox Attach Source To Message:
+ // If the attribute "moz-do-not-send" has not been set, we look at the scheme of the URL
+ // and at some preference to decide what is the best for the user.
+ // If it is set to "false", the checkbox is checked, otherwise unchecked.
+ var attach = false;
+ if (mozDoNotSend == null) {
+ // We haven't yet set the "moz-do-not-send" attribute.
+ var inputValue = gMsgCompInputElement.value.trim();
+ if (/^(file|data):/i.test(inputValue)) {
+ // For files or data URLs, default to attach them.
+ attach = true;
+ } else if (
+ !gMsgCompProcessLink && // Implies image dialogue.
+ /^https?:/i.test(inputValue)
+ ) {
+ // For images loaded via http(s) we default to the preference value.
+ attach = Services.prefs.getBoolPref("mail.compose.attach_http_images");
+ }
+ } else {
+ attach = mozDoNotSend == "false";
+ }
+
+ gMsgCompAttachSourceElement.checked = attach;
+ }
+}
+
+function DoAttachSourceCheckbox() {
+ gMsgCompPrevMozDoNotSendAttribute =
+ (!gMsgCompAttachSourceElement.checked).toString();
+ globalElement.setAttribute(
+ "moz-do-not-send",
+ gMsgCompPrevMozDoNotSendAttribute
+ );
+}
+
+function GenerateDataURL(url) {
+ var file = Services.io.newURI(url).QueryInterface(Ci.nsIFileURL).file;
+ var contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromFile(file);
+ var inputStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ inputStream.init(file, 0x01, 0o600, 0);
+ var stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(inputStream);
+ let data = "";
+ while (stream.available() > 0) {
+ data += stream.readBytes(stream.available());
+ }
+ let encoded = btoa(data);
+ stream.close();
+ return (
+ "data:" +
+ contentType +
+ ";filename=" +
+ encodeURIComponent(file.leafName) +
+ ";base64," +
+ encoded
+ );
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdImageProps.js b/comm/mail/components/compose/content/dialogs/EdImageProps.js
new file mode 100644
index 0000000000..861d098edc
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageProps.js
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+/* import-globals-from EdImageDialog.js */
+
+var gAnchorElement = null;
+var gLinkElement = null;
+var gOriginalHref = "";
+var gHNodeArray = {};
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ ImageStartup();
+ gDialog.hrefInput = document.getElementById("hrefInput");
+ gDialog.makeRelativeLink = document.getElementById("MakeRelativeLink");
+ gDialog.showLinkBorder = document.getElementById("showLinkBorder");
+ gDialog.linkTab = document.getElementById("imageLinkTab");
+ gDialog.linkAdvanced = document.getElementById("LinkAdvancedEditButton");
+
+ // Get a single selected image element
+ var tagName = "img";
+ if ("arguments" in window && window.arguments[0]) {
+ imageElement = window.arguments[0];
+ // We've been called from form field properties, so we can't insert a link
+ gDialog.linkTab.remove();
+ gDialog.linkTab = null;
+ } else {
+ // First check for <input type="image">
+ try {
+ imageElement = editor.getSelectedElement("input");
+
+ if (!imageElement || imageElement.getAttribute("type") != "image") {
+ // Get a single selected image element
+ imageElement = editor.getSelectedElement(tagName);
+ if (imageElement) {
+ gAnchorElement = editor.getElementOrParentByTagName(
+ "href",
+ imageElement
+ );
+ }
+ }
+ } catch (e) {}
+ }
+
+ if (imageElement) {
+ // We found an element and don't need to insert one
+ if (imageElement.hasAttribute("src")) {
+ gInsertNewImage = false;
+ gActualWidth = imageElement.naturalWidth;
+ gActualHeight = imageElement.naturalHeight;
+ }
+ } else {
+ gInsertNewImage = true;
+
+ // We don't have an element selected,
+ // so create one with default attributes
+ try {
+ imageElement = editor.createElementWithDefaults(tagName);
+ } catch (e) {}
+
+ if (!imageElement) {
+ dump("Failed to get selected element or create a new one!\n");
+ window.close();
+ return;
+ }
+ try {
+ gAnchorElement = editor.getSelectedElement("href");
+ } catch (e) {}
+ }
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = imageElement.cloneNode(false);
+
+ // We only need to test for this once per dialog load
+ gHaveDocumentUrl = GetDocumentBaseUrl();
+
+ InitDialog();
+ if (gAnchorElement) {
+ gOriginalHref = gAnchorElement.getAttribute("href");
+ // Make a copy to use for AdvancedEdit
+ gLinkElement = gAnchorElement.cloneNode(false);
+ } else {
+ gLinkElement = editor.createElementWithDefaults("a");
+ }
+ gDialog.hrefInput.value = gOriginalHref;
+
+ FillLinkMenulist(gDialog.hrefInput, gHNodeArray);
+ ChangeLinkLocation();
+
+ // Save initial source URL
+ gOriginalSrc = gDialog.srcInput.value;
+
+ // By default turn constrain on, but both width and height must be in pixels
+ gDialog.constrainCheckbox.checked =
+ gDialog.widthUnitsMenulist.selectedIndex == 0 &&
+ gDialog.heightUnitsMenulist.selectedIndex == 0;
+
+ // Start in "Link" tab if 2nd argument is true
+ if (gDialog.linkTab && "arguments" in window && window.arguments[1]) {
+ document.getElementById("TabBox").selectedTab = gDialog.linkTab;
+ SetTextboxFocus(gDialog.hrefInput);
+ } else {
+ SetTextboxFocus(gDialog.srcInput);
+ }
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ InitImage();
+ var border = TrimString(gDialog.border.value);
+ gDialog.showLinkBorder.checked = border != "" && border > 0;
+}
+
+function ChangeLinkLocation() {
+ var href = TrimString(gDialog.hrefInput.value);
+ SetRelativeCheckbox(gDialog.makeRelativeLink);
+ gDialog.showLinkBorder.disabled = !href;
+ gDialog.linkAdvanced.disabled = !href;
+ gLinkElement.setAttribute("href", href);
+}
+
+function ToggleShowLinkBorder() {
+ if (gDialog.showLinkBorder.checked) {
+ var border = TrimString(gDialog.border.value);
+ if (!border || border == "0") {
+ gDialog.border.value = "2";
+ }
+ } else {
+ gDialog.border.value = "0";
+ }
+}
+
+// Get data from widgets, validate, and set for the global element
+// accessible to AdvancedEdit() [in EdDialogCommon.js]
+function ValidateData() {
+ return ValidateImage();
+}
+
+function onAccept(event) {
+ // Use this now (default = false) so Advanced Edit button dialog doesn't trigger error message
+ gDoAltTextError = true;
+ window.opener.gMsgCompose.allowRemoteContent = true;
+ if (ValidateData()) {
+ if ("arguments" in window && window.arguments[0]) {
+ SaveWindowLocation();
+ return;
+ }
+
+ var editor = GetCurrentEditor();
+
+ editor.beginTransaction();
+
+ try {
+ if (gRemoveImageMap) {
+ globalElement.removeAttribute("usemap");
+ if (gImageMap) {
+ editor.deleteNode(gImageMap);
+ gInsertNewIMap = true;
+ gImageMap = null;
+ }
+ } else if (gImageMap) {
+ // un-comment to see that inserting image maps does not work!
+ /*
+ gImageMap = editor.createElementWithDefaults("map");
+ gImageMap.setAttribute("name", "testing");
+ var testArea = editor.createElementWithDefaults("area");
+ testArea.setAttribute("shape", "circle");
+ testArea.setAttribute("coords", "86,102,52");
+ testArea.setAttribute("href", "test");
+ gImageMap.appendChild(testArea);
+ */
+
+ // Assign to map if there is one
+ var mapName = gImageMap.getAttribute("name");
+ if (mapName != "") {
+ globalElement.setAttribute("usemap", "#" + mapName);
+ if (globalElement.getAttribute("border") == "") {
+ globalElement.setAttribute("border", 0);
+ }
+ }
+ }
+
+ // Create or remove the link as appropriate
+ var href = gDialog.hrefInput.value;
+ if (href != gOriginalHref) {
+ if (href && !gInsertNewImage) {
+ EditorSetTextProperty("a", "href", href);
+ // gAnchorElement is needed for cloning attributes later.
+ if (!gAnchorElement) {
+ gAnchorElement = editor.getElementOrParentByTagName(
+ "href",
+ imageElement
+ );
+ }
+ } else {
+ EditorRemoveTextProperty("href", "");
+ }
+ }
+
+ // If inside a link, always write the 'border' attribute
+ if (href) {
+ if (gDialog.showLinkBorder.checked) {
+ // Use default = 2 if border attribute is empty
+ if (!globalElement.hasAttribute("border")) {
+ globalElement.setAttribute("border", "2");
+ }
+ } else {
+ globalElement.setAttribute("border", "0");
+ }
+ }
+
+ if (gInsertNewImage) {
+ if (href) {
+ gLinkElement.appendChild(imageElement);
+ editor.insertElementAtSelection(gLinkElement, true);
+ } else {
+ // 'true' means delete the selection before inserting
+ editor.insertElementAtSelection(imageElement, true);
+ }
+ }
+
+ // Check to see if the link was to a heading
+ // Do this last because it moves the caret (BAD!)
+ if (href in gHNodeArray) {
+ var anchorNode = editor.createElementWithDefaults("a");
+ if (anchorNode) {
+ anchorNode.name = href.substr(1);
+ // Remember to use editor method so it is undoable!
+ editor.insertNode(anchorNode, gHNodeArray[href], 0);
+ }
+ }
+ // All values are valid - copy to actual element in doc or
+ // element we just inserted
+ editor.cloneAttributes(imageElement, globalElement);
+ if (gAnchorElement) {
+ editor.cloneAttributes(gAnchorElement, gLinkElement);
+ }
+
+ // If document is empty, the map element won't insert,
+ // so always insert the image first
+ if (gImageMap && gInsertNewIMap) {
+ // Insert the ImageMap element at beginning of document
+ var body = editor.rootElement;
+ editor.setShouldTxnSetSelection(false);
+ editor.insertNode(gImageMap, body, 0);
+ editor.setShouldTxnSetSelection(true);
+ }
+ } catch (e) {
+ dump(e);
+ }
+
+ editor.endTransaction();
+
+ SaveWindowLocation();
+ return;
+ }
+
+ gDoAltTextError = false;
+
+ event.preventDefault();
+}
+
+function onLinkAdvancedEdit() {
+ window.AdvancedEditOK = false;
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal,resizable=yes",
+ "",
+ gLinkElement
+ );
+ window.focus();
+ if (window.AdvancedEditOK) {
+ gDialog.hrefInput.value = gLinkElement.getAttribute("href");
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml b/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml
new file mode 100644
index 0000000000..c894a30175
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml
@@ -0,0 +1,454 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edImageProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorImageProperties.dtd">
+%edImageProperties;
+<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd">
+%composeEditorOverlayDTD;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<!-- dialog containing a control requiring initial setup -->
+<window
+ windowtype="Mail:image"
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-height: 24em"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog id="imageDlg" buttons="accept,cancel" style="width: 68ch">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageProps.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageDialog.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageLinkLoader.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <tabbox id="TabBox">
+ <tabs flex="1">
+ <tab id="imageLocationTab" label="&imageLocationTab.label;" />
+ <tab id="imageDimensionsTab" label="&imageDimensionsTab.label;" />
+ <tab id="imageAppearanceTab" label="&imageAppearanceTab.label;" />
+ <tab id="imageLinkTab" label="&imageLinkTab.label;" />
+ </tabs>
+ <tabpanels>
+ <vbox id="imageLocation">
+ <spacer class="spacer" />
+ <label
+ id="srcLabel"
+ control="srcInput"
+ value="&locationEditField.label;"
+ accesskey="&locationEditField.accessKey;"
+ tooltiptext="&locationEditField.tooltip;"
+ />
+ <tooltip id="shortenedDataURI">
+ <label value="&locationEditField.shortenedDataURI;" />
+ </tooltip>
+ <html:input
+ id="srcInput"
+ type="text"
+ oninput="ChangeImageSrc();"
+ tabindex="1"
+ class="uri-element input-inline"
+ title="&locationEditField.tooltip;"
+ aria-labelledby="srcLabel"
+ />
+ <hbox id="MakeRelativeHbox">
+ <checkbox
+ id="MakeRelativeCheckbox"
+ tabindex="2"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <checkbox
+ id="AttachSourceToMail"
+ hidden="true"
+ label="&attachImageSource.label;"
+ accesskey="&attachImageSource.accesskey;"
+ oncommand="DoAttachSourceCheckbox()"
+ />
+ <spacer flex="1" />
+ <button
+ id="ChooseFile"
+ tabindex="3"
+ oncommand="chooseFile()"
+ label="&chooseFileButton.label;"
+ accesskey="&chooseFileButton.accessKey;"
+ />
+ </hbox>
+ <spacer class="spacer" />
+ <radiogroup id="altTextRadioGroup" flex="1">
+ <hbox>
+ <vbox>
+ <hbox align="center" flex="1">
+ <label
+ id="titleLabel"
+ style="margin-left: 26px"
+ control="titleInput"
+ accesskey="&title.accessKey;"
+ value="&title.label;"
+ tooltiptext="&title.tooltip;"
+ />
+ </hbox>
+ <hbox align="center" flex="1">
+ <radio
+ id="altTextRadio"
+ value="usealt-yes"
+ label="&altText.label;"
+ accesskey="&altText.accessKey;"
+ tooltiptext="&altTextEditField.tooltip;"
+ persist="selected"
+ oncommand="SetAltTextDisabled(false);"
+ tabindex="5"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <html:input
+ id="titleInput"
+ type="text"
+ class="MinWidth20em input-inline"
+ title="&title.tooltip;"
+ tabindex="4"
+ aria-labelledby="titleLabel"
+ />
+ <html:input
+ id="altTextInput"
+ type="text"
+ class="MinWidth20em input-inline"
+ title="&altTextEditField.tooltip;"
+ oninput="SetAltTextDisabled(false);"
+ tabindex="6"
+ aria-labelledby="altTextRadio"
+ />
+ </vbox>
+ </hbox>
+ <radio
+ id="noAltTextRadio"
+ value="usealt-no"
+ label="&noAltText.label;"
+ accesskey="&noAltText.accessKey;"
+ persist="selected"
+ oncommand="SetAltTextDisabled(true);"
+ />
+ </radiogroup>
+ </vbox>
+
+ <vbox id="imageDimensions" align="start">
+ <spacer class="spacer" />
+ <hbox>
+ <radiogroup id="imgSizeGroup">
+ <radio
+ id="actualSizeRadio"
+ label="&actualSizeRadio.label;"
+ accesskey="&actualSizeRadio.accessKey;"
+ tooltiptext="&actualSizeRadio.tooltip;"
+ oncommand="SetActualSize()"
+ value="actual"
+ />
+ <radio
+ id="customSizeRadio"
+ label="&customSizeRadio.label;"
+ selected="true"
+ accesskey="&customSizeRadio.accessKey;"
+ tooltiptext="&customSizeRadio.tooltip;"
+ oncommand="doDimensionEnabling();"
+ value="custom"
+ />
+ </radiogroup>
+ <spacer flex="1" />
+ <vbox>
+ <spacer flex="1" />
+ <checkbox
+ id="constrainCheckbox"
+ label="&constrainCheckbox.label;"
+ accesskey="&constrainCheckbox.accessKey;"
+ oncommand="ToggleConstrain()"
+ tooltiptext="&constrainCheckbox.tooltip;"
+ />
+ </vbox>
+ <spacer flex="1" />
+ </hbox>
+ <spacer class="spacer" />
+ <hbox class="indent">
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ id="widthLabel"
+ control="widthInput"
+ accesskey="&widthEditField.accessKey;"
+ value="&widthEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="widthInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ oninput="constrainProportions(this.id,'heightInput')"
+ aria-labelledby="widthLabel"
+ />
+ </html:td>
+ <html:td>
+ <menulist
+ id="widthUnitsMenulist"
+ oncommand="doDimensionEnabling();"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="heightLabel"
+ control="heightInput"
+ accesskey="&heightEditField.accessKey;"
+ value="&heightEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="heightInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ oninput="constrainProportions(this.id,'widthInput')"
+ aria-labelledby="heightLabel"
+ />
+ </html:td>
+ <html:td>
+ <menulist
+ id="heightUnitsMenulist"
+ oncommand="doDimensionEnabling();"
+ />
+ </html:td>
+ </html:tr>
+ </html:table>
+ </hbox>
+ <spacer flex="1" />
+ </vbox>
+
+ <vbox id="imageAppearance">
+ <html:legend id="spacingLabel">&spacingBox.label;</html:legend>
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ id="leftrightLabel"
+ class="align-right"
+ control="imageleftrightInput"
+ accesskey="&leftRightEditField.accessKey;"
+ value="&leftRightEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="imageleftrightInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ aria-labelledby="leftrightLabel"
+ />
+ </html:td>
+ <html:td id="leftrighttypeLabel"> &pixelsPopup.value; </html:td>
+ <html:td style="width: 80%">
+ <spacer />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="topbottomLabel"
+ class="align-right"
+ control="imagetopbottomInput"
+ accesskey="&topBottomEditField.accessKey;"
+ value="&topBottomEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="imagetopbottomInput"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ aria-labelledby="topbottomLabel"
+ />
+ </html:td>
+ <html:td id="topbottomtypeLabel"> &pixelsPopup.value; </html:td>
+ <html:td>
+ <spacer />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ id="borderLabel"
+ class="align-right"
+ control="border"
+ accesskey="&borderEditField.accessKey;"
+ value="&borderEditField.label;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="border"
+ type="number"
+ min="0"
+ class="narrow input-inline"
+ aria-labelledby="borderLabel"
+ />
+ </html:td>
+ <html:td id="bordertypeLabel"> &pixelsPopup.value; </html:td>
+ <html:td>
+ <spacer />
+ </html:td>
+ </html:tr>
+ </html:table>
+ <separator class="thin" />
+ <html:legend id="alignLabel">&alignment.label;</html:legend>
+ <menulist id="alignTypeSelect" class="align-menu">
+ <menupopup>
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="top"
+ label="&topPopup.value;"
+ />
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="middle"
+ label="&centerPopup.value;"
+ />
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="bottom"
+ label="&bottomPopup.value;"
+ />
+ <!-- HTML attribute value is opposite of the button label on purpose -->
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="right"
+ label="&wrapLeftPopup.value;"
+ />
+ <menuitem
+ class="align-menu menuitem-iconic"
+ value="left"
+ label="&wrapRightPopup.value;"
+ />
+ </menupopup>
+ </menulist>
+ <separator class="thin" />
+ <html:legend id="imagemapLabel">&imagemapBox.label;</html:legend>
+ <html:div class="grid-two-column-equalsize">
+ <button
+ id="removeImageMap"
+ oncommand="removeImageMap()"
+ accesskey="&removeImageMapButton.accessKey;"
+ label="&removeImageMapButton.label;"
+ />
+ <spacer /><!-- remove when we restore Image Map Editor -->
+ </html:div>
+ </vbox>
+ <vbox>
+ <spacer class="spacer" />
+ <vbox id="LinkLocationBox">
+ <label
+ id="hrefLabel"
+ control="hrefInput"
+ accesskey="&LinkURLEditField2.accessKey;"
+ width="1"
+ >&LinkURLEditField2.label;</label
+ >
+ <html:input
+ id="hrefInput"
+ type="text"
+ class="uri-element padded input-inline"
+ oninput="ChangeLinkLocation();"
+ aria-labelledby="hrefLabel"
+ />
+ <hbox align="center">
+ <checkbox
+ id="MakeRelativeLink"
+ for="hrefInput"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ label="&chooseFileLinkButton.label;"
+ accesskey="&chooseFileLinkButton.accessKey;"
+ oncommand="chooseLinkFile();"
+ />
+ </hbox>
+ </vbox>
+ <spacer class="spacer" />
+ <hbox>
+ <checkbox
+ id="showLinkBorder"
+ label="&showImageLinkBorder.label;"
+ accesskey="&showImageLinkBorder.accessKey;"
+ oncommand="ToggleShowLinkBorder();"
+ />
+ <spacer flex="1" />
+ </hbox>
+ <separator class="thin" />
+ <hbox pack="end">
+ <button
+ id="LinkAdvancedEditButton"
+ label="&LinkAdvancedEditButton.label;"
+ accesskey="&LinkAdvancedEditButton.accessKey;"
+ tooltiptext="&LinkAdvancedEditButton.tooltip;"
+ oncommand="onLinkAdvancedEdit();"
+ />
+ </hbox>
+ </vbox>
+ </tabpanels>
+ </tabbox>
+
+ <spacer flex="1" />
+
+ <html:fieldset id="imagePreview" hidden="hidden">
+ <html:legend>&previewBox.label;</html:legend>
+
+ <html:figure>
+ <html:img id="preview-image" style="display: inline-block" alt="" />
+ <html:figcaption style="float: right">
+ <label value="&actualSize.label;" />
+ <label id="PreviewWidth" />x<label id="PreviewHeight" />
+ </html:figcaption>
+ </html:figure>
+ </html:fieldset>
+
+ <hbox pack="end">
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsSrc.js b/comm/mail/components/compose/content/dialogs/EdInsSrc.js
new file mode 100644
index 0000000000..d00f119ed7
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsSrc.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Insert Source HTML dialog */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gFullDataStrings = new Map();
+var gShortDataStrings = new Map();
+var gListenerAttached = false;
+
+window.addEventListener("load", Startup);
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ let editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ document
+ .querySelector("dialog")
+ .getButton("accept")
+ .removeAttribute("default");
+
+ // Create dialog object to store controls for easy access
+ gDialog.srcInput = document.getElementById("srcInput");
+
+ // Attach a paste listener so we can detect pasted data URIs we need to shorten.
+ gDialog.srcInput.addEventListener("paste", onPaste);
+
+ let selection;
+ try {
+ selection = editor.outputToString(
+ "text/html",
+ kOutputFormatted | kOutputSelectionOnly | kOutputWrap
+ );
+ } catch (e) {}
+ if (selection) {
+ selection = selection.replace(/<body[^>]*>/, "").replace(/<\/body>/, "");
+
+ // Shorten data URIs for display.
+ selection = replaceDataURIs(selection);
+
+ if (selection) {
+ gDialog.srcInput.value = selection;
+ }
+ }
+ // Set initial focus
+ gDialog.srcInput.focus();
+ SetWindowLocation();
+}
+
+function replaceDataURIs(input) {
+ return input.replace(
+ /(data:.+;base64,)([^"' >]+)/gi,
+ function (match, nonDataPart, dataPart) {
+ if (gShortDataStrings.has(dataPart)) {
+ // We found the exact same data URI, just return the shortened URI.
+ return nonDataPart + gShortDataStrings.get(dataPart);
+ }
+
+ let l = 5;
+ let key;
+ // Normally we insert the ellipsis after five characters but if it's not unique
+ // we include more data.
+ do {
+ key =
+ dataPart.substr(0, l) + "…" + dataPart.substr(dataPart.length - 10);
+ l++;
+ } while (gFullDataStrings.has(key) && l < dataPart.length - 10);
+ gFullDataStrings.set(key, dataPart);
+ gShortDataStrings.set(dataPart, key);
+
+ // Attach listeners. In case anyone copies/cuts from the HTML window,
+ // we want to restore the data URI on the clipboard.
+ if (!gListenerAttached) {
+ gDialog.srcInput.addEventListener("copy", onCopyOrCut);
+ gDialog.srcInput.addEventListener("cut", onCopyOrCut);
+ gListenerAttached = true;
+ }
+
+ return nonDataPart + key;
+ }
+ );
+}
+
+function onCopyOrCut(event) {
+ let startPos = gDialog.srcInput.selectionStart;
+ if (startPos == undefined) {
+ return;
+ }
+ let endPos = gDialog.srcInput.selectionEnd;
+ let clipboard = gDialog.srcInput.value.substring(startPos, endPos);
+
+ // Add back the original data URIs we stashed away earlier.
+ clipboard = clipboard.replace(
+ /(data:.+;base64,)([^"' >]+)/gi,
+ function (match, nonDataPart, key) {
+ if (!gFullDataStrings.has(key)) {
+ // User changed data URI.
+ return match;
+ }
+ return nonDataPart + gFullDataStrings.get(key);
+ }
+ );
+ event.clipboardData.setData("text/plain", clipboard);
+ if (event.type == "cut") {
+ // We have to cut the selection manually.
+ gDialog.srcInput.value =
+ gDialog.srcInput.value.substr(0, startPos) +
+ gDialog.srcInput.value.substr(endPos);
+ }
+ event.preventDefault();
+}
+
+function onPaste(event) {
+ let startPos = gDialog.srcInput.selectionStart;
+ if (startPos == undefined) {
+ return;
+ }
+ let endPos = gDialog.srcInput.selectionEnd;
+ let clipboard = event.clipboardData.getData("text/plain");
+
+ // We do out own paste by replacing the selection with the pre-processed
+ // clipboard data.
+ gDialog.srcInput.value =
+ gDialog.srcInput.value.substr(0, startPos) +
+ replaceDataURIs(clipboard) +
+ gDialog.srcInput.value.substr(endPos);
+ event.preventDefault();
+}
+
+function onAccept(event) {
+ let html = gDialog.srcInput.value;
+ if (!html) {
+ event.preventDefault();
+ return;
+ }
+
+ // Add back the original data URIs we stashed away earlier.
+ html = html.replace(
+ /(data:.+;base64,)([^"' >]+)/gi,
+ function (match, nonDataPart, key) {
+ if (!gFullDataStrings.has(key)) {
+ // User changed data URI.
+ return match;
+ }
+ return nonDataPart + gFullDataStrings.get(key);
+ }
+ );
+
+ try {
+ GetCurrentEditor().insertHTML(html);
+ } catch (e) {}
+ SaveWindowLocation();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml b/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml
new file mode 100644
index 0000000000..1f35de996d
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml
@@ -0,0 +1,67 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertSource.dtd">
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ style="min-height: 430px; min-width: 600px"
+ scrolling="false"
+>
+ <head>
+ <title>&windowTitle.label;</title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/dialogShadowDom.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/editorUtilities.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/EdDialogCommon.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/EdInsSrc.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog
+ buttonlabelaccept="&insertButton.label;"
+ buttonaccesskeyaccept="&insertButton.accesskey;"
+ >
+ <p id="srcMessage">&sourceEditField.label;</p>
+ <textarea id="srcInput" style="flex: 1" rows="18" cols="70"></textarea>
+ <p>
+ &example.label;
+ <code class="bold">
+ &exampleOpenTag.label;
+ <i>&exampleText.label;</i> &exampleCloseTag.label;
+ </code>
+ </p>
+ <hr />
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertChars.js b/comm/mail/components/compose/content/dialogs/EdInsertChars.js
new file mode 100644
index 0000000000..b710fb91a0
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertChars.js
@@ -0,0 +1,412 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// ------------------------------------------------------------------
+// From Unicode 3.0 Page 54. 3.11 Conjoining Jamo Behavior
+var SBase = 0xac00;
+var LBase = 0x1100;
+var VBase = 0x1161;
+var TBase = 0x11a7;
+var LCount = 19;
+var VCount = 21;
+var TCount = 28;
+var NCount = VCount * TCount;
+// End of Unicode 3.0
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onClose);
+
+// dialog initialization code
+function Startup() {
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+
+ StartupLatin();
+
+ // Set a variable on the opener window so we
+ // can track ownership of close this window with it
+ window.opener.InsertCharWindow = window;
+ window.sizeToContent();
+
+ SetWindowLocation();
+}
+
+function onAccept(event) {
+ // Insert the character
+ try {
+ GetCurrentEditor().insertText(LatinM.label);
+ } catch (e) {}
+
+ // Set persistent attributes to save
+ // which category, letter, and character modifier was used
+ CategoryGroup.setAttribute("category", category);
+ CategoryGroup.setAttribute("letter_index", indexL);
+ CategoryGroup.setAttribute("char_index", indexM);
+
+ // Don't close the dialog
+ event.preventDefault();
+}
+
+// Don't allow inserting in HTML Source Mode
+function onFocus() {
+ var enable = true;
+ if ("gEditorDisplayMode" in window.opener) {
+ enable = !window.opener.IsInHTMLSourceMode();
+ }
+
+ SetElementEnabled(
+ document.querySelector("dialog").getButton("accept"),
+ enable
+ );
+}
+
+function onClose() {
+ window.opener.InsertCharWindow = null;
+ SaveWindowLocation();
+}
+
+// ------------------------------------------------------------------
+var LatinL;
+var LatinM;
+var LatinL_Label;
+var LatinM_Label;
+var indexL = 0;
+var indexM = 0;
+var indexM_AU = 0;
+var indexM_AL = 0;
+var indexM_U = 0;
+var indexM_L = 0;
+var indexM_S = 0;
+var LItems = 0;
+var category;
+var CategoryGroup;
+var initialize = true;
+
+function StartupLatin() {
+ LatinL = document.getElementById("LatinL");
+ LatinM = document.getElementById("LatinM");
+ LatinL_Label = document.getElementById("LatinL_Label");
+ LatinM_Label = document.getElementById("LatinM_Label");
+
+ var Symbol = document.getElementById("Symbol");
+ var AccentUpper = document.getElementById("AccentUpper");
+ var AccentLower = document.getElementById("AccentLower");
+ var Upper = document.getElementById("Upper");
+ var Lower = document.getElementById("Lower");
+ CategoryGroup = document.getElementById("CatGrp");
+
+ // Initialize which radio button is set from persistent attribute...
+ var category = CategoryGroup.getAttribute("category");
+
+ // ...as well as indexes into the letter and character lists
+ var index = Number(CategoryGroup.getAttribute("letter_index"));
+ if (index && index >= 0) {
+ indexL = index;
+ }
+ index = Number(CategoryGroup.getAttribute("char_index"));
+ if (index && index >= 0) {
+ indexM = index;
+ }
+
+ switch (category) {
+ case "AccentUpper": // Uppercase Diacritical
+ CategoryGroup.selectedItem = AccentUpper;
+ indexM_AU = indexM;
+ break;
+ case "AccentLower": // Lowercase Diacritical
+ CategoryGroup.selectedItem = AccentLower;
+ indexM_AL = indexM;
+ break;
+ case "Upper": // Uppercase w/o Diacritical
+ CategoryGroup.selectedItem = Upper;
+ indexM_U = indexM;
+ break;
+ case "Lower": // Lowercase w/o Diacritical
+ CategoryGroup.selectedItem = Lower;
+ indexM_L = indexM;
+ break;
+ default:
+ category = "Symbol";
+ CategoryGroup.selectedItem = Symbol;
+ indexM_S = indexM;
+ break;
+ }
+
+ ChangeCategory(category);
+ initialize = false;
+}
+
+function ChangeCategory(newCategory) {
+ if (category != newCategory || initialize) {
+ category = newCategory;
+ // Note: Must do L before M to set LatinL.selectedIndex
+ UpdateLatinL();
+ UpdateLatinM();
+ UpdateCharacter();
+ }
+}
+
+function SelectLatinLetter() {
+ if (LatinL.selectedIndex != indexL) {
+ indexL = LatinL.selectedIndex;
+ UpdateLatinM();
+ UpdateCharacter();
+ }
+}
+
+function SelectLatinModifier() {
+ if (LatinM.selectedIndex != indexM) {
+ indexM = LatinM.selectedIndex;
+ UpdateCharacter();
+ }
+}
+function DisableLatinL(disable) {
+ if (disable) {
+ LatinL_Label.setAttribute("disabled", "true");
+ LatinL.setAttribute("disabled", "true");
+ } else {
+ LatinL_Label.removeAttribute("disabled");
+ LatinL.removeAttribute("disabled");
+ }
+}
+
+function UpdateLatinL() {
+ LatinL.removeAllItems();
+ if (category == "AccentUpper" || category == "AccentLower") {
+ DisableLatinL(false);
+ // No Q or q
+ var alphabet =
+ category == "AccentUpper"
+ ? "ABCDEFGHIJKLMNOPRSTUVWXYZ"
+ : "abcdefghijklmnoprstuvwxyz";
+ for (var letter = 0; letter < alphabet.length; letter++) {
+ LatinL.appendItem(alphabet.charAt(letter));
+ }
+
+ LatinL.selectedIndex = indexL;
+ } else {
+ // Other categories don't hinge on a "letter"
+ DisableLatinL(true);
+ // Note: don't change the indexL so it can be used next time
+ }
+}
+
+function UpdateLatinM() {
+ LatinM.removeAllItems();
+ var i, accent;
+ switch (category) {
+ case "AccentUpper": // Uppercase Diacritical
+ accent = upper[indexL];
+ for (i = 0; i < accent.length; i++) {
+ LatinM.appendItem(accent.charAt(i));
+ }
+
+ if (indexM_AU < accent.length) {
+ indexM = indexM_AU;
+ } else {
+ indexM = accent.length - 1;
+ }
+ indexM_AU = indexM;
+ break;
+
+ case "AccentLower": // Lowercase Diacritical
+ accent = lower[indexL];
+ for (i = 0; i < accent.length; i++) {
+ LatinM.appendItem(accent.charAt(i));
+ }
+
+ if (indexM_AL < accent.length) {
+ indexM = indexM_AL;
+ } else {
+ indexM = lower[indexL].length - 1;
+ }
+ indexM_AL = indexM;
+ break;
+
+ case "Upper": // Uppercase w/o Diacritical
+ for (i = 0; i < otherupper.length; i++) {
+ LatinM.appendItem(otherupper.charAt(i));
+ }
+
+ if (indexM_U < otherupper.length) {
+ indexM = indexM_U;
+ } else {
+ indexM = otherupper.length - 1;
+ }
+ indexM_U = indexM;
+ break;
+
+ case "Lower": // Lowercase w/o Diacritical
+ for (i = 0; i < otherlower.length; i++) {
+ LatinM.appendItem(otherlower.charAt(i));
+ }
+
+ if (indexM_L < otherlower.length) {
+ indexM = indexM_L;
+ } else {
+ indexM = otherlower.length - 1;
+ }
+ indexM_L = indexM;
+ break;
+
+ case "Symbol": // Symbol
+ for (i = 0; i < symbol.length; i++) {
+ LatinM.appendItem(symbol.charAt(i));
+ }
+
+ if (indexM_S < symbol.length) {
+ indexM = indexM_S;
+ } else {
+ indexM = symbol.length - 1;
+ }
+ indexM_S = indexM;
+ break;
+ }
+ LatinM.selectedIndex = indexM;
+}
+
+function UpdateCharacter() {
+ indexM = LatinM.selectedIndex;
+
+ switch (category) {
+ case "AccentUpper": // Uppercase Diacritical
+ indexM_AU = indexM;
+ break;
+ case "AccentLower": // Lowercase Diacritical
+ indexM_AL = indexM;
+ break;
+ case "Upper": // Uppercase w/o Diacritical
+ indexM_U = indexM;
+ break;
+ case "Lower": // Lowercase w/o Diacritical
+ indexM_L = indexM;
+ break;
+ case "Symbol":
+ indexM_S = indexM;
+ break;
+ }
+ // dump("Letter Index="+indexL+", Character Index="+indexM+", Character = "+LatinM.label+"\n");
+}
+
+const upper = [
+ // A
+ "\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u0100\u0102\u0104\u01cd\u01de\u01de\u01e0\u01fa\u0200\u0202\u0226\u1e00\u1ea0\u1ea2\u1ea4\u1ea6\u1ea8\u1eaa\u1eac\u1eae\u1eb0\u1eb2\u1eb4\u1eb6",
+ // B
+ "\u0181\u0182\u0184\u1e02\u1e04\u1e06",
+ // C
+ "\u00c7\u0106\u0108\u010a\u010c\u0187\u1e08",
+ // D
+ "\u010e\u0110\u0189\u018a\u1e0a\u1e0c\u1e0e\u1e10\u1e12",
+ // E
+ "\u00C8\u00C9\u00CA\u00CB\u0112\u0114\u0116\u0118\u011A\u0204\u0206\u0228\u1e14\u1e16\u1e18\u1e1a\u1e1c\u1eb8\u1eba\u1ebc\u1ebe\u1ec0\u1ec2\u1ec4\u1ec6",
+ // F
+ "\u1e1e",
+ // G
+ "\u011c\u011E\u0120\u0122\u01e4\u01e6\u01f4\u1e20",
+ // H
+ "\u0124\u0126\u021e\u1e22\u1e24\u1e26\u1e28\u1e2a",
+ // I
+ "\u00CC\u00CD\u00CE\u00CF\u0128\u012a\u012C\u012e\u0130\u0208\u020a\u1e2c\u1e2e\u1ec8\u1eca",
+ // J
+ "\u0134\u01f0",
+ // K
+ "\u0136\u0198\u01e8\u1e30\u1e32\u1e34",
+ // L
+ "\u0139\u013B\u013D\u013F\u0141\u1e36\u1e38\u1e3a\u1e3c",
+ // M
+ "\u1e3e\u1e40\u1e42",
+ // N
+ "\u00D1\u0143\u0145\u0147\u014A\u01F8\u1e44\u1e46\u1e48\u1e4a",
+ // O
+ "\u00D2\u00D3\u00D4\u00D5\u00D6\u014C\u014E\u0150\u01ea\u01ec\u020c\u020e\u022A\u022C\u022E\u0230\u1e4c\u1e4e\u1e50\u1e52\u1ecc\u1ece\u1ed0\u1ed2\u1ed4\u1ed6\u1ed8\u1eda\u1edc\u1ede\u1ee0\u1ee2",
+ // P
+ "\u1e54\u1e56",
+ // No Q
+ // R
+ "\u0154\u0156\u0158\u0210\u0212\u1e58\u1e5a\u1e5c\u1e5e",
+ // S
+ "\u015A\u015C\u015E\u0160\u0218\u1e60\u1e62\u1e64\u1e66\u1e68",
+ // T
+ "\u0162\u0164\u0166\u021A\u1e6a\u1e6c\u1e6e\u1e70",
+ // U
+ "\u00D9\u00DA\u00DB\u00DC\u0168\u016A\u016C\u016E\u0170\u0172\u0214\u0216\u1e72\u1e74\u1e76\u1e78\u1e7a\u1ee4\u1ee6\u1ee8\u1eea\u1eec\u1eee\u1ef0",
+ // V
+ "\u1e7c\u1e7e",
+ // W
+ "\u0174\u1e80\u1e82\u1e84\u1e86\u1e88",
+ // X
+ "\u1e8a\u1e8c",
+ // Y
+ "\u00DD\u0176\u0178\u0232\u1e8e\u1ef2\u1ef4\u1ef6\u1ef8",
+ // Z
+ "\u0179\u017B\u017D\u0224\u1e90\u1e92\u1e94",
+];
+
+const lower = [
+ // a
+ "\u00e0\u00e1\u00e2\u00e3\u00e4\u00e5\u0101\u0103\u0105\u01ce\u01df\u01e1\u01fb\u0201\u0203\u0227\u1e01\u1e9a\u1ea1\u1ea3\u1ea5\u1ea7\u1ea9\u1eab\u1ead\u1eaf\u1eb1\u1eb3\u1eb5\u1eb7",
+ // b
+ "\u0180\u0183\u0185\u1e03\u1e05\u1e07",
+ // c
+ "\u00e7\u0107\u0109\u010b\u010d\u0188\u1e09",
+ // d
+ "\u010f\u0111\u1e0b\u1e0d\u1e0f\u1e11\u1e13",
+ // e
+ "\u00e8\u00e9\u00ea\u00eb\u0113\u0115\u0117\u0119\u011b\u0205\u0207\u0229\u1e15\u1e17\u1e19\u1e1b\u1e1d\u1eb9\u1ebb\u1ebd\u1ebf\u1ec1\u1ec3\u1ec5\u1ec7",
+ // f
+ "\u1e1f",
+ // g
+ "\u011d\u011f\u0121\u0123\u01e5\u01e7\u01f5\u1e21",
+ // h
+ "\u0125\u0127\u021f\u1e23\u1e25\u1e27\u1e29\u1e2b\u1e96",
+ // i
+ "\u00ec\u00ed\u00ee\u00ef\u0129\u012b\u012d\u012f\u0131\u01d0\u0209\u020b\u1e2d\u1e2f\u1ec9\u1ecb",
+ // j
+ "\u0135",
+ // k
+ "\u0137\u0138\u01e9\u1e31\u1e33\u1e35",
+ // l
+ "\u013a\u013c\u013e\u0140\u0142\u1e37\u1e39\u1e3b\u1e3d",
+ // m
+ "\u1e3f\u1e41\u1e43",
+ // n
+ "\u00f1\u0144\u0146\u0148\u0149\u014b\u01f9\u1e45\u1e47\u1e49\u1e4b",
+ // o
+ "\u00f2\u00f3\u00f4\u00f5\u00f6\u014d\u014f\u0151\u01d2\u01eb\u01ed\u020d\u020e\u022b\u022d\u022f\u0231\u1e4d\u1e4f\u1e51\u1e53\u1ecd\u1ecf\u1ed1\u1ed3\u1ed5\u1ed7\u1ed9\u1edb\u1edd\u1edf\u1ee1\u1ee3",
+ // p
+ "\u1e55\u1e57",
+ // No q
+ // r
+ "\u0155\u0157\u0159\u0211\u0213\u1e59\u1e5b\u1e5d\u1e5f",
+ // s
+ "\u015b\u015d\u015f\u0161\u0219\u1e61\u1e63\u1e65\u1e67\u1e69",
+ // t
+ "\u0162\u0163\u0165\u0167\u021b\u1e6b\u1e6d\u1e6f\u1e71\u1e97",
+ // u
+ "\u00f9\u00fa\u00fb\u00fc\u0169\u016b\u016d\u016f\u0171\u0173\u01d4\u01d6\u01d8\u01da\u01dc\u0215\u0217\u1e73\u1e75\u1e77\u1e79\u1e7b\u1ee5\u1ee7\u1ee9\u1eeb\u1eed\u1eef\u1ef1",
+ // v
+ "\u1e7d\u1e7f",
+ // w
+ "\u0175\u1e81\u1e83\u1e85\u1e87\u1e89\u1e98",
+ // x
+ "\u1e8b\u1e8d",
+ // y
+ "\u00fd\u00ff\u0177\u0233\u1e8f\u1e99\u1ef3\u1ef5\u1ef7\u1ef9",
+ // z
+ "\u017a\u017c\u017e\u0225\u1e91\u1e93\u1e95",
+];
+
+const symbol =
+ "\u00a1\u00a2\u00a3\u00a4\u00a5\u20ac\u00a6\u00a7\u00a8\u00a9\u00aa\u00ab\u00ac\u00ae\u00af\u00b0\u00b1\u00b2\u00b3\u00b4\u00b5\u00b6\u00b7\u00b8\u00b9\u00ba\u00bb\u00bc\u00bd\u00be\u00bf\u00d7\u00f7";
+
+const otherupper =
+ "\u00c6\u00d0\u00d8\u00de\u0132\u0152\u0186\u01c4\u01c5\u01c7\u01c8\u01ca\u01cb\u01F1\u01f2";
+
+const otherlower =
+ "\u00e6\u00f0\u00f8\u00fe\u00df\u0133\u0153\u01c6\u01c9\u01cc\u01f3";
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml
new file mode 100644
index 0000000000..c610abdd88
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml
@@ -0,0 +1,92 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/EdInsertChars.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertChars.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup()"
+ onfocus="onFocus()"
+ lightweightthemes="true"
+ style="min-width: 20em"
+>
+ <dialog
+ id="insertCharsDlg"
+ buttonlabelaccept="&insertButton.label;"
+ buttonlabelcancel="&closeButton.label;"
+ >
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertChars.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset>
+ <html:legend>&category.label;</html:legend>
+ <radiogroup id="CatGrp" persist="category letter_index char_index">
+ <radio
+ id="AccentUpper"
+ label="&accentUpper.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="AccentLower"
+ label="&accentLower.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="Upper"
+ label="&otherUpper.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="Lower"
+ label="&otherLower.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ <radio
+ id="Symbol"
+ label="&commonSymbols.label;"
+ oncommand="ChangeCategory(this.id)"
+ />
+ </radiogroup>
+ <spacer class="spacer" />
+ </html:fieldset>
+ <html:div class="grid-two-column-equalsize">
+ <!-- value is set in JS from editor.properties strings -->
+ <label
+ id="LatinL_Label"
+ control="LatinL"
+ value="&letter.label;"
+ accesskey="&letter.accessKey;"
+ />
+ <menulist id="LatinL" oncommand="SelectLatinLetter()">
+ <menupopup />
+ </menulist>
+ <label
+ id="LatinM_Label"
+ control="LatinM"
+ value="&character.label;"
+ accesskey="&character.accessKey;"
+ />
+ <menulist id="LatinM" oncommand="SelectLatinModifier()">
+ <menupopup />
+ </menulist>
+ </html:div>
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertMath.js b/comm/mail/components/compose/content/dialogs/EdInsertMath.js
new file mode 100644
index 0000000000..a60a3affcc
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertMath.js
@@ -0,0 +1,317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Insert MathML dialog */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ // Create dialog object for easy access
+ gDialog.accept = document.querySelector("dialog").getButton("accept");
+ gDialog.mode = document.getElementById("optionMode");
+ gDialog.direction = document.getElementById("optionDirection");
+ gDialog.input = document.getElementById("input");
+ gDialog.output = document.getElementById("output");
+ gDialog.tabbox = document.getElementById("tabboxInsertLaTeXCommand");
+
+ // Set initial focus
+ gDialog.input.focus();
+
+ // Load TeXZilla
+ // TeXZilla.js contains non-ASCII characters and explicitly sets
+ // window.TeXZilla, so we have to specify the charset parameter but don't
+ // need to worry about the targetObj parameter.
+ /* globals TeXZilla */
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/messengercompose/TeXZilla.js",
+ {},
+ "UTF-8"
+ );
+
+ // Verify if the selection is on a <math> and initialize the dialog.
+ gDialog.oldMath = editor.getElementOrParentByTagName("math", null);
+ if (gDialog.oldMath) {
+ // When these attributes are absent or invalid, they default to "inline" and "ltr" respectively.
+ gDialog.mode.selectedIndex =
+ gDialog.oldMath.getAttribute("display") == "block" ? 1 : 0;
+ gDialog.direction.selectedIndex =
+ gDialog.oldMath.getAttribute("dir") == "rtl" ? 1 : 0;
+ gDialog.input.value = TeXZilla.getTeXSource(gDialog.oldMath);
+ }
+
+ // Create the tabbox with LaTeX commands.
+ createCommandPanel({
+ "√⅗²": [
+ "{⋯}^{⋯}",
+ "{⋯}_{⋯}",
+ "{⋯}_{⋯}^{⋯}",
+ "\\underset{⋯}{⋯}",
+ "\\overset{⋯}{⋯}",
+ "\\underoverset{⋯}{⋯}{⋯}",
+ "\\left(⋯\\right)",
+ "\\left[⋯\\right]",
+ "\\frac{⋯}{⋯}",
+ "\\binom{⋯}{⋯}",
+ "\\sqrt{⋯}",
+ "\\sqrt[⋯]{⋯}",
+ "\\cos\\left({⋯}\\right)",
+ "\\sin\\left({⋯}\\right)",
+ "\\tan\\left({⋯}\\right)",
+ "\\exp\\left({⋯}\\right)",
+ "\\ln\\left({⋯}\\right)",
+ "\\underbrace{⋯}",
+ "\\underline{⋯}",
+ "\\overbrace{⋯}",
+ "\\widevec{⋯}",
+ "\\widetilde{⋯}",
+ "\\widehat{⋯}",
+ "\\widecheck{⋯}",
+ "\\widebar{⋯}",
+ "\\dot{⋯}",
+ "\\ddot{⋯}",
+ "\\boxed{⋯}",
+ "\\slash{⋯}",
+ ],
+ "(▦)": [
+ "\\begin{matrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{matrix}",
+ "\\begin{pmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{pmatrix}",
+ "\\begin{bmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{bmatrix}",
+ "\\begin{Bmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{Bmatrix}",
+ "\\begin{vmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{vmatrix}",
+ "\\begin{Vmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{Vmatrix}",
+ "\\begin{cases} ⋯ \\\\ ⋯ \\end{cases}",
+ "\\begin{aligned} ⋯ &= ⋯ \\\\ ⋯ &= ⋯ \\end{aligned}",
+ ],
+ });
+ createSymbolPanels([
+ "∏∐∑∫∬∭⨌∮⊎⊕⊖⊗⊘⊙⋀⋁⋂⋃⌈⌉⌊⌋⎰⎱⟨⟩⟪⟫∥⫼⨀⨁⨂⨄⨅⨆ðıȷℏℑℓ℘ℜℵℶ",
+ "∀∃∄∅∉∊∋∌⊂⊃⊄⊅⊆⊇⊈⊈⊉⊊⊊⊋⊋⊏⊐⊑⊒⊓⊔⊥⋐⋑⋔⫅⫆⫋⫋⫌⫌…⋮⋯⋰⋱♭♮♯∂∇",
+ "±×÷†‡•∓∔∗∘∝∠∡∢∧∨∴∵∼∽≁≃≅≇≈≈≊≍≎≏≐≑≒≓≖≗≜≡≢≬⊚⊛⊞⊡⊢⊣⊤⊥",
+ "⊨⊩⊪⊫⊬⊭⊯⊲⊲⊳⊴⊵⊸⊻⋄⋅⋇⋈⋉⋊⋋⋌⋍⋎⋏⋒⋓⌅⌆⌣△▴▵▸▹▽▾▿◂◃◊○★♠♡♢♣⧫",
+ "≦≧≨≩≩≪≫≮≯≰≱≲≳≶≷≺≻≼≽≾≿⊀⊁⋖⋗⋘⋙⋚⋛⋞⋟⋦⋧⋨⋩⩽⩾⪅⪆⪇⪈⪉⪊⪋⪌⪕⪯⪰⪷⪸⪹⪺",
+ "←↑→↓↔↕↖↗↘↙↜↝↞↠↢↣↦↩↪↫↬↭↭↰↱↼↽↾↿⇀⇁⇂⇃⇄⇆⇇⇈⇉⇊⇋⇌⇐⇑⇒⇓⇕⇖⇗⇘⇙⟺",
+ "αβγδϵ϶εζηθϑικϰλμνξℴπϖρϱσςτυϕφχψωΓΔΘΛΞΠΣϒΦΨΩϝ℧",
+ "𝕒𝕓𝕔𝕕𝕖𝕗𝕘𝕙𝕚𝕛𝕜𝕝𝕞𝕟𝕠𝕡𝕢𝕣𝕤𝕥𝕦𝕧𝕨𝕩𝕪𝕫𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ",
+ "𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵",
+ "𝔞𝔟𝔠𝔡𝔢𝔣𝔤𝔥𝔦𝔧𝔨𝔩𝔪𝔫𝔬𝔭𝔮𝔯𝔰𝔱𝔲𝔳𝔴𝔵𝔶𝔷𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ",
+ ]);
+ gDialog.tabbox.selectedIndex = 0;
+
+ updateMath();
+
+ SetWindowLocation();
+}
+
+function insertLaTeXCommand(aButton) {
+ gDialog.input.focus();
+
+ // For a single math symbol, just use the insertText command.
+ if (aButton.label) {
+ gDialog.input.editor.insertText(aButton.label);
+ return;
+ }
+
+ // Otherwise, it's a LaTeX command with at least one argument...
+ var latex = TeXZilla.getTeXSource(aButton.firstElementChild);
+ var selectionStart = gDialog.input.selectionStart;
+ var selectionEnd = gDialog.input.selectionEnd;
+
+ // If the selection is not empty, we replace the first argument of the LaTeX
+ // command with the current selection.
+ var selection = gDialog.input.value.substring(selectionStart, selectionEnd);
+ if (selection != "") {
+ latex = latex.replace("⋯", selection);
+ }
+
+ // Try and move to the next position.
+ var latexNewStart = latex.indexOf("⋯"),
+ latexNewEnd;
+ if (latexNewStart == -1) {
+ // This is a unary function and the selection was used as an argument above.
+ // We select the expression again so that one can choose to apply further
+ // command to it or just move the caret after that text.
+ latexNewStart = 0;
+ latexNewEnd = latex.length;
+ } else {
+ // Otherwise, select the dots representing the next argument.
+ latexNewEnd = latexNewStart + 1;
+ }
+
+ // Update the input text and selection.
+ gDialog.input.editor.insertText(latex);
+ gDialog.input.setSelectionRange(
+ selectionStart + latexNewStart,
+ selectionStart + latexNewEnd
+ );
+
+ updateMath();
+}
+
+function createCommandPanel(aCommandPanelList) {
+ const columnCount = 10;
+
+ for (var label in aCommandPanelList) {
+ var commands = aCommandPanelList[label];
+
+ // Create the <table> element with the <tr>.
+ var table = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "table"
+ );
+
+ var i = 0,
+ row;
+ for (var command of commands) {
+ if (i % columnCount == 0) {
+ // Create a new row.
+ row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+ table.appendChild(row);
+ }
+
+ // Create a new button to insert the symbol.
+ var button = document.createXULElement("toolbarbutton");
+ var td = document.createElementNS("http://www.w3.org/1999/xhtml", "td");
+ button.setAttribute("class", "tabbable");
+ button.appendChild(TeXZilla.toMathML(command));
+ td.append(button);
+ row.appendChild(td);
+
+ i++;
+ }
+
+ // Create a new <tab> element.
+ var tab = document.createXULElement("tab");
+ tab.setAttribute("label", label);
+ gDialog.tabbox.tabs.appendChild(tab);
+
+ // Append the new tab panel.
+ gDialog.tabbox.tabpanels.appendChild(table);
+ }
+}
+
+function createSymbolPanels(aSymbolPanelList) {
+ const columnCount = 13,
+ tabLabelLength = 3;
+
+ for (var symbols of aSymbolPanelList) {
+ // Create the <table> element with the <tr>.
+ var table = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "table"
+ );
+ var i = 0,
+ tabLabel = "",
+ row;
+ for (var symbol of symbols) {
+ if (i % columnCount == 0) {
+ // Create a new row.
+ row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+ table.appendChild(row);
+ }
+
+ // Build the tab label from the first symbols of this tab.
+ if (i < tabLabelLength) {
+ tabLabel += symbol;
+ }
+
+ // Create a new button to insert the symbol.
+ var button = document.createXULElement("toolbarbutton");
+ var td = document.createElementNS("http://www.w3.org/1999/xhtml", "td");
+ button.setAttribute("label", symbol);
+ button.setAttribute("class", "tabbable");
+ td.append(button);
+ row.appendChild(td);
+
+ i++;
+ }
+
+ // Create a new <tab> element with the label determined above.
+ var tab = document.createXULElement("tab");
+ tab.setAttribute("label", tabLabel);
+ gDialog.tabbox.tabs.appendChild(tab);
+
+ // Append the new tab panel.
+ gDialog.tabbox.tabpanels.appendChild(table);
+ }
+}
+
+function onAccept(event) {
+ if (gDialog.output.firstElementChild) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+
+ try {
+ var newMath = editor.document.importNode(
+ gDialog.output.firstElementChild,
+ true
+ );
+ if (gDialog.oldMath) {
+ // Replace the old <math> element with the new one.
+ editor.selectElement(gDialog.oldMath);
+ editor.insertElementAtSelection(newMath, true);
+ } else {
+ // Insert the new <math> element.
+ editor.insertElementAtSelection(newMath, false);
+ }
+ } catch (e) {}
+
+ editor.endTransaction();
+ } else {
+ dump("Null value -- not inserting in MathML Source dialog\n");
+ event.preventDefault();
+ }
+ SaveWindowLocation();
+}
+
+function updateMath() {
+ // Remove the preview, if any.
+ if (gDialog.output.firstElementChild) {
+ gDialog.output.firstElementChild.remove();
+ }
+
+ // Try to convert the LaTeX source into MathML using TeXZilla.
+ // We use the placeholder text if no input is provided.
+ try {
+ var input = gDialog.input.value || gDialog.input.placeholder;
+ var newMath = TeXZilla.toMathML(
+ input,
+ gDialog.mode.selectedIndex,
+ gDialog.direction.selectedIndex,
+ true
+ );
+ gDialog.output.appendChild(document.importNode(newMath, true));
+ gDialog.output.style.opacity = gDialog.input.value ? 1 : 0.5;
+ } catch (e) {}
+ // Disable the accept button if parsing fails or when the placeholder is used.
+ gDialog.accept.disabled =
+ !gDialog.input.value || !gDialog.output.firstElementChild;
+}
+
+function updateMode() {
+ if (gDialog.output.firstElementChild) {
+ gDialog.output.firstElementChild.setAttribute(
+ "display",
+ gDialog.mode.selectedIndex ? "block" : "inline"
+ );
+ }
+}
+
+function updateDirection() {
+ if (gDialog.output.firstElementChild) {
+ gDialog.output.firstElementChild.setAttribute(
+ "dir",
+ gDialog.direction.selectedIndex ? "rtl" : "ltr"
+ );
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml
new file mode 100644
index 0000000000..d76a518b0a
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml
@@ -0,0 +1,73 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+- License, v. 2.0. If a copy of the MPL was not distributed with this
+- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertMath.dtd">
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup();"
+>
+ <dialog
+ buttonlabelaccept="&insertButton.label;"
+ buttonaccesskeyaccept="&insertButton.accesskey;"
+ >
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertMath.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <label id="srcMessage" value="&sourceEditField.label;" />
+ <html:textarea
+ id="input"
+ rows="5"
+ oninput="updateMath();"
+ placeholder="\sqrt{x_1} + \frac{π^3}{2}"
+ />
+ <vbox flex="1" style="overflow: auto; width: 30em; height: 5em">
+ <description id="output" />
+ </vbox>
+ <tabbox id="tabboxInsertLaTeXCommand">
+ <tabs />
+ <tabpanels oncommand="insertLaTeXCommand(event.target);" />
+ </tabbox>
+ <spacer class="spacer" />
+ <html:fieldset>
+ <html:legend>&options.label;</html:legend>
+ <hbox>
+ <radiogroup id="optionMode" oncommand="updateMode();">
+ <radio
+ label="&optionInline.label;"
+ accesskey="&optionInline.accesskey;"
+ />
+ <radio
+ label="&optionDisplay.label;"
+ accesskey="&optionDisplay.accesskey;"
+ />
+ </radiogroup>
+ <radiogroup id="optionDirection" oncommand="updateDirection();">
+ <radio label="&optionLTR.label;" accesskey="&optionLTR.accesskey;" />
+ <radio label="&optionRTL.label;" accesskey="&optionRTL.accesskey;" />
+ </radiogroup>
+ </hbox>
+ </html:fieldset>
+ <spacer class="spacer" />
+ <separator class="groove" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTOC.js b/comm/mail/components/compose/content/dialogs/EdInsertTOC.js
new file mode 100644
index 0000000000..45d0972f3b
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTOC.js
@@ -0,0 +1,378 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// tocHeadersArray is the array containing the pairs tag/class
+// defining TOC entries
+var tocHeadersArray = new Array(6);
+
+// a global used when building the TOC
+var currentHeaderLevel = 0;
+
+// a global set to true if the TOC is to be readonly
+var readonly = false;
+
+// a global set to true if user wants indexes in the TOC
+var orderedList = true;
+
+// constants
+const kMozToc = "mozToc";
+const kMozTocLength = 6;
+const kMozTocIdPrefix = "mozTocId";
+const kMozTocIdPrefixLength = 8;
+const kMozTocClassPrefix = "mozToc";
+const kMozTocClassPrefixLength = 6;
+
+document.addEventListener("dialogaccept", () => BuildTOC(true));
+
+// Startup() is called when EdInsertTOC.xhtml is opened
+function Startup() {
+ // early way out if if we have no editor
+ if (!GetCurrentEditor()) {
+ window.close();
+ return;
+ }
+
+ var i;
+ // clean the table of tag/class pairs we look for
+ for (i = 0; i < 6; ++i) {
+ tocHeadersArray[i] = ["", ""];
+ }
+
+ // reset all settings
+ for (i = 1; i < 7; ++i) {
+ var menulist = document.getElementById("header" + i + "Menulist");
+ var menuitem = document.getElementById("header" + i + "none");
+ var textbox = document.getElementById("header" + i + "Class");
+ menulist.selectedItem = menuitem;
+ textbox.setAttribute("disabled", "true");
+ }
+
+ var theDocument = GetCurrentEditor().document;
+
+ // do we already have a TOC in the document ? It should have "mozToc" ID
+ var toc = theDocument.getElementById(kMozToc);
+
+ // default TOC definition, use h1-h6 for TOC entry levels 1-6
+ var headers = "h1 1 h2 2 h3 3 h4 4 h5 5 h6 6";
+
+ var orderedListCheckbox = document.getElementById("orderedListCheckbox");
+ orderedListCheckbox.checked = true;
+
+ if (toc) {
+ // man, there is already a TOC here
+
+ if (toc.getAttribute("class") == "readonly") {
+ // and it's readonly
+ var checkbox = document.getElementById("readOnlyCheckbox");
+ checkbox.checked = true;
+ readonly = true;
+ }
+
+ // let's see if it's an OL or an UL
+ orderedList = toc.nodeName.toLowerCase() == "ol";
+ orderedListCheckbox.checked = orderedList;
+
+ var nodeList = toc.childNodes;
+ // let's look at the children of the TOC ; if we find a comment beginning
+ // with "mozToc", it contains the TOC definition
+ for (i = 0; i < nodeList.length; ++i) {
+ if (
+ nodeList.item(i).nodeType == Node.COMMENT_NODE &&
+ nodeList.item(i).data.startsWith(kMozToc)
+ ) {
+ // yep, there is already a definition here; parse it !
+ headers = nodeList
+ .item(i)
+ .data.substr(
+ kMozTocLength + 1,
+ nodeList.item(i).length - kMozTocLength - 1
+ );
+ break;
+ }
+ }
+ }
+
+ // let's get an array filled with the (tag.class, index level) pairs
+ var headersArray = headers.split(" ");
+
+ for (i = 0; i < headersArray.length; i += 2) {
+ var tag = headersArray[i],
+ className = "";
+ var index = headersArray[i + 1];
+ menulist = document.getElementById("header" + index + "Menulist");
+ if (menulist) {
+ var sep = tag.indexOf(".");
+ if (sep != -1) {
+ // the tag variable contains in fact "tag.className", let's parse
+ // the class and get the real tag name
+ var tmp = tag.substr(0, sep);
+ className = tag.substr(sep + 1, tag.length - sep - 1);
+ tag = tmp;
+ }
+
+ // update the dialog
+ menuitem = document.getElementById("header" + index + tag.toUpperCase());
+ textbox = document.getElementById("header" + index + "Class");
+ menulist.selectedItem = menuitem;
+ if (tag != "") {
+ textbox.removeAttribute("disabled");
+ }
+ if (className != "") {
+ textbox.value = className;
+ }
+ tocHeadersArray[index - 1] = [tag, className];
+ }
+ }
+}
+
+function BuildTOC(update) {
+ // controlClass() is a node filter that accepts a node if
+ // (a) we don't look for a class (b) we look for a class and
+ // node has it
+ function controlClass(node, index) {
+ currentHeaderLevel = index + 1;
+ if (tocHeadersArray[index][1] == "") {
+ // we are not looking for a specific class, this node is ok
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ if (node.getAttribute("class")) {
+ // yep, we look for a class, let's look at all the classes
+ // the node has
+ var classArray = node.getAttribute("class").split(" ");
+ for (var j = 0; j < classArray.length; j++) {
+ if (classArray[j] == tocHeadersArray[index][1]) {
+ // hehe, we found it...
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ }
+ }
+ return NodeFilter.FILTER_SKIP;
+ }
+
+ // the main node filter for our node iterator
+ // it selects the tag names as specified in the dialog
+ // then calls the controlClass filter above
+ function acceptNode(node) {
+ switch (node.nodeName.toLowerCase()) {
+ case tocHeadersArray[0][0]:
+ return controlClass(node, 0);
+ case tocHeadersArray[1][0]:
+ return controlClass(node, 1);
+ case tocHeadersArray[2][0]:
+ return controlClass(node, 2);
+ case tocHeadersArray[3][0]:
+ return controlClass(node, 3);
+ case tocHeadersArray[4][0]:
+ return controlClass(node, 4);
+ case tocHeadersArray[5][0]:
+ return controlClass(node, 5);
+ default:
+ return NodeFilter.FILTER_SKIP;
+ }
+ }
+
+ var editor = GetCurrentEditor();
+ var theDocument = editor.document;
+ // let's create a TreeWalker to look for our nodes
+ var treeWalker = theDocument.createTreeWalker(
+ theDocument.documentElement,
+ NodeFilter.SHOW_ELEMENT,
+ acceptNode,
+ true
+ );
+ // we need an array to store all TOC entries we find in the document
+ var tocArray = [];
+ if (treeWalker) {
+ var tocSourceNode = treeWalker.nextNode();
+ while (tocSourceNode) {
+ var headerIndex = currentHeaderLevel;
+
+ // we have a node, we need to get all its textual contents
+ var textTreeWalker = theDocument.createTreeWalker(
+ tocSourceNode,
+ NodeFilter.SHOW_TEXT,
+ null,
+ true
+ );
+ var textNode = textTreeWalker.nextNode(),
+ headerText = "";
+ while (textNode) {
+ headerText += textNode.data;
+ textNode = textTreeWalker.nextNode();
+ }
+
+ var anchor = tocSourceNode.firstChild,
+ id;
+ // do we have a named anchor as 1st child of our node ?
+ if (
+ anchor.nodeName.toLowerCase() == "a" &&
+ anchor.hasAttribute("name") &&
+ anchor.getAttribute("name").startsWith(kMozTocIdPrefix)
+ ) {
+ // yep, get its name
+ id = anchor.getAttribute("name");
+ } else {
+ // no we don't and we need to create one
+ anchor = theDocument.createElement("a");
+ tocSourceNode.insertBefore(anchor, tocSourceNode.firstChild);
+ // let's give it a random ID
+ var c = 1000000 * Math.random();
+ id = kMozTocIdPrefix + Math.round(c);
+ anchor.setAttribute("name", id);
+ anchor.setAttribute(
+ "class",
+ kMozTocClassPrefix + tocSourceNode.nodeName.toUpperCase()
+ );
+ }
+ // and store that new entry in our array
+ tocArray.push(headerIndex, headerText, id);
+ tocSourceNode = treeWalker.nextNode();
+ }
+ }
+
+ /* generate the TOC itself */
+ headerIndex = 0;
+ var item, toc;
+ for (var i = 0; i < tocArray.length; i += 3) {
+ if (!headerIndex) {
+ // do we need to create an ol/ul container for the first entry ?
+ ++headerIndex;
+ toc = theDocument.getElementById(kMozToc);
+ if (!toc || !update) {
+ // we need to create a list container for the table of contents
+ toc = GetCurrentEditor().createElementWithDefaults(
+ orderedList ? "ol" : "ul"
+ );
+ // grrr, we need to create a LI inside the list otherwise
+ // Composer will refuse an empty list and will remove it !
+ var pit = theDocument.createElement("li");
+ toc.appendChild(pit);
+ GetCurrentEditor().insertElementAtSelection(toc, true);
+ // ah, now it's inserted so let's remove the useless list item...
+ toc.removeChild(pit);
+ // we need to recognize later that this list is our TOC
+ toc.setAttribute("id", kMozToc);
+ } else if (orderedList != (toc.nodeName.toLowerCase() == "ol")) {
+ // we have to update an existing TOC, is the existing TOC of the
+ // desired type (ordered or not) ?
+
+ // nope, we have to recreate the list
+ var newToc = GetCurrentEditor().createElementWithDefaults(
+ orderedList ? "ol" : "ul"
+ );
+ toc.parentNode.insertBefore(newToc, toc);
+ // and remove the old one
+ toc.remove();
+ toc = newToc;
+ toc.setAttribute("id", kMozToc);
+ } else {
+ // we can keep the list itself but let's get rid of the TOC entries
+ while (toc.hasChildNodes()) {
+ toc.lastChild.remove();
+ }
+ }
+
+ var commentText = "mozToc ";
+ for (var j = 0; j < 6; j++) {
+ if (tocHeadersArray[j][0] != "") {
+ commentText += tocHeadersArray[j][0];
+ if (tocHeadersArray[j][1] != "") {
+ commentText += "." + tocHeadersArray[j][1];
+ }
+ commentText += " " + (j + 1) + " ";
+ }
+ }
+ // important, we have to remove trailing spaces
+ commentText = TrimStringRight(commentText);
+
+ // forge a comment we'll insert in the TOC ; that comment will hold
+ // the TOC definition for us
+ var ct = theDocument.createComment(commentText);
+ toc.appendChild(ct);
+
+ // assign a special class to the TOC top element if the TOC is readonly
+ // the definition of this class is in EditorOverride.css
+ if (readonly) {
+ toc.setAttribute("class", "readonly");
+ } else {
+ toc.removeAttribute("class");
+ }
+
+ // We need a new variable to hold the local ul/ol container
+ // The toplevel TOC element is not the parent element of a
+ // TOC entry if its depth is > 1...
+ var tocList = toc;
+ // create a list item
+ var tocItem = theDocument.createElement("li");
+ // and an anchor in this list item
+ var tocAnchor = theDocument.createElement("a");
+ // make it target the source of the TOC entry
+ tocAnchor.setAttribute("href", "#" + tocArray[i + 2]);
+ // and put the textual contents of the TOC entry in that anchor
+ var tocEntry = theDocument.createTextNode(tocArray[i + 1]);
+ // now, insert everything where it has to be inserted
+ tocAnchor.appendChild(tocEntry);
+ tocItem.appendChild(tocAnchor);
+ tocList.appendChild(tocItem);
+ item = tocList;
+ } else {
+ if (tocArray[i] < headerIndex) {
+ // if the depth of the new TOC entry is less than the depth of the
+ // last entry we created, find the good ul/ol ancestor
+ for (j = headerIndex - tocArray[i]; j > 0; --j) {
+ if (item != toc) {
+ item = item.parentNode.parentNode;
+ }
+ }
+ tocItem = theDocument.createElement("li");
+ } else if (tocArray[i] > headerIndex) {
+ // to the contrary, it's deeper than the last one
+ // we need to create sub ul/ol's and li's
+ for (j = tocArray[i] - headerIndex; j > 0; --j) {
+ tocList = theDocument.createElement(orderedList ? "ol" : "ul");
+ item.lastChild.appendChild(tocList);
+ tocItem = theDocument.createElement("li");
+ tocList.appendChild(tocItem);
+ item = tocList;
+ }
+ } else {
+ tocItem = theDocument.createElement("li");
+ }
+ tocAnchor = theDocument.createElement("a");
+ tocAnchor.setAttribute("href", "#" + tocArray[i + 2]);
+ tocEntry = theDocument.createTextNode(tocArray[i + 1]);
+ tocAnchor.appendChild(tocEntry);
+ tocItem.appendChild(tocAnchor);
+ item.appendChild(tocItem);
+ headerIndex = tocArray[i];
+ }
+ }
+ SaveWindowLocation();
+}
+
+function selectHeader(elt, index) {
+ var tag = elt.value;
+ tocHeadersArray[index - 1][0] = tag;
+ var textbox = document.getElementById("header" + index + "Class");
+ if (tag == "") {
+ textbox.setAttribute("disabled", "true");
+ } else {
+ textbox.removeAttribute("disabled");
+ }
+}
+
+function changeClass(elt, index) {
+ tocHeadersArray[index - 1][1] = elt.value;
+}
+
+function ToggleReadOnlyToc(elt) {
+ readonly = elt.checked;
+}
+
+function ToggleOrderedList(elt) {
+ orderedList = elt.checked;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml
new file mode 100644
index 0000000000..38c85c764d
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml
@@ -0,0 +1,505 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertTOC.dtd">
+
+<window
+ title="&Window.title;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup();"
+ lightweightthemes="true"
+ oncancel="window.close(); return true;"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertTOC.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+ <spacer id="dummy" style="display: none" />
+ <vbox flex="1">
+ <html:fieldset>
+ <html:legend>&buildToc.label;</html:legend>
+ <html:table>
+ <html:tr>
+ <html:th></html:th>
+ <html:th>&tag.label;</html:th>
+ <html:th>&class.label;</html:th>
+ </html:tr>
+ <html:tr>
+ <html:th id="header1Label">&header1.label;</html:th>
+ <html:td>
+ <menulist id="header1Menulist">
+ <menupopup>
+ <menuitem
+ id="header1none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header1H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 1)"
+ />
+ <menuitem
+ id="header1P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 1)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header1Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 1)"
+ aria-labelledby="header1Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header2Label">&header2.label;</html:th>
+ <html:td>
+ <menulist id="header2Menulist">
+ <menupopup>
+ <menuitem
+ id="header2none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header2H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 2)"
+ />
+ <menuitem
+ id="header2P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 2)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header2Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 2)"
+ aria-labelledby="header2Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header3Label">&header3.label;</html:th>
+ <html:td>
+ <menulist id="header3Menulist">
+ <menupopup>
+ <menuitem
+ id="header3none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header3H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 3)"
+ />
+ <menuitem
+ id="header3P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 3)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header3Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 3)"
+ aria-labelledby="header3Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header4Label">&header4.label;</html:th>
+ <html:td>
+ <menulist id="header4Menulist">
+ <menupopup>
+ <menuitem
+ id="header4none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header4H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 4)"
+ />
+ <menuitem
+ id="header4P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 4)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header4Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 4)"
+ aria-labelledby="header4Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header5Label">&header5.label;</html:th>
+ <html:td>
+ <menulist id="header5Menulist">
+ <menupopup>
+ <menuitem
+ id="header5none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header5H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 5)"
+ />
+ <menuitem
+ id="header5P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 5)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header5Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 5)"
+ aria-labelledby="header5Label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th id="header6Label">&header6.label;</html:th>
+ <html:td>
+ <menulist id="header6Menulist">
+ <menupopup>
+ <menuitem
+ id="header6none"
+ label="--"
+ value=""
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuseparator />
+ <menuitem
+ id="header6H1"
+ label="h1"
+ value="h1"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H2"
+ label="h2"
+ value="h2"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H3"
+ label="h3"
+ value="h3"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H4"
+ label="h4"
+ value="h4"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H5"
+ label="h5"
+ value="h5"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6H6"
+ label="h6"
+ value="h6"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6DIV"
+ label="div"
+ value="div"
+ oncommand="selectHeader(this, 6)"
+ />
+ <menuitem
+ id="header6P"
+ label="p"
+ value="p"
+ oncommand="selectHeader(this, 6)"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ <html:td>
+ <html:input
+ id="header6Class"
+ type="text"
+ class="input-inline"
+ size="10"
+ onchange="changeClass(this, 6)"
+ aria-labelledby="header6Label"
+ />
+ </html:td>
+ </html:tr>
+ </html:table>
+ </html:fieldset>
+ <vbox>
+ <checkbox
+ id="orderedListCheckbox"
+ label="&orderedList.label;"
+ oncommand="ToggleOrderedList(this)"
+ />
+ <checkbox
+ id="readOnlyCheckbox"
+ label="&makeReadOnly.label;"
+ oncommand="ToggleReadOnlyToc(this)"
+ />
+ </vbox>
+ <separator class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTable.js b/comm/mail/components/compose/content/dialogs/EdInsertTable.js
new file mode 100644
index 0000000000..5da0da46d3
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTable.js
@@ -0,0 +1,258 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+var gTableElement = null;
+var gRows;
+var gColumns;
+var gActiveEditor;
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ gActiveEditor = GetCurrentTableEditor();
+ if (!gActiveEditor) {
+ dump("Failed to get active editor!\n");
+ window.close();
+ return;
+ }
+
+ try {
+ gTableElement = gActiveEditor.createElementWithDefaults("table");
+ } catch (e) {}
+
+ if (!gTableElement) {
+ dump("Failed to create a new table!\n");
+ window.close();
+ return;
+ }
+ gDialog.rowsInput = document.getElementById("rowsInput");
+ gDialog.columnsInput = document.getElementById("columnsInput");
+ gDialog.widthInput = document.getElementById("widthInput");
+ gDialog.borderInput = document.getElementById("borderInput");
+ gDialog.widthPixelOrPercentMenulist = document.getElementById(
+ "widthPixelOrPercentMenulist"
+ );
+ gDialog.OkButton = document.querySelector("dialog").getButton("accept");
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = gTableElement.cloneNode(false);
+ try {
+ if (
+ Services.prefs.getBoolPref("editor.use_css") &&
+ IsHTMLEditor() &&
+ !(gActiveEditor.flags & Ci.nsIEditor.eEditorMailMask)
+ ) {
+ // only for Composer and not for htmlmail
+ globalElement.setAttribute("style", "text-align: left;");
+ }
+ } catch (e) {}
+
+ // Initialize all widgets with image attributes
+ InitDialog();
+
+ // Set initial number to 2 rows, 2 columns:
+ // Note, these are not attributes on the table,
+ // so don't put them in InitDialog(),
+ // else the user's values will be trashed when they use
+ // the Advanced Edit dialog
+ gDialog.rowsInput.value = 2;
+ gDialog.columnsInput.value = 2;
+
+ // If no default value on the width, set to 100%
+ if (gDialog.widthInput.value.length == 0) {
+ gDialog.widthInput.value = "100";
+ gDialog.widthPixelOrPercentMenulist.selectedIndex = 1;
+ }
+
+ SetTextboxFocusById("rowsInput");
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ // Get default attributes set on the created table:
+ // Get the width attribute of the element, stripping out "%"
+ // This sets contents of menu combobox list
+ // 2nd param = null: Use current selection to find if parent is table cell or window
+ gDialog.widthInput.value = InitPixelOrPercentMenulist(
+ globalElement,
+ null,
+ "width",
+ "widthPixelOrPercentMenulist",
+ gPercent
+ );
+ gDialog.borderInput.value = globalElement.getAttribute("border");
+}
+
+function ChangeRowOrColumn(id) {
+ // Allow only integers
+ forceInteger(id);
+
+ // Enable OK only if both rows and columns have a value > 0
+ var enable =
+ gDialog.rowsInput.value.length > 0 &&
+ gDialog.rowsInput.value > 0 &&
+ gDialog.columnsInput.value.length > 0 &&
+ gDialog.columnsInput.value > 0;
+
+ SetElementEnabled(gDialog.OkButton, enable);
+ SetElementEnabledById("AdvancedEditButton1", enable);
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ gRows = ValidateNumber(
+ gDialog.rowsInput,
+ null,
+ 1,
+ gMaxRows,
+ null,
+ null,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ gColumns = ValidateNumber(
+ gDialog.columnsInput,
+ null,
+ 1,
+ gMaxColumns,
+ null,
+ null,
+ true
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // Set attributes: NOTE: These may be empty strings (last param = false)
+ ValidateNumber(
+ gDialog.borderInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalElement,
+ "border",
+ false
+ );
+ // TODO: Deal with "BORDER" without value issue
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.widthInput,
+ gDialog.widthPixelOrPercentMenulist,
+ 1,
+ gMaxTableSize,
+ globalElement,
+ "width",
+ false
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ gActiveEditor.beginTransaction();
+ try {
+ gActiveEditor.cloneAttributes(gTableElement, globalElement);
+
+ // Create necessary rows and cells for the table
+ var tableBody = gActiveEditor.createElementWithDefaults("tbody");
+ if (tableBody) {
+ gTableElement.appendChild(tableBody);
+
+ // Create necessary rows and cells for the table
+ for (var i = 0; i < gRows; i++) {
+ var newRow = gActiveEditor.createElementWithDefaults("tr");
+ if (newRow) {
+ tableBody.appendChild(newRow);
+ for (var j = 0; j < gColumns; j++) {
+ var newCell = gActiveEditor.createElementWithDefaults("td");
+ if (newCell) {
+ newRow.appendChild(newCell);
+ }
+ }
+ }
+ }
+ }
+ // Detect when entire cells are selected:
+ // Get number of cells selected
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var element = gActiveEditor.getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ var deletePlaceholder = false;
+
+ if (tagNameObj.value == "table") {
+ // Replace entire selected table with new table, so delete the table
+ gActiveEditor.deleteTable();
+ } else if (tagNameObj.value == "td") {
+ if (countObj.value >= 1) {
+ if (countObj.value > 1) {
+ // Assume user wants to replace a block of
+ // contiguous cells with a table, so
+ // join the selected cells
+ gActiveEditor.joinTableCells(false);
+
+ // Get the cell everything was merged into
+ element = gActiveEditor.getSelectedCells()[0];
+
+ // Collapse selection into just that cell
+ gActiveEditor.selection.collapse(element, 0);
+ }
+
+ if (element) {
+ // Empty just the contents of the cell
+ gActiveEditor.deleteTableCellContents();
+
+ // Collapse selection to start of empty cell...
+ gActiveEditor.selection.collapse(element, 0);
+ // ...but it will contain a <br> placeholder
+ deletePlaceholder = true;
+ }
+ }
+ }
+
+ // true means delete selection when inserting
+ gActiveEditor.insertElementAtSelection(gTableElement, true);
+
+ if (
+ deletePlaceholder &&
+ gTableElement &&
+ gTableElement.nextElementSibling
+ ) {
+ // Delete the placeholder <br>
+ gActiveEditor.deleteNode(gTableElement.nextElementSibling);
+ }
+ } catch (e) {}
+
+ gActiveEditor.endTransaction();
+
+ SaveWindowLocation();
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml
new file mode 100644
index 0000000000..b114e09d44
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml
@@ -0,0 +1,126 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edInsertTable SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertTable.dtd">
+%edInsertTable;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdInsertTable.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ control="rowsInput"
+ value="&numRowsEditField.label;"
+ accesskey="&numRowsEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="rowsInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="ChangeRowOrColumn(this.id)"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ control="columnsInput"
+ value="&numColumnsEditField.label;"
+ accesskey="&numColumnsEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="columnsInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="ChangeRowOrColumn(this.id)"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ control="widthInput"
+ value="&widthEditField.label;"
+ accesskey="&widthEditField.accessKey;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="widthInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="forceInteger(this.id)"
+ />
+ </html:td>
+ <html:td>
+ <menulist id="widthPixelOrPercentMenulist" class="menulist-narrow" />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ control="borderInput"
+ value="&borderEditField.label;"
+ accesskey="&borderEditField.accessKey;"
+ tooltiptext="&borderEditField.tooltip;"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="borderInput"
+ type="number"
+ class="narrow input-inline"
+ oninput="forceInteger(this.id)"
+ />
+ </html:td>
+ <html:td>
+ <label value="&pixels.label;" />
+ </html:td>
+ </html:tr>
+ </html:table>
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdLinkProps.js b/comm/mail/components/compose/content/dialogs/EdLinkProps.js
new file mode 100644
index 0000000000..903a4d3099
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdLinkProps.js
@@ -0,0 +1,323 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gActiveEditor;
+var anchorElement = null;
+var imageElement = null;
+var insertNew = false;
+var replaceExistingLink = false;
+var insertLinkAtCaret;
+var needLinkText = false;
+var href;
+var newLinkText;
+var gHNodeArray = {};
+var gHaveNamedAnchors = false;
+var gHaveHeadings = false;
+var gCanChangeHeadingSelected = true;
+var gCanChangeAnchorSelected = true;
+
+// NOTE: Use "href" instead of "a" to distinguish from Named Anchor
+// The returned node is has an "a" tagName
+var tagName = "href";
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ gActiveEditor = GetCurrentEditor();
+ if (!gActiveEditor) {
+ dump("Failed to get active editor!\n");
+ window.close();
+ return;
+ }
+ // Message was wrapped in a <label> or <div>, so actual text is a child text node
+ gDialog.linkTextCaption = document.getElementById("linkTextCaption");
+ gDialog.linkTextMessage = document.getElementById("linkTextMessage");
+ gDialog.linkTextInput = document.getElementById("linkTextInput");
+ gDialog.hrefInput = document.getElementById("hrefInput");
+ gDialog.makeRelativeLink = document.getElementById("MakeRelativeLink");
+ gDialog.AdvancedEditSection = document.getElementById("AdvancedEdit");
+
+ // See if we have a single selected image
+ imageElement = gActiveEditor.getSelectedElement("img");
+
+ if (imageElement) {
+ // Get the parent link if it exists -- more efficient than GetSelectedElement()
+ anchorElement = gActiveEditor.getElementOrParentByTagName(
+ "href",
+ imageElement
+ );
+ if (anchorElement) {
+ if (anchorElement.children.length > 1) {
+ // If there are other children, then we want to break
+ // this image away by inserting a new link around it,
+ // so make a new node and copy existing attributes
+ anchorElement = anchorElement.cloneNode(false);
+ // insertNew = true;
+ replaceExistingLink = true;
+ }
+ }
+ } else {
+ // Get an anchor element if caret or
+ // entire selection is within the link.
+ anchorElement = gActiveEditor.getSelectedElement(tagName);
+
+ if (anchorElement) {
+ // Select the entire link
+ gActiveEditor.selectElement(anchorElement);
+ } else {
+ // If selection starts in a link, but extends beyond it,
+ // the user probably wants to extend existing link to new selection,
+ // so check if either end of selection is within a link
+ // POTENTIAL PROBLEM: This prevents user from selecting text in an existing
+ // link and making 2 links.
+ // Note that this isn't a problem with images, handled above
+
+ anchorElement = gActiveEditor.getElementOrParentByTagName(
+ "href",
+ gActiveEditor.selection.anchorNode
+ );
+ if (!anchorElement) {
+ anchorElement = gActiveEditor.getElementOrParentByTagName(
+ "href",
+ gActiveEditor.selection.focusNode
+ );
+ }
+
+ if (anchorElement) {
+ // But clone it for reinserting/merging around existing
+ // link that only partially overlaps the selection
+ anchorElement = anchorElement.cloneNode(false);
+ // insertNew = true;
+ replaceExistingLink = true;
+ }
+ }
+ }
+
+ if (!anchorElement) {
+ // No existing link -- create a new one
+ anchorElement = gActiveEditor.createElementWithDefaults(tagName);
+ insertNew = true;
+ // Hide message about removing existing link
+ // document.getElementById("RemoveLinkMsg").hidden = true;
+ }
+ if (!anchorElement) {
+ dump("Failed to get selected element or create a new one!\n");
+ window.close();
+ return;
+ }
+
+ // We insert at caret only when nothing is selected
+ insertLinkAtCaret = gActiveEditor.selection.isCollapsed;
+
+ var selectedText;
+ if (insertLinkAtCaret) {
+ // Groupbox caption:
+ gDialog.linkTextCaption.setAttribute("label", GetString("LinkText"));
+
+ // Message above input field:
+ gDialog.linkTextMessage.setAttribute("value", GetString("EnterLinkText"));
+ gDialog.linkTextMessage.setAttribute(
+ "accesskey",
+ GetString("EnterLinkTextAccessKey")
+ );
+ } else {
+ if (!imageElement) {
+ // We get here if selection is exactly around a link node
+ // Check if selection has some text - use that first
+ selectedText = GetSelectionAsText();
+ if (!selectedText) {
+ // No text, look for first image in the selection
+ imageElement = anchorElement.querySelector("img");
+ }
+ }
+ // Set "caption" for link source and the source text or image URL
+ if (imageElement) {
+ gDialog.linkTextCaption.setAttribute("label", GetString("LinkImage"));
+ // Link source string is the source URL of image
+ // TODO: THIS DOESN'T HANDLE MULTIPLE SELECTED IMAGES!
+ gDialog.linkTextMessage.setAttribute("value", imageElement.src);
+ } else {
+ gDialog.linkTextCaption.setAttribute("label", GetString("LinkText"));
+ if (selectedText) {
+ // Use just the first 60 characters and add "..."
+ gDialog.linkTextMessage.setAttribute(
+ "value",
+ TruncateStringAtWordEnd(
+ ReplaceWhitespace(selectedText, " "),
+ 60,
+ true
+ )
+ );
+ } else {
+ gDialog.linkTextMessage.setAttribute(
+ "value",
+ GetString("MixedSelection")
+ );
+ }
+ }
+ }
+
+ // Make a copy to use for AdvancedEdit and onSaveDefault
+ globalElement = anchorElement.cloneNode(false);
+
+ // Get the list of existing named anchors and headings
+ FillLinkMenulist(gDialog.hrefInput, gHNodeArray);
+
+ // We only need to test for this once per dialog load
+ gHaveDocumentUrl = GetDocumentBaseUrl();
+
+ // Set data for the dialog controls
+ InitDialog();
+
+ // Search for a URI pattern in the selected text
+ // as candidate href
+ selectedText = TrimString(selectedText);
+ if (!gDialog.hrefInput.value && TextIsURI(selectedText)) {
+ gDialog.hrefInput.value = selectedText;
+ }
+
+ // Set initial focus
+ if (insertLinkAtCaret) {
+ // We will be using the HREF inputbox, so text message
+ gDialog.linkTextInput.focus();
+ } else {
+ gDialog.hrefInput.select();
+ gDialog.hrefInput.focus();
+
+ // We will not insert a new link at caret, so remove link text input field
+ gDialog.linkTextInput.hidden = true;
+ gDialog.linkTextInput = null;
+ }
+
+ // This sets enable state on OK button
+ doEnabling();
+
+ SetWindowLocation();
+}
+
+// Set dialog widgets with attribute data
+// We get them from globalElement copy so this can be used
+// by AdvancedEdit(), which is shared by all property dialogs
+function InitDialog() {
+ // Must use getAttribute, not "globalElement.href",
+ // or foreign chars aren't converted correctly!
+ gDialog.hrefInput.value = globalElement.getAttribute("href");
+
+ // Set "Relativize" checkbox according to current URL state
+ SetRelativeCheckbox(gDialog.makeRelativeLink);
+}
+
+function doEnabling() {
+ // We disable Ok button when there's no href text only if inserting a new link
+ var enable = insertNew
+ ? TrimString(gDialog.hrefInput.value).length > 0
+ : true;
+
+ // anon. content, so can't use SetElementEnabledById here
+ var dialogNode = document.getElementById("linkDlg");
+ dialogNode.getButton("accept").disabled = !enable;
+
+ SetElementEnabledById("AdvancedEditButton1", enable);
+}
+
+function ChangeLinkLocation() {
+ SetRelativeCheckbox(gDialog.makeRelativeLink);
+ // Set OK button enable state
+ doEnabling();
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ href = TrimString(gDialog.hrefInput.value);
+ if (href) {
+ // Set the HREF directly on the editor document's anchor node
+ // or on the newly-created node if insertNew is true
+ globalElement.setAttribute("href", href);
+ } else if (insertNew) {
+ // We must have a URL to insert a new link
+ // NOTE: We accept an empty HREF on existing link to indicate removing the link
+ ShowInputErrorMessage(GetString("EmptyHREFError"));
+ return false;
+ }
+ if (gDialog.linkTextInput) {
+ // The text we will insert isn't really an attribute,
+ // but it makes sense to validate it
+ newLinkText = TrimString(gDialog.linkTextInput.value);
+ if (!newLinkText) {
+ if (href) {
+ newLinkText = href;
+ } else {
+ ShowInputErrorMessage(GetString("EmptyLinkTextError"));
+ SetTextboxFocus(gDialog.linkTextInput);
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ if (href.length > 0) {
+ // Copy attributes to element we are changing or inserting
+ gActiveEditor.cloneAttributes(anchorElement, globalElement);
+
+ // Coalesce into one undo transaction
+ gActiveEditor.beginTransaction();
+
+ // Get text to use for a new link
+ if (insertLinkAtCaret) {
+ // Append the link text as the last child node
+ // of the anchor node
+ var textNode = gActiveEditor.document.createTextNode(newLinkText);
+ if (textNode) {
+ anchorElement.appendChild(textNode);
+ }
+ try {
+ gActiveEditor.insertElementAtSelection(anchorElement, false);
+ } catch (e) {
+ dump("Exception occurred in InsertElementAtSelection\n");
+ return;
+ }
+ } else if (insertNew || replaceExistingLink) {
+ // Link source was supplied by the selection,
+ // so insert a link node as parent of this
+ // (may be text, image, or other inline content)
+ try {
+ gActiveEditor.insertLinkAroundSelection(anchorElement);
+ } catch (e) {
+ dump("Exception occurred in InsertElementAtSelection\n");
+ return;
+ }
+ }
+ // Check if the link was to a heading
+ if (href in gHNodeArray) {
+ var anchorNode = gActiveEditor.createElementWithDefaults("a");
+ if (anchorNode) {
+ anchorNode.name = href.substr(1);
+
+ // Insert the anchor into the document,
+ // but don't let the transaction change the selection
+ gActiveEditor.setShouldTxnSetSelection(false);
+ gActiveEditor.insertNode(anchorNode, gHNodeArray[href], 0);
+ gActiveEditor.setShouldTxnSetSelection(true);
+ }
+ }
+ gActiveEditor.endTransaction();
+ } else if (!insertNew) {
+ // We already had a link, but empty HREF means remove it
+ EditorRemoveTextProperty("href", "");
+ }
+ SaveWindowLocation();
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml b/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml
new file mode 100644
index 0000000000..7c550a7a45
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml
@@ -0,0 +1,112 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % linkPropertiesDTD SYSTEM "chrome://messenger/locale/messengercompose/EditorLinkProperties.dtd">
+%linkPropertiesDTD;
+<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd">
+%composeEditorOverlayDTD;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+ style="min-height: 26em"
+>
+ <dialog id="linkDlg" style="width: 50ch">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdLinkProps.js" />
+ <script src="chrome://messenger/content/messengercompose/EdImageLinkLoader.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <vbox>
+ <html:fieldset>
+ <html:legend><label id="linkTextCaption" /></html:legend>
+ <vbox>
+ <label id="linkTextMessage" control="linkTextInput" />
+ <html:input
+ id="linkTextInput"
+ type="text"
+ class="input-inline"
+ aria-labelledby="linkTextMessage"
+ />
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="LinkURLBox">
+ <html:legend>&LinkURLBox.label;</html:legend>
+ <vbox id="LinkLocationBox">
+ <label
+ id="hrefLabel"
+ control="hrefInput"
+ accesskey="&LinkURLEditField2.accessKey;"
+ width="1"
+ >&LinkURLEditField2.label;</label
+ >
+ <html:input
+ id="hrefInput"
+ type="text"
+ class="input-inline uri-element padded"
+ oninput="ChangeLinkLocation();"
+ aria-labelledby="hrefLabel"
+ />
+ <hbox align="center">
+ <checkbox
+ id="MakeRelativeLink"
+ for="hrefInput"
+ label="&makeUrlRelative.label;"
+ accesskey="&makeUrlRelative.accessKey;"
+ oncommand="MakeInputValueRelativeOrAbsolute(this);"
+ tooltiptext="&makeUrlRelative.tooltip;"
+ />
+ <spacer flex="1" />
+ <button
+ label="&chooseFileLinkButton.label;"
+ accesskey="&chooseFileLinkButton.accessKey;"
+ oncommand="chooseLinkFile();"
+ />
+ </hbox>
+ </vbox>
+ <checkbox
+ id="AttachSourceToMail"
+ hidden="true"
+ label="&attachLinkSource.label;"
+ accesskey="&attachLinkSource.accesskey;"
+ oncommand="DoAttachSourceCheckbox()"
+ />
+ </html:fieldset>
+ </vbox>
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdListProps.js b/comm/mail/components/compose/content/dialogs/EdListProps.js
new file mode 100644
index 0000000000..c33efc9bb1
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdListProps.js
@@ -0,0 +1,455 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+var gBulletStyleType = "";
+var gNumberStyleType = "";
+var gListElement;
+var gOriginalListType = "";
+var gListType = "";
+var gMixedListSelection = false;
+var gStyleType = "";
+var gOriginalStyleType = "";
+const gOnesArray = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"];
+const gTensArray = ["", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"];
+const gHundredsArray = [
+ "",
+ "C",
+ "CC",
+ "CCC",
+ "CD",
+ "D",
+ "DC",
+ "DCC",
+ "DCCC",
+ "CM",
+];
+const gThousandsArray = [
+ "",
+ "M",
+ "MM",
+ "MMM",
+ "MMMM",
+ "MMMMM",
+ "MMMMMM",
+ "MMMMMMM",
+ "MMMMMMMM",
+ "MMMMMMMMM",
+];
+const gRomanDigits = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
+const A = "A".charCodeAt(0);
+const gArabic = "1";
+const gUpperRoman = "I";
+const gLowerRoman = "i";
+const gUpperLetters = "A";
+const gLowerLetters = "a";
+const gDecimalCSS = "decimal";
+const gUpperRomanCSS = "upper-roman";
+const gLowerRomanCSS = "lower-roman";
+const gUpperAlphaCSS = "upper-alpha";
+const gLowerAlphaCSS = "lower-alpha";
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+ gDialog.ListTypeList = document.getElementById("ListType");
+ gDialog.BulletStyleList = document.getElementById("BulletStyle");
+ gDialog.BulletStyleLabel = document.getElementById("BulletStyleLabel");
+ gDialog.StartingNumberInput = document.getElementById("StartingNumber");
+ gDialog.StartingNumberLabel = document.getElementById("StartingNumberLabel");
+ gDialog.AdvancedEditButton = document.getElementById("AdvancedEditButton1");
+ gDialog.RadioGroup = document.getElementById("RadioGroup");
+ gDialog.ChangeAllRadio = document.getElementById("ChangeAll");
+ gDialog.ChangeSelectedRadio = document.getElementById("ChangeSelected");
+
+ // Try to get an existing list(s)
+ var mixedObj = { value: null };
+ try {
+ gListType = editor.getListState(mixedObj, {}, {}, {});
+
+ // We may have mixed list and non-list, or > 1 list type in selection
+ gMixedListSelection = mixedObj.value;
+
+ // Get the list element at the anchor node
+ gListElement = editor.getElementOrParentByTagName("list", null);
+ } catch (e) {}
+
+ // The copy to use in AdvancedEdit
+ if (gListElement) {
+ globalElement = gListElement.cloneNode(false);
+ }
+
+ // Show extra options for changing entire list if we have one already.
+ gDialog.RadioGroup.collapsed = !gListElement;
+ if (gListElement) {
+ // Radio button index is persistent
+ if (gDialog.RadioGroup.getAttribute("index") == "1") {
+ gDialog.RadioGroup.selectedItem = gDialog.ChangeSelectedRadio;
+ } else {
+ gDialog.RadioGroup.selectedItem = gDialog.ChangeAllRadio;
+ }
+ }
+
+ InitDialog();
+
+ gOriginalListType = gListType;
+
+ gDialog.ListTypeList.focus();
+
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ // Note that if mixed, we we pay attention
+ // only to the anchor node's list type
+ // (i.e., don't confuse user with "mixed" designation)
+ if (gListElement) {
+ gListType = gListElement.nodeName.toLowerCase();
+ } else {
+ gListType = "";
+ }
+
+ gDialog.ListTypeList.value = gListType;
+ gDialog.StartingNumberInput.value = "";
+
+ // Last param = true means attribute value is case-sensitive
+ var type = globalElement
+ ? GetHTMLOrCSSStyleValue(globalElement, "type", "list-style-type")
+ : null;
+
+ if (gListType == "ul") {
+ if (type) {
+ type = type.toLowerCase();
+ gBulletStyleType = type;
+ gOriginalStyleType = type;
+ }
+ } else if (gListType == "ol") {
+ // Translate CSS property strings
+ switch (type.toLowerCase()) {
+ case gDecimalCSS:
+ type = gArabic;
+ break;
+ case gUpperRomanCSS:
+ type = gUpperRoman;
+ break;
+ case gLowerRomanCSS:
+ type = gLowerRoman;
+ break;
+ case gUpperAlphaCSS:
+ type = gUpperLetters;
+ break;
+ case gLowerAlphaCSS:
+ type = gLowerLetters;
+ break;
+ }
+ if (type) {
+ gNumberStyleType = type;
+ gOriginalStyleType = type;
+ }
+
+ // Convert attribute number to appropriate letter or roman numeral
+ gDialog.StartingNumberInput.value = ConvertStartAttrToUserString(
+ globalElement.getAttribute("start"),
+ type
+ );
+ }
+ BuildBulletStyleList();
+}
+
+// Convert attribute number to appropriate letter or roman numeral
+function ConvertStartAttrToUserString(startAttr, type) {
+ switch (type) {
+ case gUpperRoman:
+ startAttr = ConvertArabicToRoman(startAttr);
+ break;
+ case gLowerRoman:
+ startAttr = ConvertArabicToRoman(startAttr).toLowerCase();
+ break;
+ case gUpperLetters:
+ startAttr = ConvertArabicToLetters(startAttr);
+ break;
+ case gLowerLetters:
+ startAttr = ConvertArabicToLetters(startAttr).toLowerCase();
+ break;
+ }
+ return startAttr;
+}
+
+function BuildBulletStyleList() {
+ gDialog.BulletStyleList.removeAllItems();
+ var label;
+
+ if (gListType == "ul") {
+ gDialog.BulletStyleList.removeAttribute("disabled");
+ gDialog.BulletStyleLabel.removeAttribute("disabled");
+ gDialog.StartingNumberInput.setAttribute("disabled", "true");
+ gDialog.StartingNumberLabel.setAttribute("disabled", "true");
+
+ label = GetString("BulletStyle");
+
+ gDialog.BulletStyleList.appendItem(GetString("Automatic"), "");
+ gDialog.BulletStyleList.appendItem(GetString("SolidCircle"), "disc");
+ gDialog.BulletStyleList.appendItem(GetString("OpenCircle"), "circle");
+ gDialog.BulletStyleList.appendItem(GetString("SolidSquare"), "square");
+
+ gDialog.BulletStyleList.value = gBulletStyleType;
+ } else if (gListType == "ol") {
+ gDialog.BulletStyleList.removeAttribute("disabled");
+ gDialog.BulletStyleLabel.removeAttribute("disabled");
+ gDialog.StartingNumberInput.removeAttribute("disabled");
+ gDialog.StartingNumberLabel.removeAttribute("disabled");
+ label = GetString("NumberStyle");
+
+ gDialog.BulletStyleList.appendItem(GetString("Automatic"), "");
+ gDialog.BulletStyleList.appendItem(GetString("Style_1"), gArabic);
+ gDialog.BulletStyleList.appendItem(GetString("Style_I"), gUpperRoman);
+ gDialog.BulletStyleList.appendItem(GetString("Style_i"), gLowerRoman);
+ gDialog.BulletStyleList.appendItem(GetString("Style_A"), gUpperLetters);
+ gDialog.BulletStyleList.appendItem(GetString("Style_a"), gLowerLetters);
+
+ gDialog.BulletStyleList.value = gNumberStyleType;
+ } else {
+ gDialog.BulletStyleList.setAttribute("disabled", "true");
+ gDialog.BulletStyleLabel.setAttribute("disabled", "true");
+ gDialog.StartingNumberInput.setAttribute("disabled", "true");
+ gDialog.StartingNumberLabel.setAttribute("disabled", "true");
+ }
+
+ // Disable advanced edit button if changing to "normal"
+ if (gListType) {
+ gDialog.AdvancedEditButton.removeAttribute("disabled");
+ } else {
+ gDialog.AdvancedEditButton.setAttribute("disabled", "true");
+ }
+
+ if (label) {
+ gDialog.BulletStyleLabel.textContent = label;
+ }
+}
+
+function SelectListType() {
+ // Each list type is stored in the "value" of each menuitem
+ var NewType = gDialog.ListTypeList.value;
+
+ if (NewType == "ol") {
+ SetTextboxFocus(gDialog.StartingNumberInput);
+ }
+
+ if (gListType != NewType) {
+ gListType = NewType;
+
+ // Create a newlist object for Advanced Editing
+ try {
+ if (gListType) {
+ globalElement = GetCurrentEditor().createElementWithDefaults(gListType);
+ }
+ } catch (e) {}
+
+ BuildBulletStyleList();
+ }
+}
+
+function SelectBulletStyle() {
+ // Save the selected index so when user changes
+ // list style, restore index to associated list
+ // Each bullet or number type is stored in the "value" of each menuitem
+ if (gListType == "ul") {
+ gBulletStyleType = gDialog.BulletStyleList.value;
+ } else if (gListType == "ol") {
+ var type = gDialog.BulletStyleList.value;
+ if (gNumberStyleType != type) {
+ // Convert existing input value to attr number first,
+ // then convert to the appropriate format for the newly-selected
+ gDialog.StartingNumberInput.value = ConvertStartAttrToUserString(
+ ConvertUserStringToStartAttr(gNumberStyleType),
+ type
+ );
+
+ gNumberStyleType = type;
+ SetTextboxFocus(gDialog.StartingNumberInput);
+ }
+ }
+}
+
+function ValidateData() {
+ gBulletStyleType = gDialog.BulletStyleList.value;
+ // globalElement should already be of the correct type
+
+ if (globalElement) {
+ var editor = GetCurrentEditor();
+ if (gListType == "ul") {
+ if (gBulletStyleType && gDialog.ChangeAllRadio.selected) {
+ globalElement.setAttribute("type", gBulletStyleType);
+ } else {
+ try {
+ editor.removeAttributeOrEquivalent(globalElement, "type", true);
+ } catch (e) {}
+ }
+ } else if (gListType == "ol") {
+ if (gBulletStyleType) {
+ globalElement.setAttribute("type", gBulletStyleType);
+ } else {
+ try {
+ editor.removeAttributeOrEquivalent(globalElement, "type", true);
+ } catch (e) {}
+ }
+
+ var startingNumber = ConvertUserStringToStartAttr(gBulletStyleType);
+ if (startingNumber) {
+ globalElement.setAttribute("start", startingNumber);
+ } else {
+ globalElement.removeAttribute("start");
+ }
+ }
+ }
+ return true;
+}
+
+function ConvertUserStringToStartAttr(type) {
+ var startingNumber = TrimString(gDialog.StartingNumberInput.value);
+
+ switch (type) {
+ case gUpperRoman:
+ case gLowerRoman:
+ // If the input isn't an integer, assume it's a roman numeral. Convert it.
+ if (!Number(startingNumber)) {
+ startingNumber = ConvertRomanToArabic(startingNumber);
+ }
+ break;
+ case gUpperLetters:
+ case gLowerLetters:
+ // Get the number equivalent of the letters
+ if (!Number(startingNumber)) {
+ startingNumber = ConvertLettersToArabic(startingNumber);
+ }
+ break;
+ }
+ return startingNumber;
+}
+
+function ConvertRomanToArabic(num) {
+ num = num.toUpperCase();
+ if (num && !/[^MDCLXVI]/i.test(num)) {
+ var Arabic = 0;
+ var last_digit = 1000;
+ for (var i = 0; i < num.length; i++) {
+ var digit = gRomanDigits[num.charAt(i)];
+ if (last_digit < digit) {
+ Arabic -= 2 * last_digit;
+ }
+
+ last_digit = digit;
+ Arabic += last_digit;
+ }
+ return Arabic;
+ }
+
+ return "";
+}
+
+function ConvertArabicToRoman(num) {
+ if (/^\d{1,4}$/.test(num)) {
+ var digits = ("000" + num).substr(-4);
+ return (
+ gThousandsArray[digits.charAt(0)] +
+ gHundredsArray[digits.charAt(1)] +
+ gTensArray[digits.charAt(2)] +
+ gOnesArray[digits.charAt(3)]
+ );
+ }
+ return "";
+}
+
+function ConvertLettersToArabic(letters) {
+ letters = letters.toUpperCase();
+ if (!letters || /[^A-Z]/.test(letters)) {
+ return "";
+ }
+
+ var num = 0;
+ for (var i = 0; i < letters.length; i++) {
+ num = num * 26 + letters.charCodeAt(i) - A + 1;
+ }
+ return num;
+}
+
+function ConvertArabicToLetters(num) {
+ var letters = "";
+ while (num) {
+ num--;
+ letters = String.fromCharCode(A + (num % 26)) + letters;
+ num = Math.floor(num / 26);
+ }
+ return letters;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ // Coalesce into one undo transaction
+ var editor = GetCurrentEditor();
+
+ editor.beginTransaction();
+
+ var changeEntireList =
+ gDialog.RadioGroup.selectedItem == gDialog.ChangeAllRadio;
+
+ // Remember which radio button was selected
+ if (gListElement) {
+ gDialog.RadioGroup.setAttribute("index", changeEntireList ? "0" : "1");
+ }
+
+ var changeList;
+ if (gListElement && gDialog.ChangeAllRadio.selected) {
+ changeList = true;
+ } else {
+ changeList =
+ gMixedListSelection ||
+ gListType != gOriginalListType ||
+ gBulletStyleType != gOriginalStyleType;
+ }
+ if (changeList) {
+ try {
+ if (gListType) {
+ editor.makeOrChangeList(
+ gListType,
+ changeEntireList,
+ gBulletStyleType != gOriginalStyleType ? gBulletStyleType : null
+ );
+
+ // Get the new list created:
+ gListElement = editor.getElementOrParentByTagName(gListType, null);
+
+ editor.cloneAttributes(gListElement, globalElement);
+ } else {
+ // Remove all existing lists
+ if (gListElement && changeEntireList) {
+ editor.selectElement(gListElement);
+ }
+
+ editor.removeList("ol");
+ editor.removeList("ul");
+ editor.removeList("dl");
+ }
+ } catch (e) {}
+ }
+
+ editor.endTransaction();
+
+ SaveWindowLocation();
+
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdListProps.xhtml b/comm/mail/components/compose/content/dialogs/EdListProps.xhtml
new file mode 100644
index 0000000000..b8d7c40cb2
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdListProps.xhtml
@@ -0,0 +1,101 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edListProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorListProperties.dtd">
+%edListProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdListProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <html:fieldset>
+ <html:legend>&ListType.label;</html:legend>
+ <menulist id="ListType" oncommand="SelectListType()">
+ <menupopup>
+ <menuitem label="&none.value;" />
+ <menuitem value="ul" label="&bulletList.value;" />
+ <menuitem value="ol" label="&numberList.value;" />
+ <menuitem value="dl" label="&definitionList.value;" />
+ </menupopup>
+ </menulist>
+ </html:fieldset>
+ <spacer class="spacer" />
+
+ <!-- message text and list items are set in JS
+ text value should be identical to string with id=BulletStyle in editor.properties
+ -->
+ <html:fieldset>
+ <html:legend id="BulletStyleLabel">&bulletStyle.label;</html:legend>
+ <menulist id="BulletStyle" oncommand="SelectBulletStyle()">
+ <menupopup />
+ </menulist>
+ <spacer class="spacer" />
+ <hbox align="center">
+ <label
+ id="StartingNumberLabel"
+ control="StartingNumber"
+ value="&startingNumber.label;"
+ accesskey="&startingNumber.accessKey;"
+ />
+ <html:input
+ id="StartingNumber"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="StartingNumberLabel"
+ />
+ <spacer />
+ </hbox>
+ </html:fieldset>
+ <radiogroup id="RadioGroup" index="0" persist="index">
+ <radio
+ id="ChangeAll"
+ label="&changeEntireListRadio.label;"
+ accesskey="&changeEntireListRadio.accessKey;"
+ />
+ <radio
+ id="ChangeSelected"
+ label="&changeSelectedRadio.label;"
+ accesskey="&changeSelectedRadio.accessKey;"
+ />
+ </radiogroup>
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js
new file mode 100644
index 0000000000..c943cc2833
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js
@@ -0,0 +1,159 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gInsertNew = true;
+var gAnchorElement = null;
+var gOriginalName = "";
+const kTagName = "anchor";
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ gDialog.OkButton = document.querySelector("dialog").getButton("accept");
+ gDialog.NameInput = document.getElementById("nameInput");
+
+ // Get a single selected element of the desired type
+ gAnchorElement = editor.getSelectedElement(kTagName);
+
+ if (gAnchorElement) {
+ // We found an element and don't need to insert one
+ gInsertNew = false;
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = gAnchorElement.cloneNode(false);
+ gOriginalName = ConvertToCDATAString(gAnchorElement.name);
+ } else {
+ gInsertNew = true;
+ // We don't have an element selected,
+ // so create one with default attributes
+ gAnchorElement = editor.createElementWithDefaults(kTagName);
+ if (gAnchorElement) {
+ // Use the current selection as suggested name
+ var name = GetSelectionAsText();
+ // Get 40 characters of the selected text and don't add "...",
+ // replace whitespace with "_" and strip non-word characters
+ name = ConvertToCDATAString(TruncateStringAtWordEnd(name, 40, false));
+ // Be sure the name is unique to the document
+ if (AnchorNameExists(name)) {
+ name += "_";
+ }
+
+ // Make a copy to use for AdvancedEdit
+ globalElement = gAnchorElement.cloneNode(false);
+ globalElement.setAttribute("name", name);
+ }
+ }
+ if (!gAnchorElement) {
+ dump("Failed to get selected element or create a new one!\n");
+ window.close();
+ return;
+ }
+
+ InitDialog();
+
+ DoEnabling();
+ SetTextboxFocus(gDialog.NameInput);
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ gDialog.NameInput.value = globalElement.getAttribute("name");
+}
+
+function ChangeName() {
+ if (gDialog.NameInput.value.length > 0) {
+ // Replace spaces with "_" and strip other non-URL characters
+ // Note: we could use ConvertAndEscape, but then we'd
+ // have to UnEscapeAndConvert beforehand - too messy!
+ gDialog.NameInput.value = ConvertToCDATAString(gDialog.NameInput.value);
+ }
+ DoEnabling();
+}
+
+function DoEnabling() {
+ var enable = gDialog.NameInput.value.length > 0;
+ SetElementEnabled(gDialog.OkButton, enable);
+ SetElementEnabledById("AdvancedEditButton1", enable);
+}
+
+function AnchorNameExists(name) {
+ var anchorList;
+ try {
+ anchorList = GetCurrentEditor().document.anchors;
+ } catch (e) {}
+
+ if (anchorList) {
+ for (var i = 0; i < anchorList.length; i++) {
+ if (anchorList[i].name == name) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+// Get and validate data from widgets.
+// Set attributes on globalElement so they can be accessed by AdvancedEdit()
+function ValidateData() {
+ var name = TrimString(gDialog.NameInput.value);
+ if (!name) {
+ ShowInputErrorMessage(GetString("MissingAnchorNameError"));
+ SetTextboxFocus(gDialog.NameInput);
+ return false;
+ }
+ // Replace spaces with "_" and strip other characters
+ // Note: we could use ConvertAndEscape, but then we'd
+ // have to UnConverAndEscape beforehand - too messy!
+ name = ConvertToCDATAString(name);
+
+ if (gOriginalName != name && AnchorNameExists(name)) {
+ ShowInputErrorMessage(
+ GetString("DuplicateAnchorNameError").replace(/%name%/, name)
+ );
+ SetTextboxFocus(gDialog.NameInput);
+ return false;
+ }
+ globalElement.name = name;
+
+ return true;
+}
+
+function onAccept(event) {
+ if (ValidateData()) {
+ if (gOriginalName != globalElement.name) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+
+ try {
+ // "false" = don't delete selected text when inserting
+ if (gInsertNew) {
+ // We must insert element before copying CSS style attribute,
+ // but we must set the name else it won't insert at all
+ gAnchorElement.name = globalElement.name;
+ editor.insertElementAtSelection(gAnchorElement, false);
+ }
+
+ // Copy attributes to element we are changing or inserting
+ editor.cloneAttributes(gAnchorElement, globalElement);
+ } catch (e) {}
+
+ editor.endTransaction();
+ }
+ SaveWindowLocation();
+ return;
+ }
+ event.preventDefault();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml
new file mode 100644
index 0000000000..d26f4d73b4
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml
@@ -0,0 +1,67 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edNamedAnchorProperties SYSTEM "chrome://messenger/locale/messengercompose/EdNamedAnchorProperties.dtd">
+%edNamedAnchorProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdNamedAnchorProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <label
+ id="nameLabel"
+ control="nameInput"
+ value="&anchorNameEditField.label;"
+ accesskey="&anchorNameEditField.accessKey;"
+ />
+ <html:input
+ id="nameInput"
+ type="text"
+ class="MinWidth20em input-inline"
+ oninput="ChangeName()"
+ title="&nameInput.tooltip;"
+ aria-labelledby="nameLabel"
+ />
+ <spacer class="spacer" />
+ <vbox id="AdvancedEdit">
+ <hbox flex="1" style="margin-top: 0.2em" align="center">
+ <!-- This will right-align the button -->
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton1"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <separator id="advancedSeparator" class="groove" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdReplace.js b/comm/mail/components/compose/content/dialogs/EdReplace.js
new file mode 100644
index 0000000000..c937702416
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdReplace.js
@@ -0,0 +1,380 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var gReplaceDialog; // Quick access to document/form elements.
+var gFindInst; // nsIWebBrowserFind that we're going to use
+var gFindService; // Global service which remembers find params
+var gEditor; // the editor we're using
+
+document.addEventListener("dialogaccept", event => {
+ onFindNext();
+ event.preventDefault();
+});
+
+function initDialogObject() {
+ // Create gReplaceDialog object and initialize.
+ gReplaceDialog = {};
+ gReplaceDialog.findInput = document.getElementById("dialog.findInput");
+ gReplaceDialog.replaceInput = document.getElementById("dialog.replaceInput");
+ gReplaceDialog.caseSensitive = document.getElementById(
+ "dialog.caseSensitive"
+ );
+ gReplaceDialog.wrap = document.getElementById("dialog.wrap");
+ gReplaceDialog.searchBackwards = document.getElementById(
+ "dialog.searchBackwards"
+ );
+ gReplaceDialog.findNext = document.getElementById("findNext");
+ gReplaceDialog.replace = document.getElementById("replace");
+ gReplaceDialog.replaceAndFind = document.getElementById("replaceAndFind");
+ gReplaceDialog.replaceAll = document.getElementById("replaceAll");
+}
+
+function loadDialog() {
+ // Set initial dialog field contents.
+ // Set initial dialog field contents. Use the gFindInst attributes first,
+ // this is necessary for window.find()
+ gReplaceDialog.findInput.value = gFindInst.searchString
+ ? gFindInst.searchString
+ : gFindService.searchString;
+ gReplaceDialog.replaceInput.value = gFindService.replaceString;
+ gReplaceDialog.caseSensitive.checked = gFindInst.matchCase
+ ? gFindInst.matchCase
+ : gFindService.matchCase;
+ gReplaceDialog.wrap.checked = gFindInst.wrapFind
+ ? gFindInst.wrapFind
+ : gFindService.wrapFind;
+ gReplaceDialog.searchBackwards.checked = gFindInst.findBackwards
+ ? gFindInst.findBackwards
+ : gFindService.findBackwards;
+
+ doEnabling();
+}
+
+function onLoad() {
+ // Get the xul <editor> element:
+ var editorElement = window.arguments[0];
+
+ // If we don't get the editor, then we won't allow replacing.
+ gEditor = editorElement.getEditor(editorElement.contentWindow);
+ if (!gEditor) {
+ window.close();
+ return;
+ }
+
+ // Get the nsIWebBrowserFind service:
+ gFindInst = editorElement.webBrowserFind;
+
+ try {
+ // get the find service, which stores global find state
+ gFindService = Cc["@mozilla.org/find/find_service;1"].getService(
+ Ci.nsIFindService
+ );
+ } catch (e) {
+ dump("No find service!\n");
+ gFindService = 0;
+ }
+
+ // Init gReplaceDialog.
+ initDialogObject();
+
+ // Change "OK" to "Find".
+ // dialog.find.label = document.getElementById("fBLT").getAttribute("label");
+
+ // Fill dialog.
+ loadDialog();
+
+ if (gReplaceDialog.findInput.value) {
+ gReplaceDialog.findInput.select();
+ } else {
+ gReplaceDialog.findInput.focus();
+ }
+}
+
+function saveFindData() {
+ // Set data attributes per user input.
+ if (gFindService) {
+ gFindService.searchString = gReplaceDialog.findInput.value;
+ gFindService.matchCase = gReplaceDialog.caseSensitive.checked;
+ gFindService.wrapFind = gReplaceDialog.wrap.checked;
+ gFindService.findBackwards = gReplaceDialog.searchBackwards.checked;
+ }
+}
+
+function setUpFindInst() {
+ gFindInst.searchString = gReplaceDialog.findInput.value;
+ gFindInst.matchCase = gReplaceDialog.caseSensitive.checked;
+ gFindInst.wrapFind = gReplaceDialog.wrap.checked;
+ gFindInst.findBackwards = gReplaceDialog.searchBackwards.checked;
+}
+
+function onFindNext() {
+ // Transfer dialog contents to the find service.
+ saveFindData();
+ // set up the find instance
+ setUpFindInst();
+
+ // Search.
+ var result = gFindInst.findNext();
+
+ if (!result) {
+ var bundle = document.getElementById("findBundle");
+ Services.prompt.alert(
+ window,
+ GetString("Alert"),
+ bundle.getString("notFoundWarning")
+ );
+ SetTextboxFocus(gReplaceDialog.findInput);
+ gReplaceDialog.findInput.select();
+ gReplaceDialog.findInput.focus();
+ return false;
+ }
+ return true;
+}
+
+function onReplace() {
+ if (!gEditor) {
+ return false;
+ }
+
+ // Does the current selection match the find string?
+ var selection = gEditor.selection;
+
+ var selStr = selection.toString();
+ var specStr = gReplaceDialog.findInput.value;
+ if (!gReplaceDialog.caseSensitive.checked) {
+ selStr = selStr.toLowerCase();
+ specStr = specStr.toLowerCase();
+ }
+ // Unfortunately, because of whitespace we can't just check
+ // whether (selStr == specStr), but have to loop ourselves.
+ // N chars of whitespace in specStr can match any M >= N in selStr.
+ var matches = true;
+ var specLen = specStr.length;
+ var selLen = selStr.length;
+ if (selLen < specLen) {
+ matches = false;
+ } else {
+ var specArray = specStr.match(/\S+|\s+/g);
+ var selArray = selStr.match(/\S+|\s+/g);
+ if (specArray.length != selArray.length) {
+ matches = false;
+ } else {
+ for (var i = 0; i < selArray.length; i++) {
+ if (selArray[i] != specArray[i]) {
+ if (/\S/.test(selArray[i][0]) || /\S/.test(specArray[i][0])) {
+ // not a space chunk -- match fails
+ matches = false;
+ break;
+ } else if (selArray[i].length < specArray[i].length) {
+ // if it's a space chunk then we only care that sel be
+ // at least as long as spec
+ matches = false;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // If the current selection doesn't match the pattern,
+ // then we want to find the next match, but not do the replace.
+ // That's what most other apps seem to do.
+ // So here, just return.
+ if (!matches) {
+ return false;
+ }
+
+ // Transfer dialog contents to the find service.
+ saveFindData();
+
+ // For reverse finds, need to remember the caret position
+ // before current selection
+ var newRange;
+ if (gReplaceDialog.searchBackwards.checked && selection.rangeCount > 0) {
+ newRange = selection.getRangeAt(0).cloneRange();
+ newRange.collapse(true);
+ }
+
+ // nsPlaintextEditor::InsertText fails if the string is empty,
+ // so make that a special case:
+ var replStr = gReplaceDialog.replaceInput.value;
+ if (replStr == "") {
+ gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip);
+ } else {
+ gEditor.insertText(replStr);
+ }
+
+ // For reverse finds, need to move caret just before the replaced text
+ if (gReplaceDialog.searchBackwards.checked && newRange) {
+ gEditor.selection.removeAllRanges();
+ gEditor.selection.addRange(newRange);
+ }
+
+ return true;
+}
+
+function onReplaceAll() {
+ if (!gEditor) {
+ return;
+ }
+
+ var findStr = gReplaceDialog.findInput.value;
+ var repStr = gReplaceDialog.replaceInput.value;
+
+ // Transfer dialog contents to the find service.
+ saveFindData();
+
+ var finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
+ .createInstance()
+ .QueryInterface(Ci.nsIFind);
+
+ finder.caseSensitive = gReplaceDialog.caseSensitive.checked;
+ finder.findBackwards = gReplaceDialog.searchBackwards.checked;
+
+ // We want the whole operation to be undoable in one swell foop,
+ // so start a transaction:
+ gEditor.beginTransaction();
+
+ // and to make sure we close the transaction, guard against exceptions:
+ try {
+ // Make a range containing the current selection,
+ // so we don't go past it when we wrap.
+ var selection = gEditor.selection;
+ var selecRange;
+ if (selection.rangeCount > 0) {
+ selecRange = selection.getRangeAt(0);
+ }
+ var origRange = selecRange.cloneRange();
+
+ // We'll need a range for the whole document:
+ var wholeDocRange = gEditor.document.createRange();
+ var rootNode = gEditor.rootElement;
+ wholeDocRange.selectNodeContents(rootNode);
+
+ // And start and end points:
+ var endPt = gEditor.document.createRange();
+
+ if (gReplaceDialog.searchBackwards.checked) {
+ endPt.setStart(wholeDocRange.startContainer, wholeDocRange.startOffset);
+ endPt.setEnd(wholeDocRange.startContainer, wholeDocRange.startOffset);
+ } else {
+ endPt.setStart(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ endPt.setEnd(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ }
+
+ // Find and replace from here to end (start) of document:
+ var foundRange;
+ var searchRange = wholeDocRange.cloneRange();
+ while (
+ (foundRange = finder.Find(findStr, searchRange, selecRange, endPt)) !=
+ null
+ ) {
+ gEditor.selection.removeAllRanges();
+ gEditor.selection.addRange(foundRange);
+
+ // The editor will leave the caret at the end of the replaced text.
+ // For reverse finds, we need it at the beginning,
+ // so save the next position now.
+ if (gReplaceDialog.searchBackwards.checked) {
+ selecRange = foundRange.cloneRange();
+ selecRange.setEnd(selecRange.startContainer, selecRange.startOffset);
+ }
+
+ // nsPlaintextEditor::InsertText fails if the string is empty,
+ // so make that a special case:
+ if (repStr == "") {
+ gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip);
+ } else {
+ gEditor.insertText(repStr);
+ }
+
+ // If we're going forward, we didn't save selecRange before, so do it now:
+ if (!gReplaceDialog.searchBackwards.checked) {
+ selection = gEditor.selection;
+ if (selection.rangeCount <= 0) {
+ gEditor.endTransaction();
+ return;
+ }
+ selecRange = selection.getRangeAt(0).cloneRange();
+ }
+ }
+
+ // If no wrapping, then we're done
+ if (!gReplaceDialog.wrap.checked) {
+ gEditor.endTransaction();
+ return;
+ }
+
+ // If wrapping, find from start/end of document back to start point.
+ if (gReplaceDialog.searchBackwards.checked) {
+ // Collapse origRange to end
+ origRange.setStart(origRange.endContainer, origRange.endOffset);
+ // Set current position to document end
+ selecRange.setEnd(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ selecRange.setStart(wholeDocRange.endContainer, wholeDocRange.endOffset);
+ } else {
+ // Collapse origRange to start
+ origRange.setEnd(origRange.startContainer, origRange.startOffset);
+ // Set current position to document start
+ selecRange.setStart(
+ wholeDocRange.startContainer,
+ wholeDocRange.startOffset
+ );
+ selecRange.setEnd(
+ wholeDocRange.startContainer,
+ wholeDocRange.startOffset
+ );
+ }
+
+ while (
+ (foundRange = finder.Find(
+ findStr,
+ wholeDocRange,
+ selecRange,
+ origRange
+ )) != null
+ ) {
+ gEditor.selection.removeAllRanges();
+ gEditor.selection.addRange(foundRange);
+
+ // Save insert point for backward case
+ if (gReplaceDialog.searchBackwards.checked) {
+ selecRange = foundRange.cloneRange();
+ selecRange.setEnd(selecRange.startContainer, selecRange.startOffset);
+ }
+
+ // nsPlaintextEditor::InsertText fails if the string is empty,
+ // so make that a special case:
+ if (repStr == "") {
+ gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip);
+ } else {
+ gEditor.insertText(repStr);
+ }
+
+ // Get insert point for forward case
+ if (!gReplaceDialog.searchBackwards.checked) {
+ selection = gEditor.selection;
+ if (selection.rangeCount <= 0) {
+ gEditor.endTransaction();
+ return;
+ }
+ selecRange = selection.getRangeAt(0);
+ }
+ }
+ } catch (e) {}
+
+ gEditor.endTransaction();
+}
+
+function doEnabling() {
+ var findStr = gReplaceDialog.findInput.value;
+ gReplaceDialog.enabled = findStr;
+ gReplaceDialog.findNext.disabled = !findStr;
+ gReplaceDialog.replace.disabled = !findStr;
+ gReplaceDialog.replaceAndFind.disabled = !findStr;
+ gReplaceDialog.replaceAll.disabled = !findStr;
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdReplace.xhtml b/comm/mail/components/compose/content/dialogs/EdReplace.xhtml
new file mode 100644
index 0000000000..62ce5a67e2
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdReplace.xhtml
@@ -0,0 +1,126 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorReplace.dtd">
+
+<window
+ id="replaceDlg"
+ title="&replaceDialog.title;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ onload="onLoad()"
+>
+ <dialog buttons="cancel">
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdReplace.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <stringbundle
+ id="findBundle"
+ src="chrome://global/locale/finddialog.properties"
+ />
+
+ <hbox>
+ <vbox>
+ <spacer class="spacer" />
+ <html:div class="grid-two-column">
+ <html:div class="flex-items-center">
+ <label
+ value="&findField.label;"
+ accesskey="&findField.accesskey;"
+ control="dialog.findInput"
+ />
+ </html:div>
+ <html:div>
+ <html:input
+ id="dialog.findInput"
+ class="input-inline"
+ oninput="doEnabling();"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label
+ value="&replaceField.label;"
+ accesskey="&replaceField.accesskey;"
+ control="dialog.replaceInput"
+ />
+ </html:div>
+ <html:div>
+ <html:input
+ id="dialog.replaceInput"
+ class="input-inline"
+ oninput="doEnabling();"
+ />
+ </html:div>
+ <html:div class="grid-item-col2">
+ <vbox align="start">
+ <checkbox
+ id="dialog.caseSensitive"
+ label="&caseSensitiveCheckbox.label;"
+ accesskey="&caseSensitiveCheckbox.accesskey;"
+ />
+ <checkbox
+ id="dialog.wrap"
+ label="&wrapCheckbox.label;"
+ accesskey="&wrapCheckbox.accesskey;"
+ />
+ <checkbox
+ id="dialog.searchBackwards"
+ label="&backwardsCheckbox.label;"
+ accesskey="&backwardsCheckbox.accesskey;"
+ />
+ </vbox>
+ </html:div>
+ </html:div>
+ </vbox>
+ <spacer class="spacer" />
+ <vbox>
+ <button
+ id="findNext"
+ label="&findNextButton.label;"
+ accesskey="&findNextButton.accesskey;"
+ oncommand="onFindNext();"
+ default="true"
+ />
+ <button
+ id="replace"
+ label="&replaceButton.label;"
+ accesskey="&replaceButton.accesskey;"
+ oncommand="onReplace();"
+ />
+ <button
+ id="replaceAndFind"
+ label="&replaceAndFindButton.label;"
+ accesskey="&replaceAndFindButton.accesskey;"
+ oncommand="onReplace(); onFindNext();"
+ />
+ <button
+ id="replaceAll"
+ label="&replaceAllButton.label;"
+ accesskey="&replaceAllButton.accesskey;"
+ oncommand="onReplaceAll();"
+ />
+ <button
+ dlgtype="cancel"
+ label="&closeButton.label;"
+ accesskey="&closeButton.accesskey;"
+ />
+ </vbox>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdSpellCheck.js b/comm/mail/components/compose/content/dialogs/EdSpellCheck.js
new file mode 100644
index 0000000000..5b54205bc3
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdSpellCheck.js
@@ -0,0 +1,496 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../base/content/utilityOverlay.js */
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+var { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+);
+
+var gMisspelledWord;
+var gSpellChecker = null;
+var gAllowSelectWord = true;
+var gPreviousReplaceWord = "";
+var gFirstTime = true;
+var gDictCount = 0;
+
+document.addEventListener("dialogaccept", doDefault);
+document.addEventListener("dialogcancel", CancelSpellCheck);
+
+function Startup() {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ window.close();
+ return;
+ }
+
+ // Get the spellChecker shell
+ gSpellChecker = Cu.createSpellChecker();
+ if (!gSpellChecker) {
+ dump("SpellChecker not found!!!\n");
+ window.close();
+ return;
+ }
+
+ // Start the spell checker module.
+ try {
+ var skipBlockQuotes = window.arguments[1];
+ var enableSelectionChecking = window.arguments[2];
+
+ gSpellChecker.setFilterType(
+ skipBlockQuotes
+ ? Ci.nsIEditorSpellCheck.FILTERTYPE_MAIL
+ : Ci.nsIEditorSpellCheck.FILTERTYPE_NORMAL
+ );
+ gSpellChecker.InitSpellChecker(
+ editor,
+ enableSelectionChecking,
+ spellCheckStarted
+ );
+ } catch (ex) {
+ dump("*** Exception error: InitSpellChecker\n");
+ window.close();
+ }
+}
+
+function spellCheckStarted() {
+ gDialog.MisspelledWordLabel = document.getElementById("MisspelledWordLabel");
+ gDialog.MisspelledWord = document.getElementById("MisspelledWord");
+ gDialog.ReplaceButton = document.getElementById("Replace");
+ gDialog.IgnoreButton = document.getElementById("Ignore");
+ gDialog.StopButton = document.getElementById("Stop");
+ gDialog.CloseButton = document.getElementById("Close");
+ gDialog.ReplaceWordInput = document.getElementById("ReplaceWordInput");
+ gDialog.SuggestedList = document.getElementById("SuggestedList");
+ gDialog.LanguageMenulist = document.getElementById("LanguageMenulist");
+
+ // Fill in the language menulist and sync it up
+ // with the spellchecker's current language.
+
+ var curLangs;
+
+ try {
+ curLangs = new Set(gSpellChecker.getCurrentDictionaries());
+ } catch (ex) {
+ curLangs = new Set();
+ }
+
+ InitLanguageMenu(curLangs);
+
+ // Get the first misspelled word and setup all UI
+ NextWord();
+
+ // When startup param is true, setup different UI when spell checking
+ // just before sending mail message
+ if (window.arguments[0]) {
+ // If no misspelled words found, simply close dialog and send message
+ if (!gMisspelledWord) {
+ onClose();
+ return;
+ }
+
+ // Hide "Close" button and use "Send" instead
+ gDialog.CloseButton.hidden = true;
+ gDialog.CloseButton = document.getElementById("Send");
+ gDialog.CloseButton.hidden = false;
+ } else {
+ // Normal spell checking - hide the "Stop" button
+ // (Note that this button is the "Cancel" button for
+ // Esc keybinding and related window close actions)
+ gDialog.StopButton.hidden = true;
+ }
+
+ // Clear flag that determines message when
+ // no misspelled word is found
+ // (different message when used for the first time)
+ gFirstTime = false;
+
+ window.sizeToContent();
+}
+
+/**
+ * Populate the dictionary language selector menu.
+ *
+ * @param {Set<string>} activeDictionaries - Currently active dictionaries.
+ */
+function InitLanguageMenu(activeDictionaries) {
+ // Get the list of dictionaries from
+ // the spellchecker.
+
+ var dictList;
+ try {
+ dictList = gSpellChecker.GetDictionaryList();
+ } catch (ex) {
+ dump("Failed to get DictionaryList!\n");
+ return;
+ }
+
+ // If we're not just starting up and dictionary count
+ // hasn't changed then no need to update the menu.
+ if (gDictCount == dictList.length) {
+ return;
+ }
+
+ // Store current dictionary count.
+ gDictCount = dictList.length;
+
+ var inlineSpellChecker = new InlineSpellChecker();
+ var sortedList = inlineSpellChecker.sortDictionaryList(dictList);
+
+ // Remove any languages from the list.
+ let list = document.getElementById("dictionary-list");
+ let template = document.getElementById("language-item");
+
+ list.replaceChildren(
+ ...sortedList.map(({ displayName, localeCode }) => {
+ let item = template.content.cloneNode(true);
+ item.querySelector(".checkbox-label").textContent = displayName;
+ let input = item.querySelector("input");
+ input.addEventListener("input", () => {
+ SelectLanguage(localeCode);
+ });
+ input.checked = activeDictionaries.has(localeCode);
+ return item;
+ })
+ );
+}
+
+function DoEnabling() {
+ if (!gMisspelledWord) {
+ // No more misspelled words
+ gDialog.MisspelledWord.setAttribute(
+ "value",
+ GetString(gFirstTime ? "NoMisspelledWord" : "CheckSpellingDone")
+ );
+
+ gDialog.ReplaceButton.removeAttribute("default");
+ gDialog.IgnoreButton.removeAttribute("default");
+
+ gDialog.CloseButton.setAttribute("default", "true");
+ // Shouldn't have to do this if "default" is true?
+ gDialog.CloseButton.focus();
+
+ SetElementEnabledById("MisspelledWordLabel", false);
+ SetElementEnabledById("ReplaceWordLabel", false);
+ SetElementEnabledById("ReplaceWordInput", false);
+ SetElementEnabledById("CheckWord", false);
+ SetElementEnabledById("SuggestedListLabel", false);
+ SetElementEnabledById("SuggestedList", false);
+ SetElementEnabledById("Ignore", false);
+ SetElementEnabledById("IgnoreAll", false);
+ SetElementEnabledById("Replace", false);
+ SetElementEnabledById("ReplaceAll", false);
+ SetElementEnabledById("AddToDictionary", false);
+ } else {
+ SetElementEnabledById("MisspelledWordLabel", true);
+ SetElementEnabledById("ReplaceWordLabel", true);
+ SetElementEnabledById("ReplaceWordInput", true);
+ SetElementEnabledById("CheckWord", true);
+ SetElementEnabledById("SuggestedListLabel", true);
+ SetElementEnabledById("SuggestedList", true);
+ SetElementEnabledById("Ignore", true);
+ SetElementEnabledById("IgnoreAll", true);
+ SetElementEnabledById("AddToDictionary", true);
+
+ gDialog.CloseButton.removeAttribute("default");
+ SetReplaceEnable();
+ }
+}
+
+function NextWord() {
+ gMisspelledWord = gSpellChecker.GetNextMisspelledWord();
+ SetWidgetsForMisspelledWord();
+}
+
+function SetWidgetsForMisspelledWord() {
+ gDialog.MisspelledWord.setAttribute("value", gMisspelledWord);
+
+ // Initial replace word is misspelled word
+ gDialog.ReplaceWordInput.value = gMisspelledWord;
+ gPreviousReplaceWord = gMisspelledWord;
+
+ // This sets gDialog.ReplaceWordInput to first suggested word in list
+ FillSuggestedList(gMisspelledWord);
+
+ DoEnabling();
+
+ if (gMisspelledWord) {
+ SetTextboxFocus(gDialog.ReplaceWordInput);
+ }
+}
+
+function CheckWord() {
+ var word = gDialog.ReplaceWordInput.value;
+ if (word) {
+ if (gSpellChecker.CheckCurrentWord(word)) {
+ FillSuggestedList(word);
+ SetReplaceEnable();
+ } else {
+ ClearListbox(gDialog.SuggestedList);
+ var item = gDialog.SuggestedList.appendItem(
+ GetString("CorrectSpelling"),
+ ""
+ );
+ if (item) {
+ item.setAttribute("disabled", "true");
+ }
+ // Suppress being able to select the message text
+ gAllowSelectWord = false;
+ }
+ }
+}
+
+function SelectSuggestedWord() {
+ if (gAllowSelectWord) {
+ if (gDialog.SuggestedList.selectedItem) {
+ var selValue = gDialog.SuggestedList.selectedItem.label;
+ gDialog.ReplaceWordInput.value = selValue;
+ gPreviousReplaceWord = selValue;
+ } else {
+ gDialog.ReplaceWordInput.value = gPreviousReplaceWord;
+ }
+ SetReplaceEnable();
+ }
+}
+
+function ChangeReplaceWord() {
+ // Calling this triggers SelectSuggestedWord(),
+ // so temporarily suppress the effect of that
+ var saveAllow = gAllowSelectWord;
+ gAllowSelectWord = false;
+
+ // Select matching word in list
+ var newSelectedItem;
+ var replaceWord = TrimString(gDialog.ReplaceWordInput.value);
+ if (replaceWord) {
+ for (var i = 0; i < gDialog.SuggestedList.getRowCount(); i++) {
+ var item = gDialog.SuggestedList.getItemAtIndex(i);
+ if (item.label == replaceWord) {
+ newSelectedItem = item;
+ break;
+ }
+ }
+ }
+ gDialog.SuggestedList.selectedItem = newSelectedItem;
+
+ gAllowSelectWord = saveAllow;
+
+ // Remember the new word
+ gPreviousReplaceWord = gDialog.ReplaceWordInput.value;
+
+ SetReplaceEnable();
+}
+
+function Ignore() {
+ NextWord();
+}
+
+function IgnoreAll() {
+ if (gMisspelledWord) {
+ gSpellChecker.IgnoreWordAllOccurrences(gMisspelledWord);
+ }
+ NextWord();
+}
+
+function Replace(newWord) {
+ if (!newWord) {
+ return;
+ }
+
+ if (gMisspelledWord && gMisspelledWord != newWord) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+ try {
+ gSpellChecker.ReplaceWord(gMisspelledWord, newWord, false);
+ } catch (e) {}
+ editor.endTransaction();
+ }
+ NextWord();
+}
+
+function ReplaceAll() {
+ var newWord = gDialog.ReplaceWordInput.value;
+ if (gMisspelledWord && gMisspelledWord != newWord) {
+ var editor = GetCurrentEditor();
+ editor.beginTransaction();
+ try {
+ gSpellChecker.ReplaceWord(gMisspelledWord, newWord, true);
+ } catch (e) {}
+ editor.endTransaction();
+ }
+ NextWord();
+}
+
+function AddToDictionary() {
+ if (gMisspelledWord) {
+ gSpellChecker.AddWordToDictionary(gMisspelledWord);
+ }
+ NextWord();
+}
+
+function EditDictionary() {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdDictionary.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ gMisspelledWord
+ );
+}
+
+/**
+ * Change the selection state of the given dictionary language.
+ *
+ * @param {string} language
+ */
+function SelectLanguage(language) {
+ let activeDictionaries = new Set(gSpellChecker.getCurrentDictionaries());
+ if (activeDictionaries.has(language)) {
+ activeDictionaries.delete(language);
+ } else {
+ activeDictionaries.add(language);
+ }
+ let activeDictionariesArray = Array.from(activeDictionaries);
+ gSpellChecker.setCurrentDictionaries(activeDictionariesArray);
+ // For compose windows we need to set the "lang" attribute so the
+ // core editor uses the correct dictionary for the inline spell check.
+ if (window.arguments[1]) {
+ if ("ComposeChangeLanguage" in window.opener) {
+ // We came here from a compose window.
+ window.opener.ComposeChangeLanguage(activeDictionariesArray);
+ } else if (activeDictionaries.size === 1) {
+ window.opener.document.documentElement.setAttribute(
+ "lang",
+ activeDictionariesArray[0]
+ );
+ } else {
+ window.opener.document.documentElement.setAttribute("lang", "");
+ }
+ }
+}
+
+function Recheck() {
+ var recheckLanguages;
+
+ function finishRecheck() {
+ gSpellChecker.setCurrentDictionaries(recheckLanguages);
+ gMisspelledWord = gSpellChecker.GetNextMisspelledWord();
+ SetWidgetsForMisspelledWord();
+ }
+
+ // TODO: Should we bother to add a "Recheck" method to interface?
+ try {
+ recheckLanguages = gSpellChecker.getCurrentDictionaries();
+ gSpellChecker.UninitSpellChecker();
+ // Clear the ignore all list.
+ Cc["@mozilla.org/spellchecker/personaldictionary;1"]
+ .getService(Ci.mozIPersonalDictionary)
+ .endSession();
+ gSpellChecker.InitSpellChecker(GetCurrentEditor(), false, finishRecheck);
+ } catch (ex) {
+ console.error(ex);
+ }
+}
+
+function FillSuggestedList(misspelledWord) {
+ var list = gDialog.SuggestedList;
+
+ // Clear the current contents of the list
+ gAllowSelectWord = false;
+ ClearListbox(list);
+ var item;
+
+ if (misspelledWord.length > 0) {
+ // Get suggested words until an empty string is returned
+ var count = 0;
+ do {
+ var word = gSpellChecker.GetSuggestedWord();
+ if (word.length > 0) {
+ list.appendItem(word, "");
+ count++;
+ }
+ } while (word.length > 0);
+
+ if (count == 0) {
+ // No suggestions - show a message but don't let user select it
+ item = list.appendItem(GetString("NoSuggestedWords"));
+ if (item) {
+ item.setAttribute("disabled", "true");
+ }
+ gAllowSelectWord = false;
+ } else {
+ gAllowSelectWord = true;
+ // Initialize with first suggested list by selecting it
+ gDialog.SuggestedList.selectedIndex = 0;
+ }
+ } else {
+ item = list.appendItem("", "");
+ if (item) {
+ item.setAttribute("disabled", "true");
+ }
+ }
+}
+
+function SetReplaceEnable() {
+ // Enable "Change..." buttons only if new word is different than misspelled
+ var newWord = gDialog.ReplaceWordInput.value;
+ var enable = newWord.length > 0 && newWord != gMisspelledWord;
+ SetElementEnabledById("Replace", enable);
+ SetElementEnabledById("ReplaceAll", enable);
+ if (enable) {
+ gDialog.ReplaceButton.setAttribute("default", "true");
+ gDialog.IgnoreButton.removeAttribute("default");
+ } else {
+ gDialog.IgnoreButton.setAttribute("default", "true");
+ gDialog.ReplaceButton.removeAttribute("default");
+ }
+}
+
+function doDefault(event) {
+ if (gDialog.ReplaceButton.getAttribute("default") == "true") {
+ Replace(gDialog.ReplaceWordInput.value);
+ } else if (gDialog.IgnoreButton.getAttribute("default") == "true") {
+ Ignore();
+ } else if (gDialog.CloseButton.getAttribute("default") == "true") {
+ onClose();
+ }
+
+ event.preventDefault();
+}
+
+function ExitSpellChecker() {
+ if (gSpellChecker) {
+ try {
+ gSpellChecker.UninitSpellChecker();
+ // now check the document over again with the new dictionary
+ // if we have an inline spellchecker
+ if (
+ "InlineSpellCheckerUI" in window.opener &&
+ window.opener.InlineSpellCheckerUI.enabled
+ ) {
+ window.opener.InlineSpellCheckerUI.mInlineSpellChecker.spellCheckRange(
+ null
+ );
+ }
+ } finally {
+ gSpellChecker = null;
+ }
+ }
+}
+
+function CancelSpellCheck() {
+ ExitSpellChecker();
+
+ // Signal to calling window that we canceled
+ window.opener.cancelSendMessage = true;
+}
+
+function onClose() {
+ ExitSpellChecker();
+
+ window.opener.cancelSendMessage = false;
+ window.close();
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml b/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml
new file mode 100644
index 0000000000..fcff0e1703
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml
@@ -0,0 +1,209 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorSpellCheck.dtd">
+
+<!-- dialog containing a control requiring initial setup -->
+<window
+ id="spellCheckDlg"
+ title="&windowTitle.label;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog buttons="cancel">
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/EdSpellCheck.js" />
+ <script src="chrome://global/content/contentAreaUtils.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <stringbundle
+ id="languageBundle"
+ src="chrome://global/locale/languageNames.properties"
+ />
+ <stringbundle
+ id="regionBundle"
+ src="chrome://global/locale/regionNames.properties"
+ />
+
+ <html:div class="grid-three-column-auto-x-auto">
+ <html:div class="flex-items-center">
+ <label id="MisspelledWordLabel" value="&misspelledWord.label;" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label id="MisspelledWord" class="bold" crop="end" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <button
+ class="spell-check"
+ label="&recheckButton2.label;"
+ oncommand="Recheck();"
+ accesskey="&recheckButton2.accessKey;"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label
+ id="ReplaceWordLabel"
+ value="&wordEditField.label;"
+ control="ReplaceWordInput"
+ accesskey="&wordEditField.accessKey;"
+ />
+ </html:div>
+ <html:div>
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="ReplaceWordInput"
+ type="text"
+ class="input-inline"
+ onchange="ChangeReplaceWord()"
+ aria-labelledby="ReplaceWordLabel"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="flex-items-center">
+ <button
+ id="CheckWord"
+ class="spell-check"
+ oncommand="CheckWord()"
+ label="&checkwordButton.label;"
+ accesskey="&checkwordButton.accessKey;"
+ />
+ </html:div>
+ </html:div>
+ <label
+ id="SuggestedListLabel"
+ value="&suggestions.label;"
+ control="SuggestedList"
+ accesskey="&suggestions.accessKey;"
+ />
+ <hbox flex="1" class="display-flex">
+ <html:div class="grid-two-column-x-auto flex-1">
+ <html:div class="display-flex">
+ <richlistbox
+ id="SuggestedList"
+ class="display-flex flex-1"
+ onselect="SelectSuggestedWord()"
+ ondblclick="if (gAllowSelectWord) { Replace(event.target.value); }"
+ />
+ </html:div>
+ <html:div>
+ <vbox>
+ <html:div class="grid-two-column-equalsize">
+ <button
+ id="Replace"
+ class="spell-check"
+ label="&replaceButton.label;"
+ oncommand="Replace(gDialog.ReplaceWordInput.value);"
+ accesskey="&replaceButton.accessKey;"
+ />
+ <button
+ id="Ignore"
+ class="spell-check"
+ oncommand="Ignore();"
+ label="&ignoreButton.label;"
+ accesskey="&ignoreButton.accessKey;"
+ />
+ <button
+ id="ReplaceAll"
+ class="spell-check"
+ oncommand="ReplaceAll();"
+ label="&replaceAllButton.label;"
+ accesskey="&replaceAllButton.accessKey;"
+ />
+ <button
+ id="IgnoreAll"
+ class="spell-check"
+ oncommand="IgnoreAll();"
+ label="&ignoreAllButton.label;"
+ accesskey="&ignoreAllButton.accessKey;"
+ />
+ </html:div>
+ <separator />
+ <label value="&userDictionary.label;" />
+ <hbox align="start">
+ <button
+ id="AddToDictionary"
+ class="spell-check"
+ oncommand="AddToDictionary()"
+ label="&addToUserDictionaryButton.label;"
+ accesskey="&addToUserDictionaryButton.accessKey;"
+ />
+ <button
+ id="EditDictionary"
+ class="spell-check"
+ oncommand="EditDictionary()"
+ label="&editUserDictionaryButton.label;"
+ accesskey="&editUserDictionaryButton.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ </html:div>
+ <html:div class="grid-item-span-row">
+ <label
+ value="&languagePopup.label;"
+ control="LanguageMenulist"
+ accesskey="&languagePopup.accessKey;"
+ />
+ </html:div>
+ <html:div>
+ <html:ul id="dictionary-list"> </html:ul>
+ <html:template id="language-item"
+ ><html:li>
+ <html:label
+ ><html:input type="checkbox"></html:input>
+ <html:span class="checkbox-label"></html:span
+ ></html:label> </html:li
+ ></html:template>
+ <html:a onclick="openDictionaryList()" href=""
+ >&moreDictionaries.label;</html:a
+ >
+ </html:div>
+ <html:div>
+ <hbox class="display-flex">
+ <button
+ id="Stop"
+ class="spell-check"
+ dlgtype="cancel"
+ label="&stopButton.label;"
+ oncommand="CancelSpellCheck();"
+ accesskey="&stopButton.accessKey;"
+ />
+ <spacer class="flex-1" />
+ <button
+ id="Close"
+ class="spell-check"
+ label="&closeButton.label;"
+ oncommand="onClose();"
+ accesskey="&closeButton.accessKey;"
+ />
+ <button
+ id="Send"
+ class="spell-check"
+ label="&sendButton.label;"
+ oncommand="onClose();"
+ accesskey="&sendButton.accessKey;"
+ hidden="true"
+ />
+ </hbox>
+ </html:div>
+ </html:div>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/dialogs/EdTableProps.js b/comm/mail/components/compose/content/dialogs/EdTableProps.js
new file mode 100644
index 0000000000..fd4ab40f3a
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdTableProps.js
@@ -0,0 +1,1426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../editorUtilities.js */
+/* import-globals-from EdDialogCommon.js */
+
+// Cancel() is in EdDialogCommon.js
+
+var gTableElement;
+var gCellElement;
+var gTableCaptionElement;
+var globalCellElement;
+var globalTableElement;
+var gValidateTab;
+const defHAlign = "left";
+const centerStr = "center"; // Index=1
+const rightStr = "right"; // 2
+const justifyStr = "justify"; // 3
+const charStr = "char"; // 4
+const defVAlign = "middle";
+const topStr = "top";
+const bottomStr = "bottom";
+const bgcolor = "bgcolor";
+var gTableColor;
+var gCellColor;
+
+const cssBackgroundColorStr = "background-color";
+
+var gRowCount = 1;
+var gColCount = 1;
+var gLastRowIndex;
+var gLastColIndex;
+var gNewRowCount;
+var gNewColCount;
+var gCurRowIndex;
+var gCurColIndex;
+var gCurColSpan;
+var gSelectedCellsType = 1;
+const SELECT_CELL = 1;
+const SELECT_ROW = 2;
+const SELECT_COLUMN = 3;
+const RESET_SELECTION = 0;
+var gCellData = {
+ value: null,
+ startRowIndex: 0,
+ startColIndex: 0,
+ rowSpan: 0,
+ colSpan: 0,
+ actualRowSpan: 0,
+ actualColSpan: 0,
+ isSelected: false,
+};
+var gAdvancedEditUsed;
+var gAlignWasChar = false;
+
+/*
+From C++:
+ 0 TABLESELECTION_TABLE
+ 1 TABLESELECTION_CELL There are 1 or more cells selected
+ but complete rows or columns are not selected
+ 2 TABLESELECTION_ROW All cells are in 1 or more rows
+ and in each row, all cells selected
+ Note: This is the value if all rows (thus all cells) are selected
+ 3 TABLESELECTION_COLUMN All cells are in 1 or more columns
+*/
+
+var gSelectedCellCount = 0;
+var gApplyUsed = false;
+var gSelection;
+var gCellDataChanged = false;
+var gCanDelete = false;
+var gUseCSS = true;
+var gActiveEditor;
+
+// dialog initialization code
+
+document.addEventListener("dialogaccept", onAccept);
+document.addEventListener("dialogextra1", Apply);
+document.addEventListener("dialogcancel", onCancel);
+
+function Startup() {
+ gActiveEditor = GetCurrentTableEditor();
+ if (!gActiveEditor) {
+ window.close();
+ return;
+ }
+
+ try {
+ gSelection = gActiveEditor.selection;
+ } catch (e) {}
+ if (!gSelection) {
+ return;
+ }
+
+ // Get dialog widgets - Table Panel
+ gDialog.TableRowsInput = document.getElementById("TableRowsInput");
+ gDialog.TableColumnsInput = document.getElementById("TableColumnsInput");
+ gDialog.TableWidthInput = document.getElementById("TableWidthInput");
+ gDialog.TableWidthUnits = document.getElementById("TableWidthUnits");
+ gDialog.TableHeightInput = document.getElementById("TableHeightInput");
+ gDialog.TableHeightUnits = document.getElementById("TableHeightUnits");
+ try {
+ if (
+ !Services.prefs.getBoolPref("editor.use_css") ||
+ gActiveEditor.flags & 1
+ ) {
+ gUseCSS = false;
+ var tableHeightLabel = document.getElementById("TableHeightLabel");
+ tableHeightLabel.remove();
+ gDialog.TableHeightInput.remove();
+ gDialog.TableHeightUnits.remove();
+ }
+ } catch (e) {}
+ gDialog.BorderWidthInput = document.getElementById("BorderWidthInput");
+ gDialog.SpacingInput = document.getElementById("SpacingInput");
+ gDialog.PaddingInput = document.getElementById("PaddingInput");
+ gDialog.TableAlignList = document.getElementById("TableAlignList");
+ gDialog.TableCaptionList = document.getElementById("TableCaptionList");
+ gDialog.TableInheritColor = document.getElementById("TableInheritColor");
+ gDialog.TabBox = document.getElementById("TabBox");
+
+ // Cell Panel
+ gDialog.SelectionList = document.getElementById("SelectionList");
+ gDialog.PreviousButton = document.getElementById("PreviousButton");
+ gDialog.NextButton = document.getElementById("NextButton");
+ // Currently, we always apply changes and load new attributes when changing selection
+ // (Let's keep this for possible future use)
+ // gDialog.ApplyBeforeMove = document.getElementById("ApplyBeforeMove");
+ // gDialog.KeepCurrentData = document.getElementById("KeepCurrentData");
+
+ gDialog.CellHeightInput = document.getElementById("CellHeightInput");
+ gDialog.CellHeightUnits = document.getElementById("CellHeightUnits");
+ gDialog.CellWidthInput = document.getElementById("CellWidthInput");
+ gDialog.CellWidthUnits = document.getElementById("CellWidthUnits");
+ gDialog.CellHAlignList = document.getElementById("CellHAlignList");
+ gDialog.CellVAlignList = document.getElementById("CellVAlignList");
+ gDialog.CellInheritColor = document.getElementById("CellInheritColor");
+ gDialog.CellStyleList = document.getElementById("CellStyleList");
+ gDialog.TextWrapList = document.getElementById("TextWrapList");
+
+ // In cell panel, user must tell us which attributes to apply via checkboxes,
+ // else we would apply values from one cell to ALL in selection
+ // and that's probably not what they expect!
+ gDialog.CellHeightCheckbox = document.getElementById("CellHeightCheckbox");
+ gDialog.CellWidthCheckbox = document.getElementById("CellWidthCheckbox");
+ gDialog.CellHAlignCheckbox = document.getElementById("CellHAlignCheckbox");
+ gDialog.CellVAlignCheckbox = document.getElementById("CellVAlignCheckbox");
+ gDialog.CellStyleCheckbox = document.getElementById("CellStyleCheckbox");
+ gDialog.TextWrapCheckbox = document.getElementById("TextWrapCheckbox");
+ gDialog.CellColorCheckbox = document.getElementById("CellColorCheckbox");
+ gDialog.TableTab = document.getElementById("TableTab");
+ gDialog.CellTab = document.getElementById("CellTab");
+ gDialog.AdvancedEditCell = document.getElementById("AdvancedEditButton2");
+ // Save "normal" tooltip message for Advanced Edit button
+ gDialog.AdvancedEditCellToolTipText =
+ gDialog.AdvancedEditCell.getAttribute("tooltiptext");
+
+ try {
+ gTableElement = gActiveEditor.getElementOrParentByTagName("table", null);
+ } catch (e) {}
+ if (!gTableElement) {
+ dump("Failed to get table element!\n");
+ window.close();
+ return;
+ }
+ globalTableElement = gTableElement.cloneNode(false);
+
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ var tableOrCellElement;
+ try {
+ tableOrCellElement = gActiveEditor.getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ } catch (e) {}
+
+ if (tagNameObj.value == "td") {
+ // We are in a cell
+ gSelectedCellCount = countObj.value;
+ gCellElement = tableOrCellElement;
+ globalCellElement = gCellElement.cloneNode(false);
+
+ // Tells us whether cell, row, or column is selected
+ try {
+ gSelectedCellsType = gActiveEditor.getSelectedCellsType(gTableElement);
+ } catch (e) {}
+
+ // Ignore types except Cell, Row, and Column
+ if (
+ gSelectedCellsType < SELECT_CELL ||
+ gSelectedCellsType > SELECT_COLUMN
+ ) {
+ gSelectedCellsType = SELECT_CELL;
+ }
+
+ // Be sure at least 1 cell is selected.
+ // (If the count is 0, then we were inside the cell.)
+ if (gSelectedCellCount == 0) {
+ DoCellSelection();
+ }
+
+ // Get location in the cell map
+ var rowIndexObj = { value: 0 };
+ var colIndexObj = { value: 0 };
+ try {
+ gActiveEditor.getCellIndexes(gCellElement, rowIndexObj, colIndexObj);
+ } catch (e) {}
+ gCurRowIndex = rowIndexObj.value;
+ gCurColIndex = colIndexObj.value;
+
+ // We save the current colspan to quickly
+ // move selection from from cell to cell
+ if (GetCellData(gCurRowIndex, gCurColIndex)) {
+ gCurColSpan = gCellData.colSpan;
+ }
+
+ // Starting TabPanel name is passed in
+ if (window.arguments[1] == "CellPanel") {
+ gDialog.TabBox.selectedTab = gDialog.CellTab;
+ }
+ }
+
+ if (gDialog.TabBox.selectedTab == gDialog.TableTab) {
+ // We may call this with table selected, but no cell,
+ // so disable the Cell Properties tab
+ if (!gCellElement) {
+ // XXX: Disabling of tabs is currently broken, so for
+ // now we'll just remove the tab completely.
+ // gDialog.CellTab.disabled = true;
+ gDialog.CellTab.remove();
+ }
+ }
+
+ // Note: we must use gTableElement, not globalTableElement for these,
+ // thus we should not put this in InitDialog.
+ // Instead, monitor desired counts with separate globals
+ var rowCountObj = { value: 0 };
+ var colCountObj = { value: 0 };
+ try {
+ gActiveEditor.getTableSize(gTableElement, rowCountObj, colCountObj);
+ } catch (e) {}
+
+ gRowCount = rowCountObj.value;
+ gLastRowIndex = gRowCount - 1;
+ gColCount = colCountObj.value;
+ gLastColIndex = gColCount - 1;
+
+ // Set appropriate icons and enable state for the Previous/Next buttons
+ SetSelectionButtons();
+
+ // If only one cell in table, disable change-selection widgets
+ if (gRowCount == 1 && gColCount == 1) {
+ gDialog.SelectionList.setAttribute("disabled", "true");
+ }
+
+ // User can change these via textboxes
+ gNewRowCount = gRowCount;
+ gNewColCount = gColCount;
+
+ // This flag is used to control whether set check state
+ // on "set attribute" checkboxes
+ // (Advanced Edit dialog use calls InitDialog when done)
+ gAdvancedEditUsed = false;
+ InitDialog();
+ gAdvancedEditUsed = true;
+
+ // If first initializing, we really aren't changing anything
+ gCellDataChanged = false;
+
+ SetWindowLocation();
+}
+
+function InitDialog() {
+ // Get Table attributes
+ gDialog.TableRowsInput.value = gRowCount;
+ gDialog.TableColumnsInput.value = gColCount;
+ gDialog.TableWidthInput.value = InitPixelOrPercentMenulist(
+ globalTableElement,
+ gTableElement,
+ "width",
+ "TableWidthUnits",
+ gPercent
+ );
+ if (gUseCSS) {
+ gDialog.TableHeightInput.value = InitPixelOrPercentMenulist(
+ globalTableElement,
+ gTableElement,
+ "height",
+ "TableHeightUnits",
+ gPercent
+ );
+ }
+ gDialog.BorderWidthInput.value = globalTableElement.border;
+ gDialog.SpacingInput.value = globalTableElement.cellSpacing;
+ gDialog.PaddingInput.value = globalTableElement.cellPadding;
+
+ var marginLeft = GetHTMLOrCSSStyleValue(
+ globalTableElement,
+ "align",
+ "margin-left"
+ );
+ var marginRight = GetHTMLOrCSSStyleValue(
+ globalTableElement,
+ "align",
+ "margin-right"
+ );
+ var halign = marginLeft.toLowerCase() + " " + marginRight.toLowerCase();
+ if (halign == "center center" || halign == "auto auto") {
+ gDialog.TableAlignList.value = "center";
+ } else if (halign == "right right" || halign == "auto 0px") {
+ gDialog.TableAlignList.value = "right";
+ } else {
+ // Default is left.
+ gDialog.TableAlignList.value = "left";
+ }
+
+ // Be sure to get caption from table in doc, not the copied "globalTableElement"
+ gTableCaptionElement = gTableElement.caption;
+ if (gTableCaptionElement) {
+ var align = GetHTMLOrCSSStyleValue(
+ gTableCaptionElement,
+ "align",
+ "caption-side"
+ );
+ if (align != "bottom" && align != "left" && align != "right") {
+ align = "top";
+ }
+ gDialog.TableCaptionList.value = align;
+ }
+
+ gTableColor = GetHTMLOrCSSStyleValue(
+ globalTableElement,
+ bgcolor,
+ cssBackgroundColorStr
+ );
+ gTableColor = ConvertRGBColorIntoHEXColor(gTableColor);
+ SetColor("tableBackgroundCW", gTableColor);
+
+ InitCellPanel();
+}
+
+function InitCellPanel() {
+ // Get cell attributes
+ if (globalCellElement) {
+ // This assumes order of items is Cell, Row, Column
+ gDialog.SelectionList.value = gSelectedCellsType;
+
+ var previousValue = gDialog.CellHeightInput.value;
+ gDialog.CellHeightInput.value = InitPixelOrPercentMenulist(
+ globalCellElement,
+ gCellElement,
+ "height",
+ "CellHeightUnits",
+ gPixel
+ );
+ gDialog.CellHeightCheckbox.checked =
+ gAdvancedEditUsed && previousValue != gDialog.CellHeightInput.value;
+
+ previousValue = gDialog.CellWidthInput.value;
+ gDialog.CellWidthInput.value = InitPixelOrPercentMenulist(
+ globalCellElement,
+ gCellElement,
+ "width",
+ "CellWidthUnits",
+ gPixel
+ );
+ gDialog.CellWidthCheckbox.checked =
+ gAdvancedEditUsed && previousValue != gDialog.CellWidthInput.value;
+
+ var previousIndex = gDialog.CellVAlignList.selectedIndex;
+ var valign = GetHTMLOrCSSStyleValue(
+ globalCellElement,
+ "valign",
+ "vertical-align"
+ ).toLowerCase();
+ if (valign == topStr || valign == bottomStr) {
+ gDialog.CellVAlignList.value = valign;
+ } else {
+ // Default is middle.
+ gDialog.CellVAlignList.value = defVAlign;
+ }
+
+ gDialog.CellVAlignCheckbox.checked =
+ gAdvancedEditUsed &&
+ previousIndex != gDialog.CellVAlignList.selectedIndex;
+
+ previousIndex = gDialog.CellHAlignList.selectedIndex;
+
+ gAlignWasChar = false;
+
+ var halign = GetHTMLOrCSSStyleValue(
+ globalCellElement,
+ "align",
+ "text-align"
+ ).toLowerCase();
+ switch (halign) {
+ case centerStr:
+ case rightStr:
+ case justifyStr:
+ gDialog.CellHAlignList.value = halign;
+ break;
+ case charStr:
+ // We don't support UI for this because layout doesn't work: bug 2212.
+ // Remember that's what they had so we don't change it
+ // unless they change the alignment by using the menulist
+ gAlignWasChar = true;
+ // Fall through to use show default alignment in menu
+ default:
+ // Default depends on cell type (TH is "center", TD is "left")
+ gDialog.CellHAlignList.value =
+ globalCellElement.nodeName.toLowerCase() == "th" ? "center" : "left";
+ break;
+ }
+
+ gDialog.CellHAlignCheckbox.checked =
+ gAdvancedEditUsed &&
+ previousIndex != gDialog.CellHAlignList.selectedIndex;
+
+ previousIndex = gDialog.CellStyleList.selectedIndex;
+ gDialog.CellStyleList.value = globalCellElement.nodeName.toLowerCase();
+ gDialog.CellStyleCheckbox.checked =
+ gAdvancedEditUsed && previousIndex != gDialog.CellStyleList.selectedIndex;
+
+ previousIndex = gDialog.TextWrapList.selectedIndex;
+ if (
+ GetHTMLOrCSSStyleValue(globalCellElement, "nowrap", "white-space") ==
+ "nowrap"
+ ) {
+ gDialog.TextWrapList.value = "nowrap";
+ } else {
+ gDialog.TextWrapList.value = "wrap";
+ }
+ gDialog.TextWrapCheckbox.checked =
+ gAdvancedEditUsed && previousIndex != gDialog.TextWrapList.selectedIndex;
+
+ previousValue = gCellColor;
+ gCellColor = GetHTMLOrCSSStyleValue(
+ globalCellElement,
+ bgcolor,
+ cssBackgroundColorStr
+ );
+ gCellColor = ConvertRGBColorIntoHEXColor(gCellColor);
+ SetColor("cellBackgroundCW", gCellColor);
+ gDialog.CellColorCheckbox.checked =
+ gAdvancedEditUsed && previousValue != gCellColor;
+
+ // We want to set this true in case changes came
+ // from Advanced Edit dialog session (must assume something changed)
+ gCellDataChanged = true;
+ }
+}
+
+function GetCellData(rowIndex, colIndex) {
+ // Get actual rowspan and colspan
+ var startRowIndexObj = { value: 0 };
+ var startColIndexObj = { value: 0 };
+ var rowSpanObj = { value: 0 };
+ var colSpanObj = { value: 0 };
+ var actualRowSpanObj = { value: 0 };
+ var actualColSpanObj = { value: 0 };
+ var isSelectedObj = { value: false };
+
+ try {
+ gActiveEditor.getCellDataAt(
+ gTableElement,
+ rowIndex,
+ colIndex,
+ gCellData,
+ startRowIndexObj,
+ startColIndexObj,
+ rowSpanObj,
+ colSpanObj,
+ actualRowSpanObj,
+ actualColSpanObj,
+ isSelectedObj
+ );
+ // We didn't find a cell
+ if (!gCellData.value) {
+ return false;
+ }
+ } catch (ex) {
+ return false;
+ }
+
+ gCellData.startRowIndex = startRowIndexObj.value;
+ gCellData.startColIndex = startColIndexObj.value;
+ gCellData.rowSpan = rowSpanObj.value;
+ gCellData.colSpan = colSpanObj.value;
+ gCellData.actualRowSpan = actualRowSpanObj.value;
+ gCellData.actualColSpan = actualColSpanObj.value;
+ gCellData.isSelected = isSelectedObj.value;
+ return true;
+}
+
+function SelectCellHAlign() {
+ SetCheckbox("CellHAlignCheckbox");
+ // Once user changes the alignment,
+ // we lose their original "CharAt" alignment"
+ gAlignWasChar = false;
+}
+
+function GetColorAndUpdate(ColorWellID) {
+ var colorWell = document.getElementById(ColorWellID);
+ if (!colorWell) {
+ return;
+ }
+
+ var colorObj = {
+ Type: "",
+ TableColor: 0,
+ CellColor: 0,
+ NoDefault: false,
+ Cancel: false,
+ BackgroundColor: 0,
+ };
+
+ switch (ColorWellID) {
+ case "tableBackgroundCW":
+ colorObj.Type = "Table";
+ colorObj.TableColor = gTableColor;
+ break;
+ case "cellBackgroundCW":
+ colorObj.Type = "Cell";
+ colorObj.CellColor = gCellColor;
+ break;
+ }
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ colorObj
+ );
+
+ // User canceled the dialog
+ if (colorObj.Cancel) {
+ return;
+ }
+
+ switch (ColorWellID) {
+ case "tableBackgroundCW":
+ gTableColor = colorObj.BackgroundColor;
+ SetColor(ColorWellID, gTableColor);
+ break;
+ case "cellBackgroundCW":
+ gCellColor = colorObj.BackgroundColor;
+ SetColor(ColorWellID, gCellColor);
+ SetCheckbox("CellColorCheckbox");
+ break;
+ }
+}
+
+function SetColor(ColorWellID, color) {
+ // Save the color
+ if (ColorWellID == "cellBackgroundCW") {
+ if (color) {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(
+ globalCellElement,
+ bgcolor,
+ color,
+ true
+ );
+ } catch (e) {}
+ gDialog.CellInheritColor.collapsed = true;
+ } else {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(
+ globalCellElement,
+ bgcolor,
+ true
+ );
+ } catch (e) {}
+ // Reveal addition message explaining "default" color
+ gDialog.CellInheritColor.collapsed = false;
+ }
+ } else {
+ if (color) {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(
+ globalTableElement,
+ bgcolor,
+ color,
+ true
+ );
+ } catch (e) {}
+ gDialog.TableInheritColor.collapsed = true;
+ } else {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(
+ globalTableElement,
+ bgcolor,
+ true
+ );
+ } catch (e) {}
+ gDialog.TableInheritColor.collapsed = false;
+ }
+ SetCheckbox("CellColorCheckbox");
+ }
+
+ setColorWell(ColorWellID, color);
+}
+
+function ChangeSelectionToFirstCell() {
+ if (!GetCellData(0, 0)) {
+ dump("Can't find first cell in table!\n");
+ return;
+ }
+ gCellElement = gCellData.value;
+ globalCellElement = gCellElement;
+
+ gCurRowIndex = 0;
+ gCurColIndex = 0;
+ ChangeSelection(RESET_SELECTION);
+}
+
+function ChangeSelection(newType) {
+ newType = Number(newType);
+
+ if (gSelectedCellsType == newType) {
+ return;
+ }
+
+ if (newType == RESET_SELECTION) {
+ // Restore selection to existing focus cell
+ gSelection.collapse(gCellElement, 0);
+ } else {
+ gSelectedCellsType = newType;
+ }
+
+ // Keep the same focus gCellElement, just change the type
+ DoCellSelection();
+ SetSelectionButtons();
+
+ // Note: globalCellElement should still be a clone of gCellElement
+}
+
+function MoveSelection(forward) {
+ var newRowIndex = gCurRowIndex;
+ var newColIndex = gCurColIndex;
+ var inRow = false;
+
+ if (gSelectedCellsType == SELECT_ROW) {
+ newRowIndex += forward ? 1 : -1;
+
+ // Wrap around if before first or after last row
+ if (newRowIndex < 0) {
+ newRowIndex = gLastRowIndex;
+ } else if (newRowIndex > gLastRowIndex) {
+ newRowIndex = 0;
+ }
+ inRow = true;
+
+ // Use first cell in row for focus cell
+ newColIndex = 0;
+ } else {
+ // Cell or column:
+ if (!forward) {
+ newColIndex--;
+ }
+
+ if (gSelectedCellsType == SELECT_CELL) {
+ // Skip to next cell
+ if (forward) {
+ newColIndex += gCurColSpan;
+ }
+ } else {
+ // SELECT_COLUMN
+ // Use first cell in column for focus cell
+ newRowIndex = 0;
+
+ // Don't skip by colspan,
+ // but find first cell in next cellmap column
+ if (forward) {
+ newColIndex++;
+ }
+ }
+
+ if (newColIndex < 0) {
+ // Request is before the first cell in column
+
+ // Wrap to last cell in column
+ newColIndex = gLastColIndex;
+
+ if (gSelectedCellsType == SELECT_CELL) {
+ // If moving by cell, also wrap to previous...
+ if (newRowIndex > 0) {
+ newRowIndex -= 1;
+ } else {
+ // ...or the last row.
+ newRowIndex = gLastRowIndex;
+ }
+
+ inRow = true;
+ }
+ } else if (newColIndex > gLastColIndex) {
+ // Request is after the last cell in column
+
+ // Wrap to first cell in column
+ newColIndex = 0;
+
+ if (gSelectedCellsType == SELECT_CELL) {
+ // If moving by cell, also wrap to next...
+ if (newRowIndex < gLastRowIndex) {
+ newRowIndex++;
+ } else {
+ // ...or the first row.
+ newRowIndex = 0;
+ }
+
+ inRow = true;
+ }
+ }
+ }
+
+ // Get the cell at the new location
+ do {
+ if (!GetCellData(newRowIndex, newColIndex)) {
+ dump("MoveSelection: CELL NOT FOUND\n");
+ return;
+ }
+ if (inRow) {
+ if (gCellData.startRowIndex == newRowIndex) {
+ break;
+ } else {
+ // Cell spans from a row above, look for the next cell in row.
+ newRowIndex += gCellData.actualRowSpan;
+ }
+ } else if (gCellData.startColIndex == newColIndex) {
+ break;
+ } else {
+ // Cell spans from a Col above, look for the next cell in column
+ newColIndex += gCellData.actualColSpan;
+ }
+ } while (true);
+
+ // Save data for current selection before changing
+ if (gCellDataChanged) {
+ // && gDialog.ApplyBeforeMove.checked)
+ if (!ValidateCellData()) {
+ return;
+ }
+
+ gActiveEditor.beginTransaction();
+ // Apply changes to all selected cells
+ ApplyCellAttributes();
+ gActiveEditor.endTransaction();
+
+ SetCloseButton();
+ }
+
+ // Set cell and other data for new selection
+ gCellElement = gCellData.value;
+
+ // Save globals for new current cell
+ gCurRowIndex = gCellData.startRowIndex;
+ gCurColIndex = gCellData.startColIndex;
+ gCurColSpan = gCellData.actualColSpan;
+
+ // Copy for new global cell
+ globalCellElement = gCellElement.cloneNode(false);
+
+ // Change the selection
+ DoCellSelection();
+
+ // Scroll page so new selection is visible
+ // Using SELECTION_ANCHOR_REGION makes the upper-left corner of first selected cell
+ // the point to bring into view.
+ try {
+ var selectionController = gActiveEditor.selectionController;
+ selectionController.scrollSelectionIntoView(
+ selectionController.SELECTION_NORMAL,
+ selectionController.SELECTION_ANCHOR_REGION,
+ true
+ );
+ } catch (e) {}
+
+ // Reinitialize dialog using new cell
+ // if (!gDialog.KeepCurrentData.checked)
+ // Setting this false unchecks all "set attributes" checkboxes
+ gAdvancedEditUsed = false;
+ InitCellPanel();
+ gAdvancedEditUsed = true;
+}
+
+function DoCellSelection() {
+ // Collapse selection into to the focus cell
+ // so editor uses that as start cell
+ gSelection.collapse(gCellElement, 0);
+
+ var tagNameObj = { value: "" };
+ var countObj = { value: 0 };
+ try {
+ switch (gSelectedCellsType) {
+ case SELECT_CELL:
+ gActiveEditor.selectTableCell();
+ break;
+ case SELECT_ROW:
+ gActiveEditor.selectTableRow();
+ break;
+ default:
+ gActiveEditor.selectTableColumn();
+ break;
+ }
+ // Get number of cells selected
+ gActiveEditor.getSelectedOrParentTableElement(tagNameObj, countObj);
+ } catch (e) {}
+
+ if (tagNameObj.value == "td") {
+ gSelectedCellCount = countObj.value;
+ } else {
+ gSelectedCellCount = 0;
+ }
+
+ // Currently, we can only allow advanced editing on ONE cell element at a time
+ // else we ignore CSS, JS, and HTML attributes not already in dialog
+ SetElementEnabled(gDialog.AdvancedEditCell, gSelectedCellCount == 1);
+
+ gDialog.AdvancedEditCell.setAttribute(
+ "tooltiptext",
+ gSelectedCellCount > 1
+ ? GetString("AdvancedEditForCellMsg")
+ : gDialog.AdvancedEditCellToolTipText
+ );
+}
+
+function SetSelectionButtons() {
+ if (gSelectedCellsType == SELECT_ROW) {
+ // Trigger CSS to set images of up and down arrows
+ gDialog.PreviousButton.setAttribute("type", "row");
+ gDialog.NextButton.setAttribute("type", "row");
+ } else {
+ // or images of left and right arrows
+ gDialog.PreviousButton.setAttribute("type", "col");
+ gDialog.NextButton.setAttribute("type", "col");
+ }
+ DisableSelectionButtons(
+ (gSelectedCellsType == SELECT_ROW && gRowCount == 1) ||
+ (gSelectedCellsType == SELECT_COLUMN && gColCount == 1) ||
+ (gRowCount == 1 && gColCount == 1)
+ );
+}
+
+function DisableSelectionButtons(disable) {
+ gDialog.PreviousButton.setAttribute("disabled", disable ? "true" : "false");
+ gDialog.NextButton.setAttribute("disabled", disable ? "true" : "false");
+}
+
+function SwitchToValidatePanel() {
+ if (gDialog.TabBox.selectedTab != gValidateTab) {
+ gDialog.TabBox.selectedTab = gValidateTab;
+ }
+}
+
+function SetAlign(listID, defaultValue, element, attName) {
+ var value = document.getElementById(listID).value;
+ if (value == defaultValue) {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(element, attName, true);
+ } catch (e) {}
+ } else {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(element, attName, value, true);
+ } catch (e) {}
+ }
+}
+
+function ValidateTableData() {
+ gValidateTab = gDialog.TableTab;
+ gNewRowCount = Number(
+ ValidateNumber(gDialog.TableRowsInput, null, 1, gMaxRows, null, true, true)
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ gNewColCount = Number(
+ ValidateNumber(
+ gDialog.TableColumnsInput,
+ null,
+ 1,
+ gMaxColumns,
+ null,
+ true,
+ true
+ )
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ // If user is deleting any cells, get confirmation
+ // (This is a global to the dialog and we ask only once per dialog session)
+ if (!gCanDelete && (gNewRowCount < gRowCount || gNewColCount < gColCount)) {
+ if (
+ ConfirmWithTitle(
+ GetString("DeleteTableTitle"),
+ GetString("DeleteTableMsg"),
+ GetString("DeleteCells")
+ )
+ ) {
+ gCanDelete = true;
+ } else {
+ SetTextboxFocus(
+ gNewRowCount < gRowCount
+ ? gDialog.TableRowsInput
+ : gDialog.TableColumnsInput
+ );
+ return false;
+ }
+ }
+
+ ValidateNumber(
+ gDialog.TableWidthInput,
+ gDialog.TableWidthUnits,
+ 1,
+ gMaxTableSize,
+ globalTableElement,
+ "width"
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ if (gUseCSS) {
+ ValidateNumber(
+ gDialog.TableHeightInput,
+ gDialog.TableHeightUnits,
+ 1,
+ gMaxTableSize,
+ globalTableElement,
+ "height"
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ ValidateNumber(
+ gDialog.BorderWidthInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalTableElement,
+ "border"
+ );
+ // TODO: Deal with "BORDER" without value issue
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.SpacingInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalTableElement,
+ "cellspacing"
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ ValidateNumber(
+ gDialog.PaddingInput,
+ null,
+ 0,
+ gMaxPixels,
+ globalTableElement,
+ "cellpadding"
+ );
+ if (gValidationError) {
+ return false;
+ }
+
+ SetAlign("TableAlignList", defHAlign, globalTableElement, "align");
+
+ // Color is set on globalCellElement immediately
+ return true;
+}
+
+function ValidateCellData() {
+ gValidateTab = gDialog.CellTab;
+
+ if (gDialog.CellHeightCheckbox.checked) {
+ ValidateNumber(
+ gDialog.CellHeightInput,
+ gDialog.CellHeightUnits,
+ 1,
+ gMaxTableSize,
+ globalCellElement,
+ "height"
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ if (gDialog.CellWidthCheckbox.checked) {
+ ValidateNumber(
+ gDialog.CellWidthInput,
+ gDialog.CellWidthUnits,
+ 1,
+ gMaxTableSize,
+ globalCellElement,
+ "width"
+ );
+ if (gValidationError) {
+ return false;
+ }
+ }
+
+ if (gDialog.CellHAlignCheckbox.checked) {
+ var hAlign = gDialog.CellHAlignList.value;
+
+ // Horizontal alignment is complicated by "char" type
+ // We don't change current values if user didn't edit alignment
+ if (!gAlignWasChar) {
+ globalCellElement.removeAttribute(charStr);
+
+ // Always set "align" attribute,
+ // so the default "left" is effective in a cell
+ // when parent row has align set.
+ globalCellElement.setAttribute("align", hAlign);
+ }
+ }
+
+ if (gDialog.CellVAlignCheckbox.checked) {
+ // Always set valign (no default in 2nd param) so
+ // the default "middle" is effective in a cell
+ // when parent row has valign set.
+ SetAlign("CellVAlignList", "", globalCellElement, "valign");
+ }
+
+ if (gDialog.TextWrapCheckbox.checked) {
+ if (gDialog.TextWrapList.value == "nowrap") {
+ try {
+ gActiveEditor.setAttributeOrEquivalent(
+ globalCellElement,
+ "nowrap",
+ "nowrap",
+ true
+ );
+ } catch (e) {}
+ } else {
+ try {
+ gActiveEditor.removeAttributeOrEquivalent(
+ globalCellElement,
+ "nowrap",
+ true
+ );
+ } catch (e) {}
+ }
+ }
+
+ return true;
+}
+
+function ValidateData() {
+ var result;
+
+ // Validate current panel first
+ if (gDialog.TabBox.selectedTab == gDialog.TableTab) {
+ result = ValidateTableData();
+ if (result) {
+ result = ValidateCellData();
+ }
+ } else {
+ result = ValidateCellData();
+ if (result) {
+ result = ValidateTableData();
+ }
+ }
+ if (!result) {
+ return false;
+ }
+
+ // Set global element for AdvancedEdit
+ if (gDialog.TabBox.selectedTab == gDialog.TableTab) {
+ globalElement = globalTableElement;
+ } else {
+ globalElement = globalCellElement;
+ }
+
+ return true;
+}
+
+function ChangeCellTextbox(textboxID) {
+ // Filter input for just integers
+ forceInteger(textboxID);
+
+ if (gDialog.TabBox.selectedTab == gDialog.CellTab) {
+ gCellDataChanged = true;
+ }
+}
+
+// Call this when a textbox or menulist is changed
+// so the checkbox is automatically set
+function SetCheckbox(checkboxID) {
+ if (checkboxID && checkboxID.length > 0) {
+ // Set associated checkbox
+ document.getElementById(checkboxID).checked = true;
+ }
+ gCellDataChanged = true;
+}
+
+function ChangeIntTextbox(checkboxID) {
+ // Set associated checkbox
+ SetCheckbox(checkboxID);
+}
+
+function CloneAttribute(destElement, srcElement, attr) {
+ var value = srcElement.getAttribute(attr);
+ // Use editor methods since we are always
+ // modifying a table in the document and
+ // we need transaction system for undo
+ try {
+ if (!value || value.length == 0) {
+ gActiveEditor.removeAttributeOrEquivalent(destElement, attr, false);
+ } else {
+ gActiveEditor.setAttributeOrEquivalent(destElement, attr, value, false);
+ }
+ } catch (e) {}
+}
+
+/* eslint-disable complexity */
+function ApplyTableAttributes() {
+ var newAlign = gDialog.TableCaptionList.value;
+ if (!newAlign) {
+ newAlign = "";
+ }
+
+ if (gTableCaptionElement) {
+ // Get current alignment
+ var align = GetHTMLOrCSSStyleValue(
+ gTableCaptionElement,
+ "align",
+ "caption-side"
+ ).toLowerCase();
+ // This is the default
+ if (!align) {
+ align = "top";
+ }
+
+ if (newAlign == "") {
+ // Remove existing caption
+ try {
+ gActiveEditor.deleteNode(gTableCaptionElement);
+ } catch (e) {}
+ gTableCaptionElement = null;
+ } else if (newAlign != align) {
+ try {
+ if (newAlign == "top") {
+ // This is default, so don't explicitly set it
+ gActiveEditor.removeAttributeOrEquivalent(
+ gTableCaptionElement,
+ "align",
+ false
+ );
+ } else {
+ gActiveEditor.setAttributeOrEquivalent(
+ gTableCaptionElement,
+ "align",
+ newAlign,
+ false
+ );
+ }
+ } catch (e) {}
+ }
+ } else if (newAlign != "") {
+ // Create and insert a caption:
+ try {
+ gTableCaptionElement = gActiveEditor.createElementWithDefaults("caption");
+ } catch (e) {}
+ if (gTableCaptionElement) {
+ if (newAlign != "top") {
+ gTableCaptionElement.setAttribute("align", newAlign);
+ }
+
+ // Insert it into the table - caption is always inserted as first child
+ try {
+ gActiveEditor.insertNode(gTableCaptionElement, gTableElement, 0);
+ } catch (e) {}
+
+ // Put selection back where it was
+ ChangeSelection(RESET_SELECTION);
+ }
+ }
+
+ var countDelta;
+ var foundCell;
+ var i;
+
+ if (gNewRowCount != gRowCount) {
+ countDelta = gNewRowCount - gRowCount;
+ if (gNewRowCount > gRowCount) {
+ // Append new rows
+ // Find first cell in last row
+ if (GetCellData(gLastRowIndex, 0)) {
+ try {
+ // Move selection to the last cell
+ gSelection.collapse(gCellData.value, 0);
+ // Insert new rows after it
+ gActiveEditor.insertTableRow(countDelta, true);
+ gRowCount = gNewRowCount;
+ gLastRowIndex = gRowCount - 1;
+ // Put selection back where it was
+ ChangeSelection(RESET_SELECTION);
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST ROW\n");
+ }
+ }
+ } else if (gCanDelete) {
+ // Delete rows
+ // Find first cell starting in first row we delete
+ var firstDeleteRow = gRowCount + countDelta;
+ foundCell = false;
+ for (i = 0; i <= gLastColIndex; i++) {
+ if (!GetCellData(firstDeleteRow, i)) {
+ // We failed to find a cell.
+ break;
+ }
+
+ if (gCellData.startRowIndex == firstDeleteRow) {
+ foundCell = true;
+ break;
+ }
+ }
+ if (foundCell) {
+ try {
+ // Move selection to the cell we found
+ gSelection.collapse(gCellData.value, 0);
+ gActiveEditor.deleteTableRow(-countDelta);
+ gRowCount = gNewRowCount;
+ gLastRowIndex = gRowCount - 1;
+ if (gCurRowIndex > gLastRowIndex) {
+ // We are deleting our selection
+ // move it to start of table
+ ChangeSelectionToFirstCell();
+ } else {
+ // Put selection back where it was.
+ ChangeSelection(RESET_SELECTION);
+ }
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST ROW\n");
+ }
+ }
+ }
+ }
+
+ if (gNewColCount != gColCount) {
+ countDelta = gNewColCount - gColCount;
+
+ if (gNewColCount > gColCount) {
+ // Append new columns
+ // Find last cell in first column
+ if (GetCellData(0, gLastColIndex)) {
+ try {
+ // Move selection to the last cell
+ gSelection.collapse(gCellData.value, 0);
+ gActiveEditor.insertTableColumn(countDelta, true);
+ gColCount = gNewColCount;
+ gLastColIndex = gColCount - 1;
+ // Restore selection
+ ChangeSelection(RESET_SELECTION);
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST COLUMN\n");
+ }
+ }
+ } else if (gCanDelete) {
+ // Delete columns
+ var firstDeleteCol = gColCount + countDelta;
+ foundCell = false;
+ for (i = 0; i <= gLastRowIndex; i++) {
+ // Find first cell starting in first column we delete
+ if (!GetCellData(i, firstDeleteCol)) {
+ // We failed to find a cell.
+ break;
+ }
+
+ if (gCellData.startColIndex == firstDeleteCol) {
+ foundCell = true;
+ break;
+ }
+ }
+ if (foundCell) {
+ try {
+ // Move selection to the cell we found
+ gSelection.collapse(gCellData.value, 0);
+ gActiveEditor.deleteTableColumn(-countDelta);
+ gColCount = gNewColCount;
+ gLastColIndex = gColCount - 1;
+ if (gCurColIndex > gLastColIndex) {
+ ChangeSelectionToFirstCell();
+ } else {
+ ChangeSelection(RESET_SELECTION);
+ }
+ } catch (ex) {
+ dump("FAILED TO FIND FIRST CELL IN LAST ROW\n");
+ }
+ }
+ }
+ }
+
+ // Clone all remaining attributes to pick up
+ // anything changed by Advanced Edit Dialog
+ try {
+ gActiveEditor.cloneAttributes(gTableElement, globalTableElement);
+ } catch (e) {}
+}
+/* eslint-enable complexity */
+
+function ApplyCellAttributes() {
+ let selectedCells = gActiveEditor.getSelectedCells();
+ if (selectedCells.length == 0) {
+ return;
+ }
+
+ if (selectedCells.length == 1) {
+ let cell = selectedCells[0];
+ // When only one cell is selected, simply clone entire element,
+ // thus CSS and JS from Advanced edit is copied
+
+ gActiveEditor.cloneAttributes(cell, globalCellElement);
+
+ if (gDialog.CellStyleCheckbox.checked) {
+ let currentStyleIndex = cell.nodeName.toLowerCase() == "th" ? 1 : 0;
+ if (gDialog.CellStyleList.selectedIndex != currentStyleIndex) {
+ // Switch cell types
+ // (replaces with new cell and copies attributes and contents)
+ gActiveEditor.switchTableCellHeaderType(cell);
+ }
+ }
+ } else {
+ // Apply changes to all selected cells
+ // XXX THIS DOESN'T COPY ADVANCED EDIT CHANGES!
+ for (let cell of selectedCells) {
+ ApplyAttributesToOneCell(cell);
+ }
+ }
+ gCellDataChanged = false;
+}
+
+function ApplyAttributesToOneCell(destElement) {
+ if (gDialog.CellHeightCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "height");
+ }
+
+ if (gDialog.CellWidthCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "width");
+ }
+
+ if (gDialog.CellHAlignCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "align");
+ CloneAttribute(destElement, globalCellElement, charStr);
+ }
+
+ if (gDialog.CellVAlignCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "valign");
+ }
+
+ if (gDialog.TextWrapCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "nowrap");
+ }
+
+ if (gDialog.CellStyleCheckbox.checked) {
+ var newStyleIndex = gDialog.CellStyleList.selectedIndex;
+ var currentStyleIndex = destElement.nodeName.toLowerCase() == "th" ? 1 : 0;
+
+ if (newStyleIndex != currentStyleIndex) {
+ // Switch cell types
+ // (replaces with new cell and copies attributes and contents)
+ try {
+ destElement = gActiveEditor.switchTableCellHeaderType(destElement);
+ } catch (e) {}
+ }
+ }
+
+ if (gDialog.CellColorCheckbox.checked) {
+ CloneAttribute(destElement, globalCellElement, "bgcolor");
+ }
+}
+
+function SetCloseButton() {
+ // Change text on "Cancel" button after Apply is used
+ if (!gApplyUsed) {
+ document
+ .querySelector("dialog")
+ .setAttribute(
+ "buttonlabelcancel",
+ document.querySelector("dialog").getAttribute("buttonlabelclose")
+ );
+ gApplyUsed = true;
+ }
+}
+
+function Apply() {
+ if (ValidateData()) {
+ gActiveEditor.beginTransaction();
+
+ ApplyTableAttributes();
+
+ // We may have just a table, so check for cell element
+ if (globalCellElement) {
+ ApplyCellAttributes();
+ }
+
+ gActiveEditor.endTransaction();
+
+ SetCloseButton();
+ return true;
+ }
+ return false;
+}
+
+function onAccept(event) {
+ // Do same as Apply and close window if ValidateData succeeded
+ var retVal = Apply();
+ if (retVal) {
+ SaveWindowLocation();
+ } else {
+ event.preventDefault();
+ }
+}
diff --git a/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml b/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml
new file mode 100644
index 0000000000..a82d5e18c5
--- /dev/null
+++ b/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml
@@ -0,0 +1,472 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % edTableProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorTableProperties.dtd">
+%edTableProperties;
+<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd">
+%edDialogOverlay; ]>
+
+<window
+ title="&tableWindow.title;"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ onload="Startup()"
+>
+ <dialog
+ id="tableDlg"
+ buttons="accept,extra1,cancel"
+ buttonlabelclose="&closeButton.label;"
+ buttonlabelextra1="&applyButton.label;"
+ buttonaccesskeyextra1="&applyButton.accesskey;"
+ >
+ <!-- Methods common to all editor dialogs -->
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/messengercompose/editorUtilities.js" />
+ <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" />
+ <script src="chrome://messenger/content/messengercompose/EdTableProps.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <spacer id="location" offsetY="50" persist="offsetX offsetY" />
+
+ <tabbox id="TabBox">
+ <tabs flex="1">
+ <tab id="TableTab" label="&tableTab.label;" />
+ <tab id="CellTab" label="&cellTab.label;" />
+ </tabs>
+ <tabpanels>
+ <!-- TABLE PANEL -->
+ <vbox>
+ <html:fieldset orient="horizontal">
+ <html:legend>&size.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox>
+ <vbox>
+ <hbox align="center" flex="1">
+ <label
+ id="TableRowsLabel"
+ value="&tableRows.label;"
+ accesskey="&tableRows.accessKey;"
+ control="TableRowsInput"
+ />
+ </hbox>
+ <hbox align="center" flex="1">
+ <label
+ id="TableColumnsLabel"
+ value="&tableColumns.label;"
+ accesskey="&tableColumns.accessKey;"
+ control="TableColumnsInput"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <html:input
+ id="TableRowsInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableRowsLabel"
+ />
+ <html:input
+ id="TableColumnsInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableColumnsLabel"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ <vbox>
+ <html:div class="grid-three-column">
+ <html:div class="flex-items-center">
+ <label
+ id="TableHeightLabel"
+ value="&tableHeight.label;"
+ accesskey="&tableHeight.accessKey;"
+ control="TableHeightInput"
+ />
+ </html:div>
+ <html:div>
+ <html:input
+ id="TableHeightInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableHeightLabel"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <menulist id="TableHeightUnits" />
+ </html:div>
+ <html:div class="flex-items-center">
+ <label
+ id="TableWidthLabel"
+ value="&tableWidth.label;"
+ accesskey="&tableWidth.accessKey;"
+ control="TableWidthInput"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <html:input
+ id="TableWidthInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="TableWidthLabel"
+ />
+ </html:div>
+ <html:div class="flex-items-center">
+ <menulist id="TableWidthUnits" />
+ </html:div>
+ </html:div>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ <html:fieldset>
+ <html:legend>&tableBorderSpacing.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <label
+ id="BorderWidthLabel"
+ control="BorderWidthInput"
+ value="&tableBorderWidth.label;"
+ accesskey="&tableBorderWidth.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="SpacingLabel"
+ control="SpacingInput"
+ value="&tableSpacing.label;"
+ accesskey="&tableSpacing.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="PaddingLabel"
+ control="PaddingInput"
+ value="&tablePadding.label;"
+ accesskey="&tablePadding.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <html:input
+ id="BorderWidthInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="BorderWidthLabel"
+ />
+ <html:input
+ id="SpacingInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="SpacingLabel"
+ />
+ <html:input
+ id="PaddingInput"
+ type="number"
+ class="narrow input-inline"
+ aria-labelledby="PaddingLabel"
+ />
+ </vbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <label align="start" value="&pixels.label;" />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label value="&tablePxBetwCells.label;" />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label value="&tablePxBetwBrdrCellContent.label;" />
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ <!-- Table Alignment and Caption -->
+ <hbox flex="1" align="center">
+ <label
+ control="TableAlignList"
+ value="&tableAlignment.label;"
+ accesskey="&tableAlignment.accessKey;"
+ />
+ <menulist id="TableAlignList">
+ <menupopup>
+ <menuitem label="&AlignLeft.label;" value="left" />
+ <menuitem label="&AlignCenter.label;" value="center" />
+ <menuitem label="&AlignRight.label;" value="right" />
+ </menupopup>
+ </menulist>
+ <spacer class="spacer" />
+ <label
+ control="TableCaptionList"
+ value="&tableCaption.label;"
+ accesskey="&tableCaption.accessKey;"
+ />
+ <menulist id="TableCaptionList">
+ <menupopup>
+ <menuitem label="&tableCaptionNone.label;" value="" />
+ <menuitem label="&tableCaptionAbove.label;" value="top" />
+ <menuitem label="&tableCaptionBelow.label;" value="bottom" />
+ <menuitem label="&tableCaptionLeft.label;" value="left" />
+ <menuitem label="&tableCaptionRight.label;" value="right" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator class="groove" />
+ <hbox align="center">
+ <label value="&backgroundColor.label;" />
+ <button
+ id="tableBackground"
+ class="color-button"
+ oncommand="GetColorAndUpdate('tableBackgroundCW');"
+ >
+ <spacer id="tableBackgroundCW" class="color-well" />
+ </button>
+ <spacer class="spacer" />
+ <label
+ id="TableInheritColor"
+ value="&tableInheritColor.label;"
+ collapsed="true"
+ />
+ </hbox>
+ <separator class="groove" />
+ <hbox flex="1" align="center">
+ <spacer flex="1" />
+ <button
+ id="AdvancedEditButton"
+ oncommand="onAdvancedEdit();"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <spacer flex="1" /> </vbox
+ ><!-- Table Panel -->
+
+ <!-- CELL PANEL -->
+ <vbox>
+ <html:fieldset>
+ <html:legend>&cellSelection.label;</html:legend>
+ <vbox>
+ <menulist
+ id="SelectionList"
+ oncommand="ChangeSelection(event.target.value)"
+ >
+ <menupopup>
+ <!-- JS code assumes order is Cell, Row, Column -->
+ <menuitem label="&cellSelectCell.label;" value="1" />
+ <menuitem label="&cellSelectRow.label;" value="2" />
+ <menuitem label="&cellSelectColumn.label;" value="3" />
+ </menupopup>
+ </menulist>
+ <hbox>
+ <button
+ id="PreviousButton"
+ label="&cellSelectPrevious.label;"
+ accesskey="&cellSelectPrevious.accessKey;"
+ oncommand="MoveSelection(0)"
+ />
+ <button
+ id="NextButton"
+ label="&cellSelectNext.label;"
+ accesskey="&cellSelectNext.accessKey;"
+ oncommand="MoveSelection(1)"
+ />
+ </hbox>
+ <hbox flex="1"> &applyBeforeChange.label; </hbox>
+ </vbox>
+ </html:fieldset>
+
+ <separator class="groove" />
+
+ <hbox align="center">
+ <html:fieldset>
+ <html:legend>&size.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <checkbox
+ id="CellHeightCheckbox"
+ label="&tableHeight.label;"
+ accesskey="&tableHeight.accessKey;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <checkbox
+ id="CellWidthCheckbox"
+ label="&tableWidth.label;"
+ accesskey="&tableWidth.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <hbox flex="1" align="center">
+ <html:input
+ id="CellHeightInput"
+ type="number"
+ class="narrow input-inline"
+ onchange="ChangeIntTextbox('CellHeightCheckbox');"
+ aria-labelledby="CellHeightCheckbox"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <html:input
+ id="CellWidthInput"
+ type="number"
+ class="narrow input-inline"
+ onchange="ChangeIntTextbox('CellWidthCheckbox');"
+ aria-labelledby="CellWidthCheckbox"
+ />
+ </hbox>
+ </vbox>
+ <vbox>
+ <hbox flex="1" align="center">
+ <menulist
+ id="CellHeightUnits"
+ oncommand="SetCheckbox('CellHeightCheckbox');"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <menulist
+ id="CellWidthUnits"
+ oncommand="SetCheckbox('CellWidthCheckbox');"
+ />
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ <html:fieldset>
+ <html:legend>&cellContentAlignment.label;</html:legend>
+ <hbox>
+ <vbox>
+ <hbox align="center" flex="1">
+ <checkbox
+ id="CellVAlignCheckbox"
+ label="&cellVertical.label;"
+ accesskey="&cellVertical.accessKey;"
+ />
+ </hbox>
+ <hbox align="center" flex="1">
+ <checkbox
+ id="CellHAlignCheckbox"
+ label="&cellHorizontal.label;"
+ accesskey="&cellHorizontal.accessKey;"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <menulist
+ id="CellVAlignList"
+ oncommand="SetCheckbox('CellVAlignCheckbox');"
+ >
+ <menupopup>
+ <menuitem label="&cellAlignTop.label;" value="top" />
+ <menuitem
+ label="&cellAlignMiddle.label;"
+ value="middle"
+ />
+ <menuitem
+ label="&cellAlignBottom.label;"
+ value="bottom"
+ />
+ </menupopup>
+ </menulist>
+ <menulist id="CellHAlignList" oncommand="SelectCellHAlign()">
+ <menupopup>
+ <menuitem label="&AlignLeft.label;" value="left" />
+ <menuitem label="&AlignCenter.label;" value="center" />
+ <menuitem label="&AlignRight.label;" value="right" />
+ <menuitem
+ label="&cellAlignJustify.label;"
+ value="justify"
+ />
+ </menupopup>
+ </menulist>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </hbox>
+ <spacer class="spacer" />
+ <hbox align="center">
+ <checkbox
+ id="CellStyleCheckbox"
+ label="&cellStyle.label;"
+ accesskey="&cellStyle.accessKey;"
+ />
+ <menulist
+ id="CellStyleList"
+ oncommand="SetCheckbox('CellStyleCheckbox');"
+ >
+ <menupopup>
+ <menuitem label="&cellNormal.label;" value="td" />
+ <menuitem label="&cellHeader.label;" value="th" />
+ </menupopup>
+ </menulist>
+ <spacer flex="1" />
+ <checkbox
+ id="TextWrapCheckbox"
+ label="&cellTextWrap.label;"
+ accesskey="&cellTextWrap.accessKey;"
+ />
+ <menulist
+ id="TextWrapList"
+ oncommand="SetCheckbox('TextWrapCheckbox');"
+ >
+ <menupopup>
+ <menuitem label="&cellWrap.label;" value="wrap" />
+ <menuitem label="&cellNoWrap.label;" value="nowrap" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator class="groove" />
+ <hbox align="center">
+ <checkbox
+ id="CellColorCheckbox"
+ label="&backgroundColor.label;"
+ accesskey="&backgroundColor.accessKey;"
+ />
+ <button
+ class="color-button"
+ oncommand="GetColorAndUpdate('cellBackgroundCW');"
+ >
+ <spacer id="cellBackgroundCW" class="color-well" />
+ </button>
+ <spacer class="spacer" />
+ <label
+ id="CellInheritColor"
+ value="&cellInheritColor.label;"
+ collapsed="true"
+ />
+ </hbox>
+ <separator class="groove" />
+ <hbox align="center">
+ <description class="wrap" flex="1" style="width: 1em"
+ >&cellUseCheckboxHelp.label;</description
+ >
+ <button
+ id="AdvancedEditButton2"
+ oncommand="onAdvancedEdit()"
+ label="&AdvancedEditButton.label;"
+ accesskey="&AdvancedEditButton.accessKey;"
+ tooltiptext="&AdvancedEditButton.tooltip;"
+ />
+ </hbox>
+ <spacer flex="1" /> </vbox
+ ><!-- Cell Panel -->
+ </tabpanels>
+ </tabbox>
+ <spacer class="spacer" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/compose/content/editFormatButtons.inc.xhtml b/comm/mail/components/compose/content/editFormatButtons.inc.xhtml
new file mode 100644
index 0000000000..f84b2610e6
--- /dev/null
+++ b/comm/mail/components/compose/content/editFormatButtons.inc.xhtml
@@ -0,0 +1,282 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ <!-- Formatting toolbar items. "value" are HTML tagnames, don't translate -->
+ <menulist id="ParagraphSelect"
+ class="toolbar-focustarget"
+ oncommand="setParagraphState(event);"
+ crop="end"
+ tooltiptext="&ParagraphSelect.tooltip;"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="ParagraphPopup">
+ <menuitem id="toolbarmenu_bodyText" label="&bodyTextCmd.label;" value=""/>
+ <menuitem id="toolbarmenu_paragraph" label="&paragraphParagraphCmd.label;" value="p"/>
+ <menuitem id="toolbarmenu_h1" label="&heading1Cmd.label;" value="h1"/>
+ <menuitem id="toolbarmenu_h2" label="&heading2Cmd.label;" value="h2"/>
+ <menuitem id="toolbarmenu_h3" label="&heading3Cmd.label;" value="h3"/>
+ <menuitem id="toolbarmenu_h4" label="&heading4Cmd.label;" value="h4"/>
+ <menuitem id="toolbarmenu_h5" label="&heading5Cmd.label;" value="h5"/>
+ <menuitem id="toolbarmenu_h6" label="&heading6Cmd.label;" value="h6"/>
+ <menuitem id="toolbarmenu_address" label="&paragraphAddressCmd.label;" value="address"/>
+ <menuitem id="toolbarmenu_pre" label="&paragraphPreformatCmd.label;" value="pre"/>
+ </menupopup>
+ </menulist>
+
+ <!-- "value" are HTML tagnames, don't translate -->
+ <menulist id="FontFaceSelect"
+ class="toolbar-focustarget"
+ oncommand="doStatefulCommand('cmd_fontFace', event.target.value)"
+ crop="center"
+ sizetopopup="pref"
+ tooltiptext="&FontFaceSelect.tooltip;"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="FontFacePopup">
+ <menuitem id="toolbarmenu_fontVarWidth" label="&fontVarWidth.label;" value=""/>
+ <menuitem id="toolbarmenu_fontFixedWidth" label="&fontFixedWidth.label;" value="monospace"/>
+ <menuseparator id="toolbarmenuAfterGenericFontsSeparator"/>
+ <menuitem id="toolbarmenu_fontHelvetica" label="&fontHelvetica.label;"
+ value="Helvetica, Arial, sans-serif"
+ value_parsed="helvetica,arial,sans-serif"/>
+ <menuitem id="toolbarmenu_fontTimes" label="&fontTimes.label;"
+ value="Times New Roman, Times, serif"
+ value_parsed="times new roman,times,serif"/>
+ <menuitem id="toolbarmenu_fontCourier" label="&fontCourier.label;"
+ value="Courier New, Courier, monospace"
+ value_parsed="courier new,courier,monospace"/>
+ <menuseparator id="toolbarmenuAfterDefaultFontsSeparator"
+ class="fontFaceMenuAfterDefaultFonts"/>
+ <menuseparator id="toolbarmenuAfterUsedFontsSeparator"
+ class="fontFaceMenuAfterUsedFonts"
+ hidden="true"/>
+ <!-- Local font face items added here by initLocalFontFaceMenu() -->
+ </menupopup>
+ </menulist>
+
+ <toolbaritem id="color-buttons-container"
+ class="formatting-button"
+ align="center">
+ <stack id="ColorButtons">
+ <box class="color-button" id="BackgroundColorButton"
+ onclick="if (!this.hasAttribute('disabled') || this.getAttribute('disabled') != 'true') { EditorSelectColor('', event); }"
+ tooltiptext="&BackgroundColorButton.tooltip;"
+ observes="cmd_backgroundColor"
+ oncommand="/* See MsgComposeCommands.js::updateAllItems for why this attribute is needed here. */"/>
+ <box class="color-button" id="TextColorButton"
+ onclick="if (!this.hasAttribute('disabled') || this.getAttribute('disabled') != 'true') { EditorSelectColor('Text', event); }"
+ tooltiptext="&TextColorButton.tooltip;"
+ observes="cmd_fontColor"
+ oncommand="/* See MsgComposeCommands.js::updateAllItems for why this attribute is needed here. */"/>
+ </stack>
+ </toolbaritem>
+
+ <toolbarbutton id="AbsoluteFontSizeButton"
+ class="formatting-button"
+ tooltiptext="&absoluteFontSizeToolbarCmd.tooltip;"
+ type="menu"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="AbsoluteFontSizeButtonPopup"
+ onpopupshowing="initFontSizeMenu(this);"
+ oncommand="setFontSize(event)">
+ <menuitem id="toobarmenu_fontSize_x-small"
+ label="&size-tinyCmd.label;"
+ type="radio" name="fontSize"
+ value="1"/>
+ <menuitem id="toobarmenu_fontSize_small"
+ label="&size-smallCmd.label;"
+ type="radio" name="fontSize"
+ value="2"/>
+ <menuitem id="toobarmenu_fontSize_medium"
+ label="&size-mediumCmd.label;"
+ type="radio" name="fontSize"
+ value="3"/>
+ <menuitem id="toobarmenu_fontSize_large"
+ label="&size-largeCmd.label;"
+ type="radio" name="fontSize"
+ value="4"/>
+ <menuitem id="toobarmenu_fontSize_x-large"
+ label="&size-extraLargeCmd.label;"
+ type="radio" name="fontSize"
+ value="5"/>
+ <menuitem id="toobarmenu_fontSize_xx-large"
+ label="&size-hugeCmd.label;"
+ type="radio" name="fontSize"
+ value="6"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="DecreaseFontSizeButton"
+ class="formatting-button"
+ tooltiptext="&decreaseFontSizeToolbarCmd.tooltip;"
+ observes="cmd_decreaseFontStep"/>
+
+ <toolbarbutton id="IncreaseFontSizeButton"
+ class="formatting-button"
+ tooltiptext="&increaseFontSizeToolbarCmd.tooltip;"
+ observes="cmd_increaseFontStep"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="boldButton"
+ class="formatting-button"
+ tooltiptext="&boldToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_bold"/>
+
+ <toolbarbutton id="italicButton"
+ class="formatting-button"
+ tooltiptext="&italicToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_italic"/>
+
+ <toolbarbutton id="underlineButton"
+ class="formatting-button"
+ tooltiptext="&underlineToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_underline"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="removeStylingButton"
+ class="formatting-button"
+ data-l10n-id="compose-tool-button-remove-text-styling"
+ observes="cmd_removeStyles"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="ulButton"
+ class="formatting-button"
+ tooltiptext="&bulletListToolbarCmd.tooltip;"
+ type="radio"
+ group="lists"
+ autoCheck="false"
+ observes="cmd_ul"/>
+
+ <toolbarbutton id="olButton"
+ class="formatting-button"
+ tooltiptext="&numberListToolbarCmd.tooltip;"
+ type="radio"
+ group="lists"
+ autoCheck="false"
+ observes="cmd_ol"/>
+
+ <toolbarbutton id="outdentButton"
+ class="formatting-button"
+ tooltiptext="&outdentToolbarCmd.tooltip;"
+ observes="cmd_outdent"/>
+
+ <toolbarbutton id="indentButton"
+ class="formatting-button"
+ tooltiptext="&indentToolbarCmd.tooltip;"
+ observes="cmd_indent"/>
+
+ <toolbarseparator class="toolbarseparator-standard"/>
+
+ <toolbarbutton id="AlignPopupButton"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&AlignPopupButton.tooltip;"
+ observes="cmd_align">
+ <menupopup id="AlignPopup">
+ <menuitem id="AlignLeftItem" class="menuitem-iconic" label="&alignLeft.label;"
+ oncommand="doStatefulCommand('cmd_align', 'left')"
+ tooltiptext="&alignLeftButton.tooltip;" />
+ <menuitem id="AlignCenterItem" class="menuitem-iconic" label="&alignCenter.label;"
+ oncommand="doStatefulCommand('cmd_align', 'center')"
+ tooltiptext="&alignCenterButton.tooltip;" />
+ <menuitem id="AlignRightItem" class="menuitem-iconic" label="&alignRight.label;"
+ oncommand="doStatefulCommand('cmd_align', 'right')"
+ tooltiptext="&alignRightButton.tooltip;" />
+ <menuitem id="AlignJustifyItem" class="menuitem-iconic" label="&alignJustify.label;"
+ oncommand="doStatefulCommand('cmd_align', 'justify')"
+ tooltiptext="&alignJustifyButton.tooltip;" />
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- InsertPopupButton is used by messengercompose.xhtml -->
+ <toolbarbutton id="InsertPopupButton"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&InsertPopupButton.tooltip;"
+ observes="cmd_renderedHTMLEnabler">
+ <menupopup id="InsertPopup">
+ <menuitem id="InsertLinkItem" class="menuitem-iconic" observes="cmd_link"
+ oncommand="goDoCommand('cmd_link')" label="&linkToolbarCmd.label;"
+ tooltiptext="&linkToolbarCmd.tooltip;" />
+ <menuitem id="InsertAnchorItem" class="menuitem-iconic" observes="cmd_anchor"
+ oncommand="goDoCommand('cmd_anchor')" label="&anchorToolbarCmd.label;"
+ tooltiptext="&anchorToolbarCmd.tooltip;" />
+ <menuitem id="InsertImageItem" class="menuitem-iconic" observes="cmd_image"
+ oncommand="goDoCommand('cmd_image')" label="&imageToolbarCmd.label;"
+ tooltiptext="&imageToolbarCmd.tooltip;" />
+ <menuitem id="InsertHRuleItem" class="menuitem-iconic" observes="cmd_hline"
+ oncommand="goDoCommand('cmd_hline')" label="&hruleToolbarCmd.label;"
+ tooltiptext="&hruleToolbarCmd.tooltip;" />
+ <menuitem id="InsertTableItem" class="menuitem-iconic" observes="cmd_table"
+ oncommand="goDoCommand('cmd_table')" label="&tableToolbarCmd.label;"
+ tooltiptext="&tableToolbarCmd.tooltip;" />
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="smileButtonMenu"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&SmileButton.tooltip;"
+ observes="cmd_smiley">
+ <menupopup id="smileyPopup" class="no-icon-menupopup">
+ <menuitem id="smileySmile" class="menuitem-iconic"
+ label="&#128578; &smiley1Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128578;')"/>
+ <menuitem id="smileyFrown" class="menuitem-iconic"
+ label="&#128577; &smiley2Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128577;')"/>
+ <menuitem id="smileyWink" class="menuitem-iconic"
+ label="&#128521; &smiley3Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128521;')"/>
+ <menuitem id="smileyTongue" class="menuitem-iconic"
+ label="&#128539; &smiley4Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128539;')"/>
+ <menuitem id="smileyLaughing" class="menuitem-iconic"
+ label="&#128514; &smiley5Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128514;')"/>
+ <menuitem id="smileyEmbarassed" class="menuitem-iconic"
+ label="&#128563; &smiley6Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128563;')"/>
+ <menuitem id="smileyUndecided" class="menuitem-iconic"
+ label="&#128533; &smiley7Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128533;')"/>
+ <menuitem id="smileySurprise" class="menuitem-iconic"
+ label="&#128558; &smiley8Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128558;')"/>
+ <menuitem id="smileyKiss" class="menuitem-iconic"
+ label="&#128536; &smiley9Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128536;')"/>
+ <menuitem id="smileyYell" class="menuitem-iconic"
+ label="&#128544; &smiley10Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128544;')"/>
+ <menuitem id="smileyCool" class="menuitem-iconic"
+ label="&#128526; &smiley11Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128526;')"/>
+ <menuitem id="smileyMoney" class="menuitem-iconic"
+ label="&#129297; &smiley12Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#129297;')"/>
+ <menuitem id="smileyFoot" class="menuitem-iconic"
+ label="&#128556; &smiley13Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128556;')"/>
+ <menuitem id="smileyInnocent" class="menuitem-iconic"
+ label="&#128519; &smiley14Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128519;')"/>
+ <menuitem id="smileyCry" class="menuitem-iconic"
+ label="&#128557; &smiley15Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128557;')"/>
+ <menuitem id="smileySealed" class="menuitem-iconic"
+ label="&#129296; &smiley16Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#129296;')"/>
+ </menupopup>
+ </toolbarbutton>
diff --git a/comm/mail/components/compose/content/editor.js b/comm/mail/components/compose/content/editor.js
new file mode 100644
index 0000000000..7535ecb0d1
--- /dev/null
+++ b/comm/mail/components/compose/content/editor.js
@@ -0,0 +1,2392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../../toolkit/content/viewZoomOverlay.js */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from ComposerCommands.js */
+/* import-globals-from editorUtilities.js */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/* Main Composer window UI control */
+
+var gComposerWindowControllerID = 0;
+var prefAuthorString = "";
+
+var kDisplayModeNormal = 0;
+var kDisplayModeAllTags = 1;
+var kDisplayModeSource = 2;
+var kDisplayModePreview = 3;
+
+const kDisplayModeMenuIDs = [
+ "viewNormalMode",
+ "viewAllTagsMode",
+ "viewSourceMode",
+ "viewPreviewMode",
+];
+const kDisplayModeTabIDS = [
+ "NormalModeButton",
+ "TagModeButton",
+ "SourceModeButton",
+ "PreviewModeButton",
+];
+const kNormalStyleSheet = "chrome://messenger/skin/shared/editorContent.css";
+const kContentEditableStyleSheet = "resource://gre/res/contenteditable.css";
+
+var kTextMimeType = "text/plain";
+var kHTMLMimeType = "text/html";
+var kXHTMLMimeType = "application/xhtml+xml";
+
+var gPreviousNonSourceDisplayMode = 1;
+var gEditorDisplayMode = -1;
+var gDocWasModified = false; // Check if clean document, if clean then unload when user "Opens"
+var gContentWindow = 0;
+var gSourceContentWindow = 0;
+var gSourceTextEditor = null;
+var gContentWindowDeck;
+var gFormatToolbar;
+var gFormatToolbarHidden = false;
+var gViewFormatToolbar;
+var gChromeState;
+var gColorObj = {
+ LastTextColor: "",
+ LastBackgroundColor: "",
+ LastHighlightColor: "",
+ Type: "",
+ SelectedType: "",
+ NoDefault: false,
+ Cancel: false,
+ HighlightColor: "",
+ BackgroundColor: "",
+ PageColor: "",
+ TextColor: "",
+ TableColor: "",
+ CellColor: "",
+};
+var gDefaultTextColor = "";
+var gDefaultBackgroundColor = "";
+var gCSSPrefListener;
+var gEditorToolbarPrefListener;
+var gReturnInParagraphPrefListener;
+var gLocalFonts = null;
+
+var gLastFocusNode = null;
+var gLastFocusNodeWasSelected = false;
+
+// These must be kept in synch with the XUL <options> lists
+var gFontSizeNames = [
+ "xx-small",
+ "x-small",
+ "small",
+ "medium",
+ "large",
+ "x-large",
+ "xx-large",
+];
+
+var kUseCssPref = "editor.use_css";
+var kCRInParagraphsPref = "editor.CR_creates_new_p";
+
+// This should be called by all editor users when they close their window.
+function EditorCleanup() {
+ SwitchInsertCharToAnotherEditorOrClose();
+}
+
+/** @implements {nsIDocumentStateListener} */
+var DocumentReloadListener = {
+ NotifyDocumentWillBeDestroyed() {},
+
+ NotifyDocumentStateChanged(isNowDirty) {
+ var editor = GetCurrentEditor();
+ try {
+ // unregister the listener to prevent multiple callbacks
+ editor.removeDocumentStateListener(DocumentReloadListener);
+
+ var charset = editor.documentCharacterSet;
+
+ // update the META charset with the current presentation charset
+ editor.documentCharacterSet = charset;
+ } catch (e) {}
+ },
+};
+
+// implements nsIObserver
+var gEditorDocumentObserver = {
+ observe(aSubject, aTopic, aData) {
+ // Should we allow this even if NOT the focused editor?
+ var commandManager = GetCurrentCommandManager();
+ if (commandManager != aSubject) {
+ return;
+ }
+
+ var editor = GetCurrentEditor();
+ switch (aTopic) {
+ case "obs_documentCreated":
+ // Just for convenience
+ gContentWindow = window.content;
+
+ // Get state to see if document creation succeeded
+ var params = newCommandParams();
+ if (!params) {
+ return;
+ }
+
+ try {
+ commandManager.getCommandState(aTopic, gContentWindow, params);
+ var errorStringId = 0;
+ var editorStatus = params.getLongValue("state_data");
+ if (!editor && editorStatus == Ci.nsIEditingSession.eEditorOK) {
+ dump(
+ "\n ****** NO EDITOR BUT NO EDITOR ERROR REPORTED ******* \n\n"
+ );
+ editorStatus = Ci.nsIEditingSession.eEditorErrorUnknown;
+ }
+
+ switch (editorStatus) {
+ case Ci.nsIEditingSession.eEditorErrorCantEditFramesets:
+ errorStringId = "CantEditFramesetMsg";
+ break;
+ case Ci.nsIEditingSession.eEditorErrorCantEditMimeType:
+ errorStringId = "CantEditMimeTypeMsg";
+ break;
+ case Ci.nsIEditingSession.eEditorErrorUnknown:
+ errorStringId = "CantEditDocumentMsg";
+ break;
+ // Note that for "eEditorErrorFileNotFound,
+ // network code popped up an alert dialog, so we don't need to
+ }
+ if (errorStringId) {
+ Services.prompt.alert(window, "", GetString(errorStringId));
+ }
+ } catch (e) {
+ dump("EXCEPTION GETTING obs_documentCreated state " + e + "\n");
+ }
+
+ // We have a bad editor -- nsIEditingSession will rebuild an editor
+ // with a blank page, so simply abort here
+ if (editorStatus) {
+ return;
+ }
+
+ if (!("InsertCharWindow" in window)) {
+ window.InsertCharWindow = null;
+ }
+
+ let domWindowUtils =
+ GetCurrentEditorElement().contentWindow.windowUtils;
+ // And extra styles for showing anchors, table borders, smileys, etc.
+ domWindowUtils.loadSheetUsingURIString(
+ kNormalStyleSheet,
+ domWindowUtils.AGENT_SHEET
+ );
+
+ // Remove contenteditable stylesheets if they were applied by the
+ // editingSession.
+ domWindowUtils.removeSheetUsingURIString(
+ kContentEditableStyleSheet,
+ domWindowUtils.AGENT_SHEET
+ );
+
+ // Add mouse click watcher if right type of editor
+ if (IsHTMLEditor()) {
+ // Force color widgets to update
+ onFontColorChange();
+ onBackgroundColorChange();
+ }
+ break;
+
+ case "cmd_setDocumentModified":
+ window.updateCommands("save");
+ break;
+
+ case "obs_documentWillBeDestroyed":
+ dump("obs_documentWillBeDestroyed notification\n");
+ break;
+
+ case "obs_documentLocationChanged":
+ // Ignore this when editor doesn't exist,
+ // which happens once when page load starts
+ if (editor) {
+ try {
+ editor.updateBaseURL();
+ } catch (e) {
+ dump(e);
+ }
+ }
+ break;
+
+ case "cmd_bold":
+ // Update all style items
+ // cmd_bold is a proxy; see EditorSharedStartup (above) for details
+ window.updateCommands("style");
+ window.updateCommands("undo");
+ break;
+ }
+ },
+};
+
+function SetFocusOnStartup() {
+ gContentWindow.focus();
+}
+
+function EditorLoadUrl(url) {
+ try {
+ if (url) {
+ let loadURIOptions = {
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ GetCurrentEditorElement().webNavigation.fixupAndLoadURIString(
+ url,
+ loadURIOptions
+ );
+ }
+ } catch (e) {
+ dump(" EditorLoadUrl failed: " + e + "\n");
+ }
+}
+
+// This should be called by all Composer types
+function EditorSharedStartup() {
+ // Just for convenience
+ gContentWindow = window.content;
+
+ // Disable DNS Prefetching on the docshell - we don't need it for composer
+ // type windows.
+ GetCurrentEditorElement().docShell.allowDNSPrefetch = false;
+
+ let messageEditorBrowser = GetCurrentEditorElement();
+ messageEditorBrowser.addEventListener(
+ "DoZoomEnlargeBy10",
+ () => {
+ ZoomManager.scrollZoomEnlarge(messageEditorBrowser);
+ },
+ true
+ );
+ messageEditorBrowser.addEventListener(
+ "DoZoomReduceBy10",
+ () => {
+ ZoomManager.scrollReduceEnlarge(messageEditorBrowser);
+ },
+ true
+ );
+
+ // Set up the mime type and register the commands.
+ if (IsHTMLEditor()) {
+ SetupHTMLEditorCommands();
+ } else {
+ SetupTextEditorCommands();
+ }
+
+ // add observer to be called when document is really done loading
+ // and is modified
+ // Note: We're really screwed if we fail to install this observer!
+ try {
+ var commandManager = GetCurrentCommandManager();
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "obs_documentCreated"
+ );
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "cmd_setDocumentModified"
+ );
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "obs_documentWillBeDestroyed"
+ );
+ commandManager.addCommandObserver(
+ gEditorDocumentObserver,
+ "obs_documentLocationChanged"
+ );
+
+ // Until nsIControllerCommandGroup-based code is implemented,
+ // we will observe just the bold command to trigger update of
+ // all toolbar style items
+ commandManager.addCommandObserver(gEditorDocumentObserver, "cmd_bold");
+ } catch (e) {
+ dump(e);
+ }
+
+ var isMac = AppConstants.platform == "macosx";
+
+ // Set platform-specific hints for how to select cells
+ // Mac uses "Cmd", all others use "Ctrl"
+ var tableKey = GetString(isMac ? "XulKeyMac" : "TableSelectKey");
+ var dragStr = tableKey + GetString("Drag");
+ var clickStr = tableKey + GetString("Click");
+
+ var delStr = GetString(isMac ? "Clear" : "Del");
+
+ SafeSetAttribute("menu_SelectCell", "acceltext", clickStr);
+ SafeSetAttribute("menu_SelectRow", "acceltext", dragStr);
+ SafeSetAttribute("menu_SelectColumn", "acceltext", dragStr);
+ SafeSetAttribute("menu_SelectAllCells", "acceltext", dragStr);
+ // And add "Del" or "Clear"
+ SafeSetAttribute("menu_DeleteCellContents", "acceltext", delStr);
+
+ // Set text for indent, outdent keybinding
+
+ // hide UI that we don't have components for
+ RemoveInapplicableUIElements();
+
+ // Use browser colors as initial values for editor's default colors
+ var BrowserColors = GetDefaultBrowserColors();
+ if (BrowserColors) {
+ gDefaultTextColor = BrowserColors.TextColor;
+ gDefaultBackgroundColor = BrowserColors.BackgroundColor;
+ }
+
+ // For new window, no default last-picked colors
+ gColorObj.LastTextColor = "";
+ gColorObj.LastBackgroundColor = "";
+ gColorObj.LastHighlightColor = "";
+}
+
+function SafeSetAttribute(nodeID, attributeName, attributeValue) {
+ var theNode = document.getElementById(nodeID);
+ if (theNode) {
+ theNode.setAttribute(attributeName, attributeValue);
+ }
+}
+
+async function CheckAndSaveDocument(command, allowDontSave) {
+ var document;
+ try {
+ // if we don't have an editor or an document, bail
+ var editor = GetCurrentEditor();
+ document = editor.document;
+ if (!document) {
+ return true;
+ }
+ } catch (e) {
+ return true;
+ }
+
+ if (!IsDocumentModified() && !IsHTMLSourceChanged()) {
+ return true;
+ }
+
+ // call window.focus, since we need to pop up a dialog
+ // and therefore need to be visible (to prevent user confusion)
+ top.document.commandDispatcher.focusedWindow.focus();
+
+ var strID;
+ switch (command) {
+ case "cmd_close":
+ strID = "BeforeClosing";
+ break;
+ }
+
+ var reasonToSave = strID ? GetString(strID) : "";
+
+ var title = document.title || GetString("untitledDefaultFilename");
+
+ var dialogTitle = GetString("SaveDocument");
+ var dialogMsg = GetString("SaveFilePrompt");
+ dialogMsg = dialogMsg
+ .replace(/%title%/, title)
+ .replace(/%reason%/, reasonToSave);
+
+ let result = { value: 0 };
+ let promptFlags =
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+ let button1Title = null;
+ let button3Title = null;
+
+ promptFlags +=
+ Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0;
+
+ // If allowing "Don't..." button, add that
+ if (allowDontSave) {
+ promptFlags +=
+ Services.prompt.BUTTON_TITLE_DONT_SAVE * Services.prompt.BUTTON_POS_2;
+ }
+
+ result = Services.prompt.confirmEx(
+ window,
+ dialogTitle,
+ dialogMsg,
+ promptFlags,
+ button1Title,
+ null,
+ button3Title,
+ null,
+ { value: 0 }
+ );
+
+ if (result == 0) {
+ // Save to local disk
+ return SaveDocument(false, false, editor.contentsMIMEType);
+ }
+
+ if (result == 2) {
+ // "Don't Save"
+ return true;
+ }
+
+ // Default or result == 1 (Cancel)
+ return false;
+}
+
+// --------------------------- Text style ---------------------------
+
+function editorSetParagraphState(state) {
+ if (state === "") {
+ // Corresponds to body text. Has no corresponding formatBlock value.
+ goDoCommandParams("cmd_paragraphState", "");
+ } else {
+ GetCurrentEditor().document.execCommand("formatBlock", false, state);
+ }
+ document.getElementById("cmd_paragraphState").setAttribute("state", state);
+ onParagraphFormatChange();
+}
+
+function onParagraphFormatChange() {
+ let paraMenuList = document.getElementById("ParagraphSelect");
+ if (!paraMenuList) {
+ return;
+ }
+
+ var commandNode = document.getElementById("cmd_paragraphState");
+ var state = commandNode.getAttribute("state");
+
+ // force match with "normal"
+ if (state == "body") {
+ state = "";
+ }
+
+ if (state == "mixed") {
+ // Selection is the "mixed" ( > 1 style) state
+ paraMenuList.selectedItem = null;
+ paraMenuList.setAttribute("label", GetString("Mixed"));
+ } else {
+ var menuPopup = document.getElementById("ParagraphPopup");
+ for (let menuItem of menuPopup.children) {
+ if (menuItem.value === state) {
+ paraMenuList.selectedItem = menuItem;
+ break;
+ }
+ }
+ }
+}
+
+function editorRemoveTextStyling() {
+ GetCurrentEditor().document.execCommand("removeFormat", false, null);
+ // After removing the formatting, update the full styling command set.
+ window.updateCommands("style");
+}
+
+/**
+ * Selects the current font face in the menulist.
+ */
+function onFontFaceChange() {
+ let fontFaceMenuList = document.getElementById("FontFaceSelect");
+ var commandNode = document.getElementById("cmd_fontFace");
+ var editorFont = commandNode.getAttribute("state");
+
+ // Strip quotes in font names. Experiments have shown that we only
+ // ever get double quotes around the font name, never single quotes,
+ // even if they were in the HTML source. Also single or double
+ // quotes within the font name are never returned.
+ editorFont = editorFont.replace(/"/g, "");
+
+ switch (editorFont) {
+ case "mixed":
+ // Selection is the "mixed" ( > 1 style) state.
+ fontFaceMenuList.selectedItem = null;
+ fontFaceMenuList.setAttribute("label", GetString("Mixed"));
+ return;
+ case "":
+ case "serif":
+ case "sans-serif":
+ // Generic variable width.
+ fontFaceMenuList.selectedIndex = 0;
+ return;
+ case "tt":
+ case "monospace":
+ // Generic fixed width.
+ fontFaceMenuList.selectedIndex = 1;
+ return;
+ default:
+ }
+
+ let menuPopup = fontFaceMenuList.menupopup;
+ let menuItems = menuPopup.children;
+
+ const genericFamilies = [
+ "serif",
+ "sans-serif",
+ "monospace",
+ "fantasy",
+ "cursive",
+ ];
+ // Bug 1139524: Normalise before we compare: Make it lower case
+ // and replace ", " with "," so that entries like
+ // "Helvetica, Arial, sans-serif" are always recognised correctly
+ let editorFontToLower = editorFont.toLowerCase().replace(/, /g, ",");
+ let foundFont = null;
+ let exactMatch = false;
+ let usedFontsSep = menuPopup.querySelector(
+ "menuseparator.fontFaceMenuAfterUsedFonts"
+ );
+ let editorFontOptions = editorFontToLower.split(",");
+ let editorOptionsCount = editorFontOptions.length;
+ let matchedFontIndex = editorOptionsCount; // initialise to high invalid value
+
+ // The font menu has this structure:
+ // 0: Variable Width
+ // 1: Fixed Width
+ // 2: Separator
+ // 3: Helvetica, Arial (stored as Helvetica, Arial, sans-serif)
+ // 4: Times (stored as Times New Roman, Times, serif)
+ // 5: Courier (stored as Courier New, Courier, monospace)
+ // 6: Separator, "menuseparator.fontFaceMenuAfterDefaultFonts"
+ // from 7: Used Font Section (for quick selection)
+ // followed by separator, "menuseparator.fontFaceMenuAfterUsedFonts"
+ // followed by all other available fonts.
+ // The following variable keeps track of where we are when we loop over the menu.
+ let afterUsedFontSection = false;
+
+ // The menu items not only have "label" and "value", but also some other attributes:
+ // "value_parsed": Is the toLowerCase() and space-stripped value.
+ // "value_cache": Is a concatenation of all editor fonts that were ever mapped
+ // onto this menu item. This is done for optimization.
+ // "used": This item is in the used font section.
+
+ for (let i = 0; i < menuItems.length; i++) {
+ let menuItem = menuItems.item(i);
+ if (
+ menuItem.hasAttribute("label") &&
+ menuItem.hasAttribute("value_parsed")
+ ) {
+ // The element seems to represent a font <menuitem>.
+ let fontMenuValue = menuItem.getAttribute("value_parsed");
+ if (
+ fontMenuValue == editorFontToLower ||
+ (menuItem.hasAttribute("value_cache") &&
+ menuItem
+ .getAttribute("value_cache")
+ .split("|")
+ .includes(editorFontToLower))
+ ) {
+ // This menuitem contains the font we are looking for.
+ foundFont = menuItem;
+ exactMatch = true;
+ break;
+ } else if (editorOptionsCount > 1 && afterUsedFontSection) {
+ // Once we are in the list of all other available fonts,
+ // we will find the one that best matches one of the options.
+ let matchPos = editorFontOptions.indexOf(fontMenuValue);
+ if (matchPos >= 0 && matchPos < matchedFontIndex) {
+ // This menu font comes earlier in the list of options,
+ // so prefer it.
+ matchedFontIndex = matchPos;
+ foundFont = menuItem;
+ // If we matched the first option, we don't need to look for
+ // a better match.
+ if (matchPos == 0) {
+ break;
+ }
+ }
+ }
+ } else if (menuItem == usedFontsSep) {
+ // Some other element type.
+ // We have now passed the section of used fonts and are now in the list of all.
+ afterUsedFontSection = true;
+ }
+ }
+
+ if (foundFont) {
+ let defaultFontsSep = menuPopup.querySelector(
+ "menuseparator.fontFaceMenuAfterDefaultFonts"
+ );
+ if (exactMatch) {
+ if (afterUsedFontSection) {
+ // Copy the matched font into the section of used fonts.
+ // We insert after the separator following the default fonts,
+ // so right at the beginning of the used fonts section.
+ let copyItem = foundFont.cloneNode(true);
+ menuPopup.insertBefore(copyItem, defaultFontsSep.nextElementSibling);
+ usedFontsSep.hidden = false;
+ foundFont = copyItem;
+ foundFont.setAttribute("used", "true");
+ }
+ } else {
+ // Keep only the found font and generic families in the font string.
+ editorFont = editorFont
+ .replace(/, /g, ",")
+ .split(",")
+ .filter(
+ font =>
+ font.toLowerCase() == foundFont.getAttribute("value_parsed") ||
+ genericFamilies.includes(font)
+ )
+ .join(",");
+
+ // Check if such an item is already in the used font section.
+ if (afterUsedFontSection) {
+ foundFont = menuPopup.querySelector(
+ 'menuitem[used="true"][value_parsed="' +
+ editorFont.toLowerCase() +
+ '"]'
+ );
+ }
+ // If not, create a new entry which will be inserted into that section.
+ if (!foundFont) {
+ foundFont = createFontFaceMenuitem(editorFont, editorFont, menuPopup);
+ }
+
+ // Add the editor font string into the 'cache' attribute in the element
+ // so we can later find it quickly without building the reduced string again.
+ let fontCache = "";
+ if (foundFont.hasAttribute("value_cache")) {
+ fontCache = foundFont.getAttribute("value_cache");
+ }
+ foundFont.setAttribute(
+ "value_cache",
+ fontCache + "|" + editorFontToLower
+ );
+
+ // If we created a new item, set it up and insert.
+ if (!foundFont.hasAttribute("used")) {
+ foundFont.setAttribute("used", "true");
+ usedFontsSep.hidden = false;
+ menuPopup.insertBefore(foundFont, defaultFontsSep.nextElementSibling);
+ }
+ }
+ } else {
+ // The editor encountered a font that is not installed on this system.
+ // Add it to the font menu now, in the used-fonts section right at the
+ // bottom before the separator of the section.
+ let fontLabel = GetFormattedString("NotInstalled", editorFont);
+ foundFont = createFontFaceMenuitem(fontLabel, editorFont, menuPopup);
+ foundFont.setAttribute("used", "true");
+ usedFontsSep.hidden = false;
+ menuPopup.insertBefore(foundFont, usedFontsSep);
+ }
+ fontFaceMenuList.selectedItem = foundFont;
+}
+
+/**
+ * Changes the font size for the selection or at the insertion point. This
+ * requires an integer from 1-7 as a value argument (x-small - xxx-large)
+ *
+ * @param {"1"|"2"|"3"|"4"|"5"|"6"|"7"} size - The font size.
+ */
+function EditorSetFontSize(size) {
+ // For normal/medium size (that is 3), we clear size.
+ if (size == "3") {
+ EditorRemoveTextProperty("font", "size");
+ // Also remove big and small,
+ // else it will seem like size isn't changing correctly
+ EditorRemoveTextProperty("small", "");
+ EditorRemoveTextProperty("big", "");
+ } else {
+ GetCurrentEditor().document.execCommand("fontSize", false, size);
+ }
+ // Enable or Disable the toolbar buttons according to the font size.
+ goUpdateCommand("cmd_decreaseFontStep");
+ goUpdateCommand("cmd_increaseFontStep");
+ gContentWindow.focus();
+}
+
+function initFontFaceMenu(menuPopup) {
+ initLocalFontFaceMenu(menuPopup);
+
+ if (menuPopup) {
+ var children = menuPopup.children;
+ if (!children) {
+ return;
+ }
+
+ var mixed = { value: false };
+ var editorFont = GetCurrentEditor().getFontFaceState(mixed);
+
+ // Strip quotes in font names. Experiments have shown that we only
+ // ever get double quotes around the font name, never single quotes,
+ // even if they were in the HTML source. Also single or double
+ // quotes within the font name are never returned.
+ editorFont = editorFont.replace(/"/g, "");
+
+ if (!mixed.value) {
+ switch (editorFont) {
+ case "":
+ case "serif":
+ case "sans-serif":
+ // Generic variable width.
+ editorFont = "";
+ break;
+ case "tt":
+ case "monospace":
+ // Generic fixed width.
+ editorFont = "monospace";
+ break;
+ default:
+ editorFont = editorFont.toLowerCase().replace(/, /g, ","); // bug 1139524
+ }
+ }
+
+ var editorFontOptions = editorFont.split(",");
+ var matchedOption = editorFontOptions.length; // initialise to high invalid value
+ for (var i = 0; i < children.length; i++) {
+ var menuItem = children[i];
+ if (menuItem.localName == "menuitem") {
+ var matchFound = false;
+ if (!mixed.value) {
+ var menuFont = menuItem
+ .getAttribute("value")
+ .toLowerCase()
+ .replace(/, /g, ",");
+
+ // First compare the entire font string to match items that contain commas.
+ if (menuFont == editorFont) {
+ menuItem.setAttribute("checked", "true");
+ break;
+ } else if (editorFontOptions.length > 1) {
+ // Next compare the individual options.
+ var matchPos = editorFontOptions.indexOf(menuFont);
+ if (matchPos >= 0 && matchPos < matchedOption) {
+ // This menu font comes earlier in the list of options,
+ // so prefer it.
+ menuItem.setAttribute("checked", "true");
+
+ // If we matched the first option, we don't need to look for
+ // a better match.
+ if (matchPos == 0) {
+ break;
+ }
+
+ matchedOption = matchPos;
+ matchFound = true;
+ }
+ }
+ }
+
+ // In case this item doesn't match, make sure we've cleared the checkmark.
+ if (!matchFound) {
+ menuItem.removeAttribute("checked");
+ }
+ }
+ }
+ }
+}
+
+// Number of fixed font face menuitems, these are:
+// Variable Width
+// Fixed Width
+// ==separator
+// Helvetica, Arial
+// Times
+// Courier
+// ==separator
+// ==separator
+const kFixedFontFaceMenuItems = 8;
+
+function initLocalFontFaceMenu(menuPopup) {
+ if (!gLocalFonts) {
+ // Build list of all local fonts once per editor
+ try {
+ var enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].getService(
+ Ci.nsIFontEnumerator
+ );
+ gLocalFonts = enumerator.EnumerateAllFonts();
+ } catch (e) {}
+ }
+
+ // Don't use radios for menulists.
+ let useRadioMenuitems = menuPopup.parentNode.localName == "menu";
+ menuPopup.setAttribute("useRadios", useRadioMenuitems);
+ if (menuPopup.children.length == kFixedFontFaceMenuItems) {
+ if (gLocalFonts.length == 0) {
+ menuPopup.querySelector(".fontFaceMenuAfterDefaultFonts").hidden = true;
+ }
+ for (let i = 0; i < gLocalFonts.length; ++i) {
+ // Remove Linux system generic fonts that collide with CSS generic fonts.
+ if (
+ gLocalFonts[i] != "" &&
+ gLocalFonts[i] != "serif" &&
+ gLocalFonts[i] != "sans-serif" &&
+ gLocalFonts[i] != "monospace"
+ ) {
+ let itemNode = createFontFaceMenuitem(
+ gLocalFonts[i],
+ gLocalFonts[i],
+ menuPopup
+ );
+ menuPopup.appendChild(itemNode);
+ }
+ }
+ }
+}
+
+/**
+ * Creates a menuitem element for the font faces menulist. Returns the menuitem
+ * but does not add it automatically to the menupopup.
+ *
+ * @param aFontLabel Label to be displayed for the item.
+ * @param aFontName The font face value to be used for the item.
+ * Will be used in <font face="value"> in the edited document.
+ * @param aMenuPopup The menupopup for which this menuitem is created.
+ */
+function createFontFaceMenuitem(aFontLabel, aFontName, aMenuPopup) {
+ let itemNode = document.createXULElement("menuitem");
+ itemNode.setAttribute("label", aFontLabel);
+ itemNode.setAttribute("value", aFontName);
+ itemNode.setAttribute(
+ "value_parsed",
+ aFontName.toLowerCase().replace(/, /g, ",")
+ );
+ itemNode.setAttribute("tooltiptext", aFontLabel);
+ if (aMenuPopup.getAttribute("useRadios") == "true") {
+ itemNode.setAttribute("type", "radio");
+ itemNode.setAttribute("observes", "cmd_renderedHTMLEnabler");
+ }
+ return itemNode;
+}
+
+/**
+ * Helper function
+ *
+ * @see https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#legacy-font-size-for
+ */
+function getLegacyFontSize() {
+ let fontSize = GetCurrentEditor().document.queryCommandValue("fontSize");
+ // If one selects all the texts in the editor and deletes it, the editor
+ // will return null fontSize. We will set it to default value then.
+ if (!fontSize) {
+ fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3");
+ }
+ return fontSize;
+}
+
+function initFontSizeMenu(menuPopup) {
+ if (menuPopup) {
+ let fontSize = getLegacyFontSize();
+ for (let menuitem of menuPopup.children) {
+ if (menuitem.getAttribute("value") == fontSize) {
+ menuitem.setAttribute("checked", true);
+ }
+ }
+ }
+}
+
+function onFontColorChange() {
+ ChangeButtonColor("cmd_fontColor", "TextColorButton", gDefaultTextColor);
+}
+
+function onBackgroundColorChange() {
+ ChangeButtonColor(
+ "cmd_backgroundColor",
+ "BackgroundColorButton",
+ gDefaultBackgroundColor
+ );
+}
+
+/* Helper function that changes the button color.
+ * commandID - The ID of the command element.
+ * id - The ID of the button needing to be changed.
+ * defaultColor - The default color the button gets set to.
+ */
+function ChangeButtonColor(commandID, id, defaultColor) {
+ var commandNode = document.getElementById(commandID);
+ if (commandNode) {
+ var color = commandNode.getAttribute("state");
+ var button = document.getElementById(id);
+ if (button) {
+ button.setAttribute("color", color);
+
+ // No color or a mixed color - get color set on page or other defaults.
+ if (!color || color == "mixed") {
+ color = defaultColor;
+ }
+
+ button.style.backgroundColor = color;
+ }
+ }
+}
+
+// Call this when user changes text and/or background colors of the page
+function UpdateDefaultColors() {
+ var BrowserColors = GetDefaultBrowserColors();
+ var bodyelement = GetBodyElement();
+ var defTextColor = gDefaultTextColor;
+ var defBackColor = gDefaultBackgroundColor;
+
+ if (bodyelement) {
+ var color = bodyelement.getAttribute("text");
+ if (color) {
+ gDefaultTextColor = color;
+ } else if (BrowserColors) {
+ gDefaultTextColor = BrowserColors.TextColor;
+ }
+
+ color = bodyelement.getAttribute("bgcolor");
+ if (color) {
+ gDefaultBackgroundColor = color;
+ } else if (BrowserColors) {
+ gDefaultBackgroundColor = BrowserColors.BackgroundColor;
+ }
+ }
+
+ // Trigger update on toolbar
+ if (defTextColor != gDefaultTextColor) {
+ goUpdateCommandState("cmd_fontColor");
+ onFontColorChange();
+ }
+ if (defBackColor != gDefaultBackgroundColor) {
+ goUpdateCommandState("cmd_backgroundColor");
+ onBackgroundColorChange();
+ }
+}
+
+function GetBackgroundElementWithColor() {
+ var editor = GetCurrentTableEditor();
+ if (!editor) {
+ return null;
+ }
+
+ gColorObj.Type = "";
+ gColorObj.PageColor = "";
+ gColorObj.TableColor = "";
+ gColorObj.CellColor = "";
+ gColorObj.BackgroundColor = "";
+ gColorObj.SelectedType = "";
+
+ var tagNameObj = { value: "" };
+ var element;
+ try {
+ element = editor.getSelectedOrParentTableElement(tagNameObj, { value: 0 });
+ } catch (e) {}
+
+ if (element && tagNameObj && tagNameObj.value) {
+ gColorObj.BackgroundColor = GetHTMLOrCSSStyleValue(
+ element,
+ "bgcolor",
+ "background-color"
+ );
+ gColorObj.BackgroundColor = ConvertRGBColorIntoHEXColor(
+ gColorObj.BackgroundColor
+ );
+ if (tagNameObj.value.toLowerCase() == "td") {
+ gColorObj.Type = "Cell";
+ gColorObj.CellColor = gColorObj.BackgroundColor;
+
+ // Get any color that might be on parent table
+ var table = GetParentTable(element);
+ gColorObj.TableColor = GetHTMLOrCSSStyleValue(
+ table,
+ "bgcolor",
+ "background-color"
+ );
+ gColorObj.TableColor = ConvertRGBColorIntoHEXColor(gColorObj.TableColor);
+ } else {
+ gColorObj.Type = "Table";
+ gColorObj.TableColor = gColorObj.BackgroundColor;
+ }
+ gColorObj.SelectedType = gColorObj.Type;
+ } else {
+ let IsCSSPrefChecked = Services.prefs.getBoolPref(kUseCssPref);
+ if (IsCSSPrefChecked && IsHTMLEditor()) {
+ let selection = editor.selection;
+ if (selection) {
+ element = selection.focusNode;
+ while (!editor.nodeIsBlock(element)) {
+ element = element.parentNode;
+ }
+ } else {
+ element = GetBodyElement();
+ }
+ } else {
+ element = GetBodyElement();
+ }
+ if (element) {
+ gColorObj.Type = "Page";
+ gColorObj.BackgroundColor = GetHTMLOrCSSStyleValue(
+ element,
+ "bgcolor",
+ "background-color"
+ );
+ if (gColorObj.BackgroundColor == "") {
+ gColorObj.BackgroundColor = "transparent";
+ } else {
+ gColorObj.BackgroundColor = ConvertRGBColorIntoHEXColor(
+ gColorObj.BackgroundColor
+ );
+ }
+ gColorObj.PageColor = gColorObj.BackgroundColor;
+ }
+ }
+ return element;
+}
+
+/* eslint-disable complexity */
+function EditorSelectColor(colorType, mouseEvent) {
+ var editor = GetCurrentEditor();
+ if (!editor || !gColorObj) {
+ return;
+ }
+
+ // Shift + mouse click automatically applies last color, if available
+ var useLastColor = mouseEvent
+ ? mouseEvent.button == 0 && mouseEvent.shiftKey
+ : false;
+ var element;
+ var table;
+ var currentColor = "";
+ var commandNode;
+
+ if (!colorType) {
+ colorType = "";
+ }
+
+ if (colorType == "Text") {
+ gColorObj.Type = colorType;
+
+ // Get color from command node state
+ commandNode = document.getElementById("cmd_fontColor");
+ currentColor = commandNode.getAttribute("state");
+ currentColor = ConvertRGBColorIntoHEXColor(currentColor);
+ gColorObj.TextColor = currentColor;
+
+ if (useLastColor && gColorObj.LastTextColor) {
+ gColorObj.TextColor = gColorObj.LastTextColor;
+ } else {
+ useLastColor = false;
+ }
+ } else if (colorType == "Highlight") {
+ gColorObj.Type = colorType;
+
+ // Get color from command node state
+ commandNode = document.getElementById("cmd_highlight");
+ currentColor = commandNode.getAttribute("state");
+ currentColor = ConvertRGBColorIntoHEXColor(currentColor);
+ gColorObj.HighlightColor = currentColor;
+
+ if (useLastColor && gColorObj.LastHighlightColor) {
+ gColorObj.HighlightColor = gColorObj.LastHighlightColor;
+ } else {
+ useLastColor = false;
+ }
+ } else {
+ element = GetBackgroundElementWithColor();
+ if (!element) {
+ return;
+ }
+
+ // Get the table if we found a cell
+ if (gColorObj.Type == "Table") {
+ table = element;
+ } else if (gColorObj.Type == "Cell") {
+ table = GetParentTable(element);
+ }
+
+ // Save to avoid resetting if not necessary
+ currentColor = gColorObj.BackgroundColor;
+
+ if (colorType == "TableOrCell" || colorType == "Cell") {
+ if (gColorObj.Type == "Cell") {
+ gColorObj.Type = colorType;
+ } else if (gColorObj.Type != "Table") {
+ return;
+ }
+ } else if (colorType == "Table" && gColorObj.Type == "Page") {
+ return;
+ }
+
+ if (colorType == "" && gColorObj.Type == "Cell") {
+ // Using empty string for requested type means
+ // we can let user select cell or table
+ gColorObj.Type = "TableOrCell";
+ }
+
+ if (useLastColor && gColorObj.LastBackgroundColor) {
+ gColorObj.BackgroundColor = gColorObj.LastBackgroundColor;
+ } else {
+ useLastColor = false;
+ }
+ }
+ // Save the type we are really requesting
+ colorType = gColorObj.Type;
+
+ if (!useLastColor) {
+ // Avoid the JS warning
+ gColorObj.NoDefault = false;
+
+ // Launch the ColorPicker dialog
+ // TODO: Figure out how to position this under the color buttons on the toolbar
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ gColorObj
+ );
+
+ // User canceled the dialog
+ if (gColorObj.Cancel) {
+ return;
+ }
+ }
+
+ if (gColorObj.Type == "Text") {
+ if (currentColor != gColorObj.TextColor) {
+ if (gColorObj.TextColor) {
+ GetCurrentEditor().document.execCommand(
+ "foreColor",
+ false,
+ gColorObj.TextColor
+ );
+ } else {
+ EditorRemoveTextProperty("font", "color");
+ }
+ }
+ // Update the command state (this will trigger color button update)
+ goUpdateCommandState("cmd_fontColor");
+ } else if (gColorObj.Type == "Highlight") {
+ if (currentColor != gColorObj.HighlightColor) {
+ if (gColorObj.HighlightColor) {
+ GetCurrentEditor().document.execCommand(
+ "backColor",
+ false,
+ gColorObj.HighlightColor
+ );
+ } else {
+ EditorRemoveTextProperty("font", "bgcolor");
+ }
+ }
+ // Update the command state (this will trigger color button update)
+ goUpdateCommandState("cmd_highlight");
+ } else if (element) {
+ if (gColorObj.Type == "Table") {
+ // Set background on a table
+ // Note that we shouldn't trust "currentColor" because of "TableOrCell" behavior
+ if (table) {
+ var bgcolor = table.getAttribute("bgcolor");
+ if (bgcolor != gColorObj.BackgroundColor) {
+ try {
+ if (gColorObj.BackgroundColor) {
+ editor.setAttributeOrEquivalent(
+ table,
+ "bgcolor",
+ gColorObj.BackgroundColor,
+ false
+ );
+ } else {
+ editor.removeAttributeOrEquivalent(table, "bgcolor", false);
+ }
+ } catch (e) {}
+ }
+ }
+ } else if (currentColor != gColorObj.BackgroundColor && IsHTMLEditor()) {
+ editor.beginTransaction();
+ try {
+ editor.setBackgroundColor(gColorObj.BackgroundColor);
+
+ if (gColorObj.Type == "Page" && gColorObj.BackgroundColor) {
+ // Set all page colors not explicitly set,
+ // else you can end up with unreadable pages
+ // because viewer's default colors may not be same as page author's
+ var bodyelement = GetBodyElement();
+ if (bodyelement) {
+ var defColors = GetDefaultBrowserColors();
+ if (defColors) {
+ if (!bodyelement.getAttribute("text")) {
+ editor.setAttributeOrEquivalent(
+ bodyelement,
+ "text",
+ defColors.TextColor,
+ false
+ );
+ }
+
+ // The following attributes have no individual CSS declaration counterparts
+ // Getting rid of them in favor of CSS implies CSS rules management
+ if (!bodyelement.getAttribute("link")) {
+ editor.setAttribute(bodyelement, "link", defColors.LinkColor);
+ }
+
+ if (!bodyelement.getAttribute("alink")) {
+ editor.setAttribute(
+ bodyelement,
+ "alink",
+ defColors.ActiveLinkColor
+ );
+ }
+
+ if (!bodyelement.getAttribute("vlink")) {
+ editor.setAttribute(
+ bodyelement,
+ "vlink",
+ defColors.VisitedLinkColor
+ );
+ }
+ }
+ }
+ }
+ } catch (e) {}
+
+ editor.endTransaction();
+ }
+
+ goUpdateCommandState("cmd_backgroundColor");
+ }
+ gContentWindow.focus();
+}
+/* eslint-enable complexity */
+
+function GetParentTable(element) {
+ var node = element;
+ while (node) {
+ if (node.nodeName.toLowerCase() == "table") {
+ return node;
+ }
+
+ node = node.parentNode;
+ }
+ return node;
+}
+
+function GetParentTableCell(element) {
+ var node = element;
+ while (node) {
+ if (
+ node.nodeName.toLowerCase() == "td" ||
+ node.nodeName.toLowerCase() == "th"
+ ) {
+ return node;
+ }
+
+ node = node.parentNode;
+ }
+ return node;
+}
+
+function EditorDblClick(event) {
+ // Only bring up properties if clicked on an element or selected link
+ let element = event.target;
+ // We use "href" instead of "a" to not be fooled by named anchor
+ if (!element) {
+ try {
+ element = GetCurrentEditor().getSelectedElement("href");
+ } catch (e) {}
+ }
+
+ // Don't fire for body/p and other block elements.
+ // It's common that people try to double-click
+ // to select a word, but the click hits an empty area.
+ if (
+ element &&
+ ![
+ "body",
+ "p",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "blockquote",
+ "div",
+ "pre",
+ ].includes(element.nodeName.toLowerCase())
+ ) {
+ goDoCommand("cmd_objectProperties");
+ event.preventDefault();
+ }
+}
+
+/* TODO: We need an oncreate hook to do enabling/disabling for the
+ Format menu. There should be code like this for the
+ object-specific "Properties" item
+*/
+// For property dialogs, we want the selected element,
+// but will accept a parent link, list, or table cell if inside one
+function GetObjectForProperties() {
+ var editor = GetCurrentEditor();
+ if (!editor || !IsHTMLEditor()) {
+ return null;
+ }
+
+ var element;
+ try {
+ element = editor.getSelectedElement("");
+ } catch (e) {}
+ if (element) {
+ if (element.namespaceURI == "http://www.w3.org/1998/Math/MathML") {
+ // If the object is a MathML element, we collapse the selection on it and
+ // we return its <math> ancestor. Hence the math dialog will be used.
+ GetCurrentEditor().selection.collapse(element, 0);
+ } else {
+ return element;
+ }
+ }
+
+ // Find nearest parent of selection anchor node
+ // that is a link, list, table cell, or table
+
+ var anchorNode;
+ var node;
+ try {
+ anchorNode = editor.selection.anchorNode;
+ if (anchorNode.firstChild) {
+ // Start at actual selected node
+ var offset = editor.selection.anchorOffset;
+ // Note: If collapsed, offset points to element AFTER caret,
+ // thus node may be null
+ node = anchorNode.childNodes.item(offset);
+ }
+ if (!node) {
+ node = anchorNode;
+ }
+ } catch (e) {}
+
+ while (node) {
+ if (node.nodeName) {
+ var nodeName = node.nodeName.toLowerCase();
+
+ // Done when we hit the body or #text.
+ if (nodeName == "body" || nodeName == "#text") {
+ break;
+ }
+
+ if (
+ (nodeName == "a" && node.href) ||
+ nodeName == "ol" ||
+ nodeName == "ul" ||
+ nodeName == "dl" ||
+ nodeName == "td" ||
+ nodeName == "th" ||
+ nodeName == "table" ||
+ nodeName == "math"
+ ) {
+ return node;
+ }
+ }
+ node = node.parentNode;
+ }
+ return null;
+}
+
+function UpdateWindowTitle() {
+ try {
+ var filename = "";
+ var windowTitle = "";
+ var title = document.title;
+
+ // Append just the 'leaf' filename to the Doc. Title for the window caption
+ var docUrl = GetDocumentUrl();
+ if (docUrl && !IsUrlAboutBlank(docUrl)) {
+ var scheme = GetScheme(docUrl);
+ filename = GetFilename(docUrl);
+ if (filename) {
+ windowTitle = " [" + scheme + ":/.../" + filename + "]";
+ }
+
+ var fileType = IsHTMLEditor() ? "html" : "text";
+ // Save changed title in the recent pages data in prefs
+ SaveRecentFilesPrefs(title, fileType);
+ }
+
+ document.title = (title || filename) + windowTitle;
+ } catch (e) {
+ dump(e);
+ }
+}
+
+function SaveRecentFilesPrefs(aTitle, aFileType) {
+ var curUrl = StripPassword(GetDocumentUrl());
+ var historyCount = Services.prefs.getIntPref("editor.history.url_maximum");
+
+ var titleArray = [];
+ var urlArray = [];
+ var typeArray = [];
+
+ if (historyCount && !IsUrlAboutBlank(curUrl) && GetScheme(curUrl) != "data") {
+ titleArray.push(aTitle);
+ urlArray.push(curUrl);
+ typeArray.push(aFileType);
+ }
+
+ for (let i = 0; i < historyCount && urlArray.length < historyCount; i++) {
+ let url = Services.prefs.getStringPref("editor.history_url_" + i, "");
+
+ // Continue if URL pref is missing because
+ // a URL not found during loading may have been removed
+
+ // Skip over current an "data" URLs
+ if (url && url != curUrl && GetScheme(url) != "data") {
+ let title = Services.prefs.getStringPref("editor.history_title_" + i, "");
+ let fileType = Services.prefs.getStringPref(
+ "editor.history_type_" + i,
+ ""
+ );
+ titleArray.push(title);
+ urlArray.push(url);
+ typeArray.push(fileType);
+ }
+ }
+
+ // Resave the list back to prefs in the new order
+ for (let i = 0; i < urlArray.length; i++) {
+ SetStringPref("editor.history_title_" + i, titleArray[i]);
+ SetStringPref("editor.history_url_" + i, urlArray[i]);
+ SetStringPref("editor.history_type_" + i, typeArray[i]);
+ }
+}
+
+function EditorInitFormatMenu() {
+ try {
+ InitObjectPropertiesMenuitem();
+ InitRemoveStylesMenuitems(
+ "removeStylesMenuitem",
+ "removeLinksMenuitem",
+ "removeNamedAnchorsMenuitem"
+ );
+ } catch (ex) {}
+}
+
+function InitObjectPropertiesMenuitem() {
+ // Set strings and enable for the [Object] Properties item
+ // Note that we directly do the enabling instead of
+ // using goSetCommandEnabled since we already have the command.
+ var cmd = document.getElementById("cmd_objectProperties");
+ if (!cmd) {
+ return null;
+ }
+
+ var element;
+ var menuStr = GetString("AdvancedProperties");
+ var name;
+
+ if (IsEditingRenderedHTML()) {
+ element = GetObjectForProperties();
+ }
+
+ if (element && element.nodeName) {
+ var objStr = "";
+ cmd.removeAttribute("disabled");
+ name = element.nodeName.toLowerCase();
+ switch (name) {
+ case "img":
+ // Check if img is enclosed in link
+ // (use "href" to not be fooled by named anchor)
+ try {
+ if (GetCurrentEditor().getElementOrParentByTagName("href", element)) {
+ objStr = GetString("ImageAndLink");
+ // Return "href" so it is detected as a link.
+ name = "href";
+ }
+ } catch (e) {}
+
+ if (objStr == "") {
+ objStr = GetString("Image");
+ }
+ break;
+ case "hr":
+ objStr = GetString("HLine");
+ break;
+ case "table":
+ objStr = GetString("Table");
+ break;
+ case "th":
+ name = "td";
+ // Falls through
+ case "td":
+ objStr = GetString("TableCell");
+ break;
+ case "ol":
+ case "ul":
+ case "dl":
+ objStr = GetString("List");
+ break;
+ case "li":
+ objStr = GetString("ListItem");
+ break;
+ case "form":
+ objStr = GetString("Form");
+ break;
+ case "input":
+ var type = element.getAttribute("type");
+ if (type && type.toLowerCase() == "image") {
+ objStr = GetString("InputImage");
+ } else {
+ objStr = GetString("InputTag");
+ }
+ break;
+ case "textarea":
+ objStr = GetString("TextArea");
+ break;
+ case "select":
+ objStr = GetString("Select");
+ break;
+ case "button":
+ objStr = GetString("Button");
+ break;
+ case "label":
+ objStr = GetString("Label");
+ break;
+ case "fieldset":
+ objStr = GetString("FieldSet");
+ break;
+ case "a":
+ if (element.name) {
+ objStr = GetString("NamedAnchor");
+ name = "anchor";
+ } else if (element.href) {
+ objStr = GetString("Link");
+ name = "href";
+ }
+ break;
+ }
+ if (objStr) {
+ menuStr = GetString("ObjectProperties").replace(/%obj%/, objStr);
+ }
+ } else {
+ // We show generic "Properties" string, but disable the command.
+ cmd.setAttribute("disabled", "true");
+ }
+ cmd.setAttribute("label", menuStr);
+ cmd.setAttribute("accesskey", GetString("ObjectPropertiesAccessKey"));
+ return name;
+}
+
+function InitParagraphMenu() {
+ var mixedObj = { value: null };
+ var state;
+ try {
+ state = GetCurrentEditor().getParagraphState(mixedObj);
+ } catch (e) {}
+ var IDSuffix;
+
+ // PROBLEM: When we get blockquote, it masks other styles contained by it
+ // We need a separate method to get blockquote state
+
+ // We use "x" as uninitialized paragraph state
+ if (!state || state == "x") {
+ // No paragraph container.
+ IDSuffix = "bodyText";
+ } else {
+ IDSuffix = state;
+ }
+
+ // Set "radio" check on one item, but...
+ var menuItem = document.getElementById("menu_" + IDSuffix);
+ menuItem.setAttribute("checked", "true");
+
+ // ..."bodyText" is returned if mixed selection, so remove checkmark
+ if (mixedObj.value) {
+ menuItem.setAttribute("checked", "false");
+ }
+}
+
+function GetListStateString() {
+ try {
+ var editor = GetCurrentEditor();
+
+ var mixedObj = { value: null };
+ var hasOL = { value: false };
+ var hasUL = { value: false };
+ var hasDL = { value: false };
+ editor.getListState(mixedObj, hasOL, hasUL, hasDL);
+
+ if (mixedObj.value) {
+ return "mixed";
+ }
+ if (hasOL.value) {
+ return "ol";
+ }
+ if (hasUL.value) {
+ return "ul";
+ }
+
+ if (hasDL.value) {
+ var hasLI = { value: false };
+ var hasDT = { value: false };
+ var hasDD = { value: false };
+ editor.getListItemState(mixedObj, hasLI, hasDT, hasDD);
+ if (mixedObj.value) {
+ return "mixed";
+ }
+ if (hasLI.value) {
+ return "li";
+ }
+ if (hasDT.value) {
+ return "dt";
+ }
+ if (hasDD.value) {
+ return "dd";
+ }
+ }
+ } catch (e) {}
+
+ // return "noList" if we aren't in a list at all
+ return "noList";
+}
+
+function InitListMenu() {
+ if (!IsHTMLEditor()) {
+ return;
+ }
+
+ var IDSuffix = GetListStateString();
+
+ // Set enable state for the "None" menuitem
+ goSetCommandEnabled("cmd_removeList", IDSuffix != "noList");
+
+ // Set "radio" check on one item, but...
+ // we won't find a match if it's "mixed"
+ var menuItem = document.getElementById("menu_" + IDSuffix);
+ if (menuItem) {
+ menuItem.setAttribute("checked", "true");
+ }
+}
+
+function GetAlignmentString() {
+ var mixedObj = { value: null };
+ var alignObj = { value: null };
+ try {
+ GetCurrentEditor().getAlignment(mixedObj, alignObj);
+ } catch (e) {}
+
+ if (mixedObj.value) {
+ return "mixed";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eLeft) {
+ return "left";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eCenter) {
+ return "center";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eRight) {
+ return "right";
+ }
+ if (alignObj.value == Ci.nsIHTMLEditor.eJustify) {
+ return "justify";
+ }
+
+ // return "left" if we got here
+ return "left";
+}
+
+function InitAlignMenu() {
+ if (!IsHTMLEditor()) {
+ return;
+ }
+
+ var IDSuffix = GetAlignmentString();
+
+ // we won't find a match if it's "mixed"
+ var menuItem = document.getElementById("menu_" + IDSuffix);
+ if (menuItem) {
+ menuItem.setAttribute("checked", "true");
+ }
+}
+
+function EditorSetDefaultPrefsAndDoctype() {
+ var editor = GetCurrentEditor();
+
+ var domdoc;
+ try {
+ domdoc = editor.document;
+ } catch (e) {
+ dump(e + "\n");
+ }
+ if (!domdoc) {
+ dump("EditorSetDefaultPrefsAndDoctype: EDITOR DOCUMENT NOT FOUND\n");
+ return;
+ }
+
+ // Insert a doctype element
+ // if it is missing from existing doc
+ if (!domdoc.doctype) {
+ var newdoctype = domdoc.implementation.createDocumentType(
+ "HTML",
+ "-//W3C//DTD HTML 4.01 Transitional//EN",
+ ""
+ );
+ if (newdoctype) {
+ domdoc.insertBefore(newdoctype, domdoc.firstChild);
+ }
+ }
+
+ // search for head; we'll need this for meta tag additions
+ let headelement = domdoc.querySelector("head");
+ if (!headelement) {
+ headelement = domdoc.createElement("head");
+ domdoc.insertAfter(headelement, domdoc.firstChild);
+ }
+
+ /* only set default prefs for new documents */
+ if (!IsUrlAboutBlank(GetDocumentUrl())) {
+ return;
+ }
+
+ // search for author meta tag.
+ // if one is found, don't do anything.
+ // if not, create one and make it a child of the head tag
+ // and set its content attribute to the value of the editor.author preference.
+
+ if (domdoc.querySelector("meta")) {
+ // we should do charset first since we need to have charset before
+ // hitting other 8-bit char in other meta tags
+ // grab charset pref and make it the default charset
+ var element;
+ var prefCharsetString = Services.prefs.getCharPref(
+ "intl.charset.fallback.override"
+ );
+ if (prefCharsetString) {
+ editor.documentCharacterSet = prefCharsetString;
+ }
+
+ // let's start by assuming we have an author in case we don't have the pref
+
+ var prefAuthorString = null;
+ let authorFound = domdoc.querySelector('meta[name="author"]');
+ try {
+ prefAuthorString = Services.prefs.getStringPref("editor.author");
+ } catch (ex) {}
+ if (
+ prefAuthorString &&
+ prefAuthorString != 0 &&
+ !authorFound &&
+ headelement
+ ) {
+ // create meta tag with 2 attributes
+ element = domdoc.createElement("meta");
+ if (element) {
+ element.setAttribute("name", "author");
+ element.setAttribute("content", prefAuthorString);
+ headelement.appendChild(element);
+ }
+ }
+ }
+
+ // add title tag if not present
+ if (headelement && !editor.document.querySelector("title")) {
+ var titleElement = domdoc.createElement("title");
+ if (titleElement) {
+ headelement.appendChild(titleElement);
+ }
+ }
+
+ // find body node
+ var bodyelement = GetBodyElement();
+ if (bodyelement) {
+ if (Services.prefs.getBoolPref("editor.use_custom_colors")) {
+ let text_color = Services.prefs.getCharPref("editor.text_color");
+ let background_color = Services.prefs.getCharPref(
+ "editor.background_color"
+ );
+
+ // add the color attributes to the body tag.
+ // and use them for the default text and background colors if not empty
+ editor.setAttributeOrEquivalent(bodyelement, "text", text_color, true);
+ gDefaultTextColor = text_color;
+ editor.setAttributeOrEquivalent(
+ bodyelement,
+ "bgcolor",
+ background_color,
+ true
+ );
+ gDefaultBackgroundColor = background_color;
+ bodyelement.setAttribute(
+ "link",
+ Services.prefs.getCharPref("editor.link_color")
+ );
+ bodyelement.setAttribute(
+ "alink",
+ Services.prefs.getCharPref("editor.active_link_color")
+ );
+ bodyelement.setAttribute(
+ "vlink",
+ Services.prefs.getCharPref("editor.followed_link_color")
+ );
+ }
+ // Default image is independent of Custom colors???
+ try {
+ let background_image = Services.prefs.getCharPref(
+ "editor.default_background_image"
+ );
+ if (background_image) {
+ editor.setAttributeOrEquivalent(
+ bodyelement,
+ "background",
+ background_image,
+ true
+ );
+ }
+ } catch (e) {
+ dump("BACKGROUND EXCEPTION: " + e + "\n");
+ }
+ }
+ // auto-save???
+}
+
+function GetBodyElement() {
+ try {
+ return GetCurrentEditor().rootElement;
+ } catch (ex) {
+ dump("no body tag found?!\n");
+ // better have one, how can we blow things up here?
+ }
+ return null;
+}
+
+// --------------------------------------------------------------------
+function initFontStyleMenu(menuPopup) {
+ for (var i = 0; i < menuPopup.children.length; i++) {
+ var menuItem = menuPopup.children[i];
+ var theStyle = menuItem.getAttribute("state");
+ if (theStyle) {
+ menuItem.setAttribute("checked", theStyle);
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------------
+function IsSpellCheckerInstalled() {
+ return true; // Always installed.
+}
+
+// -----------------------------------------------------------------------------------
+function IsFindInstalled() {
+ return (
+ "@mozilla.org/embedcomp/rangefind;1" in Cc &&
+ "@mozilla.org/find/find_service;1" in Cc
+ );
+}
+
+// -----------------------------------------------------------------------------------
+function RemoveInapplicableUIElements() {
+ // For items that are in their own menu block, remove associated separator
+ // (we can't use "hidden" since class="hide-in-IM" CSS rule interferes)
+
+ // if no find, remove find ui
+ if (!IsFindInstalled()) {
+ HideItem("menu_find");
+ HideItem("menu_findnext");
+ HideItem("menu_replace");
+ HideItem("menu_find");
+ RemoveItem("sep_find");
+ }
+
+ // if no spell checker, remove spell checker ui
+ if (!IsSpellCheckerInstalled()) {
+ HideItem("spellingButton");
+ HideItem("menu_checkspelling");
+ RemoveItem("sep_checkspelling");
+ }
+
+ // Remove menu items (from overlay shared with HTML editor) in non-HTML.
+ if (!IsHTMLEditor()) {
+ HideItem("insertAnchor");
+ HideItem("insertImage");
+ HideItem("insertHline");
+ HideItem("insertTable");
+ HideItem("insertHTML");
+ HideItem("insertFormMenu");
+ HideItem("fileExportToText");
+ HideItem("viewFormatToolbar");
+ HideItem("viewEditModeToolbar");
+ }
+}
+
+function HideItem(id) {
+ var item = document.getElementById(id);
+ if (item) {
+ item.hidden = true;
+ }
+}
+
+function RemoveItem(id) {
+ var item = document.getElementById(id);
+ if (item) {
+ item.remove();
+ }
+}
+
+// Command Updating Strategy:
+// Don't update on on selection change, only when menu is displayed,
+// with this "oncreate" handler:
+function EditorInitTableMenu() {
+ try {
+ InitJoinCellMenuitem("menu_JoinTableCells");
+ } catch (ex) {}
+
+ // Set enable states for all table commands
+ goUpdateTableMenuItems(document.getElementById("composerTableMenuItems"));
+}
+
+function InitJoinCellMenuitem(id) {
+ // Change text on the "Join..." item depending if we
+ // are joining selected cells or just cell to right
+ // TODO: What to do about normal selection that crosses
+ // table border? Try to figure out all cells
+ // included in the selection?
+ var menuText;
+ var menuItem = document.getElementById(id);
+ if (!menuItem) {
+ return;
+ }
+
+ // Use "Join selected cells if there's more than 1 cell selected
+ var numSelected;
+ var foundElement;
+
+ try {
+ var tagNameObj = {};
+ var countObj = { value: 0 };
+ foundElement = GetCurrentTableEditor().getSelectedOrParentTableElement(
+ tagNameObj,
+ countObj
+ );
+ numSelected = countObj.value;
+ } catch (e) {}
+ if (foundElement && numSelected > 1) {
+ menuText = GetString("JoinSelectedCells");
+ } else {
+ menuText = GetString("JoinCellToRight");
+ }
+
+ menuItem.setAttribute("label", menuText);
+ menuItem.setAttribute("accesskey", GetString("JoinCellAccesskey"));
+}
+
+function InitRemoveStylesMenuitems(
+ removeStylesId,
+ removeLinksId,
+ removeNamedAnchorsId
+) {
+ var editor = GetCurrentEditor();
+ if (!editor) {
+ return;
+ }
+
+ // Change wording of menuitems depending on selection
+ var stylesItem = document.getElementById(removeStylesId);
+ var linkItem = document.getElementById(removeLinksId);
+
+ var isCollapsed = editor.selection.isCollapsed;
+ if (stylesItem) {
+ stylesItem.setAttribute(
+ "label",
+ isCollapsed ? GetString("StopTextStyles") : GetString("RemoveTextStyles")
+ );
+ stylesItem.setAttribute(
+ "accesskey",
+ GetString("RemoveTextStylesAccesskey")
+ );
+ }
+ if (linkItem) {
+ linkItem.setAttribute(
+ "label",
+ isCollapsed ? GetString("StopLinks") : GetString("RemoveLinks")
+ );
+ linkItem.setAttribute("accesskey", GetString("RemoveLinksAccesskey"));
+ // Note: disabling text style is a pain since there are so many - forget it!
+
+ // Disable if not in a link, but always allow "Remove"
+ // if selection isn't collapsed since we only look at anchor node
+ try {
+ SetElementEnabled(
+ linkItem,
+ !isCollapsed || editor.getElementOrParentByTagName("href", null)
+ );
+ } catch (e) {}
+ }
+ // Disable if selection is collapsed
+ SetElementEnabledById(removeNamedAnchorsId, !isCollapsed);
+}
+
+function goUpdateTableMenuItems(commandset) {
+ var editor = GetCurrentTableEditor();
+ if (!editor) {
+ dump("goUpdateTableMenuItems: too early, not initialized\n");
+ return;
+ }
+
+ var enabled = false;
+ var enabledIfTable = false;
+
+ var flags = editor.flags;
+ if (!(flags & Ci.nsIEditor.eEditorReadonlyMask) && IsEditingRenderedHTML()) {
+ var tagNameObj = { value: "" };
+ var element;
+ try {
+ element = editor.getSelectedOrParentTableElement(tagNameObj, {
+ value: 0,
+ });
+ } catch (e) {}
+
+ if (element) {
+ // Value when we need to have a selected table or inside a table
+ enabledIfTable = true;
+
+ // All others require being inside a cell or selected cell
+ enabled = tagNameObj.value == "td";
+ }
+ }
+
+ // Loop through command nodes
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ if (
+ commandID == "cmd_InsertTable" ||
+ commandID == "cmd_JoinTableCells" ||
+ commandID == "cmd_SplitTableCell" ||
+ commandID == "cmd_ConvertToTable"
+ ) {
+ // Call the update method in the command class
+ goUpdateCommand(commandID);
+ } else if (
+ commandID == "cmd_DeleteTable" ||
+ commandID == "cmd_editTable" ||
+ commandID == "cmd_TableOrCellColor" ||
+ commandID == "cmd_SelectTable"
+ ) {
+ // Directly set with the values calculated here
+ goSetCommandEnabled(commandID, enabledIfTable);
+ } else {
+ goSetCommandEnabled(commandID, enabled);
+ }
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------------
+// Helpers for inserting and editing tables:
+
+function IsInTable() {
+ var editor = GetCurrentEditor();
+ try {
+ var flags = editor.flags;
+ return (
+ IsHTMLEditor() &&
+ !(flags & Ci.nsIEditor.eEditorReadonlyMask) &&
+ IsEditingRenderedHTML() &&
+ null != editor.getElementOrParentByTagName("table", null)
+ );
+ } catch (e) {}
+ return false;
+}
+
+function IsInTableCell() {
+ try {
+ var editor = GetCurrentEditor();
+ var flags = editor.flags;
+ return (
+ IsHTMLEditor() &&
+ !(flags & Ci.nsIEditor.eEditorReadonlyMask) &&
+ IsEditingRenderedHTML() &&
+ null != editor.getElementOrParentByTagName("td", null)
+ );
+ } catch (e) {}
+ return false;
+}
+
+function IsSelectionInOneCell() {
+ try {
+ var editor = GetCurrentEditor();
+ var selection = editor.selection;
+
+ if (selection.rangeCount == 1) {
+ // We have a "normal" single-range selection
+ if (
+ !selection.isCollapsed &&
+ selection.anchorNode != selection.focusNode
+ ) {
+ // Check if both nodes are within the same cell
+ var anchorCell = editor.getElementOrParentByTagName(
+ "td",
+ selection.anchorNode
+ );
+ var focusCell = editor.getElementOrParentByTagName(
+ "td",
+ selection.focusNode
+ );
+ return (
+ focusCell != null && anchorCell != null && focusCell == anchorCell
+ );
+ }
+ // Collapsed selection or anchor == focus (thus must be in 1 cell)
+ return true;
+ }
+ } catch (e) {}
+ return false;
+}
+
+// Call this with insertAllowed = true to allow inserting if not in existing table,
+// else use false to do nothing if not in a table
+function EditorInsertOrEditTable(insertAllowed) {
+ if (IsInTable()) {
+ // Edit properties of existing table
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdTableProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ "TablePanel"
+ );
+ gContentWindow.focus();
+ } else if (insertAllowed) {
+ try {
+ if (GetCurrentEditor().selection.isCollapsed) {
+ // If we have a caret, insert a blank table...
+ EditorInsertTable();
+ } else {
+ // Else convert the selection into a table.
+ goDoCommand("cmd_ConvertToTable");
+ }
+ } catch (e) {}
+ }
+}
+
+function EditorInsertTable() {
+ // Insert a new table
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertTable.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ ""
+ );
+ gContentWindow.focus();
+}
+
+function EditorTableCellProperties() {
+ if (!IsHTMLEditor()) {
+ return;
+ }
+
+ try {
+ var cell = GetCurrentEditor().getElementOrParentByTagName("td", null);
+ if (cell) {
+ // Start Table Properties dialog on the "Cell" panel
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdTableProps.xhtml",
+ "_blank",
+ "chrome,close,titlebar,modal",
+ "",
+ "CellPanel"
+ );
+ gContentWindow.focus();
+ }
+ } catch (e) {}
+}
+
+function GetNumberOfContiguousSelectedRows() {
+ if (!IsHTMLEditor()) {
+ return 0;
+ }
+
+ var rows = 0;
+ var editor = GetCurrentTableEditor();
+ var rowObj = { value: 0 };
+ var colObj = { value: 0 };
+ var cell = editor.getFirstSelectedCellInTable(rowObj, colObj);
+ if (!cell) {
+ return 0;
+ }
+
+ // We have at least one row
+ rows++;
+
+ var lastIndex = rowObj.value;
+ for (let cell of editor.getSelectedCells()) {
+ editor.getCellIndexes(cell, rowObj, colObj);
+ var index = rowObj.value;
+ if (index == lastIndex + 1) {
+ lastIndex = index;
+ rows++;
+ }
+ }
+
+ return rows;
+}
+
+function GetNumberOfContiguousSelectedColumns() {
+ if (!IsHTMLEditor()) {
+ return 0;
+ }
+
+ var columns = 0;
+
+ var editor = GetCurrentTableEditor();
+ var colObj = { value: 0 };
+ var rowObj = { value: 0 };
+ var cell = editor.getFirstSelectedCellInTable(rowObj, colObj);
+ if (!cell) {
+ return 0;
+ }
+
+ // We have at least one column
+ columns++;
+
+ var lastIndex = colObj.value;
+ for (let cell of editor.getSelectedCells()) {
+ editor.getCellIndexes(cell, rowObj, colObj);
+ var index = colObj.value;
+ if (index == lastIndex + 1) {
+ lastIndex = index;
+ columns++;
+ }
+ }
+
+ return columns;
+}
+
+function EditorOnFocus() {
+ // Current window already has the InsertCharWindow
+ if ("InsertCharWindow" in window && window.InsertCharWindow) {
+ return;
+ }
+
+ // Find window with an InsertCharsWindow and switch association to this one
+ var windowWithDialog = FindEditorWithInsertCharDialog();
+ if (windowWithDialog) {
+ // Switch the dialog to current window
+ // this sets focus to dialog, so bring focus back to editor window
+ if (SwitchInsertCharToThisWindow(windowWithDialog)) {
+ top.document.commandDispatcher.focusedWindow.focus();
+ }
+ }
+}
+
+function SwitchInsertCharToThisWindow(windowWithDialog) {
+ if (
+ windowWithDialog &&
+ "InsertCharWindow" in windowWithDialog &&
+ windowWithDialog.InsertCharWindow
+ ) {
+ // Move dialog association to the current window
+ window.InsertCharWindow = windowWithDialog.InsertCharWindow;
+ windowWithDialog.InsertCharWindow = null;
+
+ // Switch the dialog's opener to current window's
+ window.InsertCharWindow.opener = window;
+
+ // Bring dialog to the foreground
+ window.InsertCharWindow.focus();
+ return true;
+ }
+ return false;
+}
+
+function FindEditorWithInsertCharDialog() {
+ try {
+ // Find window with an InsertCharsWindow and switch association to this one
+
+ for (let tempWindow of Services.wm.getEnumerator(null)) {
+ if (
+ !tempWindow.closed &&
+ tempWindow != window &&
+ "InsertCharWindow" in tempWindow &&
+ tempWindow.InsertCharWindow
+ ) {
+ return tempWindow;
+ }
+ }
+ } catch (e) {}
+ return null;
+}
+
+function EditorFindOrCreateInsertCharWindow() {
+ if ("InsertCharWindow" in window && window.InsertCharWindow) {
+ window.InsertCharWindow.focus();
+ } else {
+ // Since we switch the dialog during EditorOnFocus(),
+ // this should really never be found, but it's good to be sure
+ var windowWithDialog = FindEditorWithInsertCharDialog();
+ if (windowWithDialog) {
+ SwitchInsertCharToThisWindow(windowWithDialog);
+ } else {
+ // The dialog will set window.InsertCharWindow to itself
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertChars.xhtml",
+ "_blank",
+ "chrome,close,titlebar",
+ ""
+ );
+ }
+ }
+}
+
+// Find another HTML editor window to associate with the InsertChar dialog
+// or close it if none found (May be a mail composer)
+function SwitchInsertCharToAnotherEditorOrClose() {
+ if ("InsertCharWindow" in window && window.InsertCharWindow) {
+ var enumerator;
+ try {
+ enumerator = Services.wm.getEnumerator(null);
+ } catch (e) {}
+ if (!enumerator) {
+ return;
+ }
+
+ // TODO: Fix this to search for command controllers and look for "cmd_InsertChars"
+ // For now, detect just Web Composer and HTML Mail Composer
+ for (let tempWindow of enumerator) {
+ if (
+ !tempWindow.closed &&
+ tempWindow != window &&
+ tempWindow != window.InsertCharWindow &&
+ "GetCurrentEditor" in tempWindow &&
+ tempWindow.GetCurrentEditor()
+ ) {
+ tempWindow.InsertCharWindow = window.InsertCharWindow;
+ window.InsertCharWindow = null;
+ tempWindow.InsertCharWindow.opener = tempWindow;
+ return;
+ }
+ }
+ // Didn't find another editor - close the dialog
+ window.InsertCharWindow.close();
+ }
+}
+
+function UpdateTOC() {
+ window.openDialog(
+ "chrome://messenger/content/messengercompose/EdInsertTOC.xhtml",
+ "_blank",
+ "chrome,close,modal,titlebar"
+ );
+ window.content.focus();
+}
+
+function InitTOCMenu() {
+ var elt = GetCurrentEditor().document.getElementById("mozToc");
+ var createMenuitem = document.getElementById("insertTOCMenuitem");
+ var updateMenuitem = document.getElementById("updateTOCMenuitem");
+ var removeMenuitem = document.getElementById("removeTOCMenuitem");
+ if (removeMenuitem && createMenuitem && updateMenuitem) {
+ if (elt) {
+ createMenuitem.setAttribute("disabled", "true");
+ updateMenuitem.removeAttribute("disabled");
+ removeMenuitem.removeAttribute("disabled");
+ } else {
+ createMenuitem.removeAttribute("disabled");
+ removeMenuitem.setAttribute("disabled", "true");
+ updateMenuitem.setAttribute("disabled", "true");
+ }
+ }
+}
+
+function RemoveTOC() {
+ var theDocument = GetCurrentEditor().document;
+ var elt = theDocument.getElementById("mozToc");
+ if (elt) {
+ elt.remove();
+ }
+
+ let anchorNodes = theDocument.querySelectorAll('a[name^="mozTocId"]');
+ for (let node of anchorNodes) {
+ if (node.parentNode) {
+ node.remove();
+ }
+ }
+}
diff --git a/comm/mail/components/compose/content/editorUtilities.js b/comm/mail/components/compose/content/editorUtilities.js
new file mode 100644
index 0000000000..3af6810c9c
--- /dev/null
+++ b/comm/mail/components/compose/content/editorUtilities.js
@@ -0,0 +1,1015 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from editor.js */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+// Each editor window must include this file
+// Variables shared by all dialogs:
+
+// Object to attach commonly-used widgets (all dialogs should use this)
+var gDialog = {};
+
+var kOutputEncodeBasicEntities =
+ Ci.nsIDocumentEncoder.OutputEncodeBasicEntities;
+var kOutputEncodeHTMLEntities = Ci.nsIDocumentEncoder.OutputEncodeHTMLEntities;
+var kOutputEncodeLatin1Entities =
+ Ci.nsIDocumentEncoder.OutputEncodeLatin1Entities;
+var kOutputEncodeW3CEntities = Ci.nsIDocumentEncoder.OutputEncodeW3CEntities;
+var kOutputFormatted = Ci.nsIDocumentEncoder.OutputFormatted;
+var kOutputLFLineBreak = Ci.nsIDocumentEncoder.OutputLFLineBreak;
+var kOutputSelectionOnly = Ci.nsIDocumentEncoder.OutputSelectionOnly;
+var kOutputWrap = Ci.nsIDocumentEncoder.OutputWrap;
+
+var gStringBundle;
+var gFilePickerDirectory;
+
+/** *********** Message dialogs */
+
+// Optional: Caller may supply text to substitute for "Ok" and/or "Cancel"
+function ConfirmWithTitle(title, message, okButtonText, cancelButtonText) {
+ let okFlag = okButtonText
+ ? Services.prompt.BUTTON_TITLE_IS_STRING
+ : Services.prompt.BUTTON_TITLE_OK;
+ let cancelFlag = cancelButtonText
+ ? Services.prompt.BUTTON_TITLE_IS_STRING
+ : Services.prompt.BUTTON_TITLE_CANCEL;
+
+ return (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ okFlag * Services.prompt.BUTTON_POS_0 +
+ cancelFlag * Services.prompt.BUTTON_POS_1,
+ okButtonText,
+ cancelButtonText,
+ null,
+ null,
+ { value: 0 }
+ ) == 0
+ );
+}
+
+/** *********** String Utilities */
+
+function GetString(name) {
+ if (!gStringBundle) {
+ try {
+ gStringBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messengercompose/editor.properties"
+ );
+ } catch (ex) {}
+ }
+ if (gStringBundle) {
+ try {
+ return gStringBundle.GetStringFromName(name);
+ } catch (e) {}
+ }
+ return null;
+}
+
+function GetFormattedString(aName, aVal) {
+ if (!gStringBundle) {
+ try {
+ gStringBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messengercompose/editor.properties"
+ );
+ } catch (ex) {}
+ }
+ if (gStringBundle) {
+ try {
+ return gStringBundle.formatStringFromName(aName, [aVal]);
+ } catch (e) {}
+ }
+ return null;
+}
+
+function TrimStringLeft(string) {
+ if (!string) {
+ return "";
+ }
+ return string.trimLeft();
+}
+
+function TrimStringRight(string) {
+ if (!string) {
+ return "";
+ }
+ return string.trimRight();
+}
+
+// Remove whitespace from both ends of a string
+function TrimString(string) {
+ if (!string) {
+ return "";
+ }
+ return string.trim();
+}
+
+function TruncateStringAtWordEnd(string, maxLength, addEllipses) {
+ // Return empty if string is null, undefined, or the empty string
+ if (!string) {
+ return "";
+ }
+
+ // We assume they probably don't want whitespace at the beginning
+ string = string.trimLeft();
+ if (string.length <= maxLength) {
+ return string;
+ }
+
+ // We need to truncate the string to maxLength or fewer chars
+ if (addEllipses) {
+ maxLength -= 3;
+ }
+ string = string.replace(RegExp("(.{0," + maxLength + "})\\s.*"), "$1");
+
+ if (string.length > maxLength) {
+ string = string.slice(0, maxLength);
+ }
+
+ if (addEllipses) {
+ string += "...";
+ }
+ return string;
+}
+
+// Replace all whitespace characters with supplied character
+// E.g.: Use charReplace = " ", to "unwrap" the string by removing line-end chars
+// Use charReplace = "_" when you don't want spaces (like in a URL)
+function ReplaceWhitespace(string, charReplace) {
+ return string.trim().replace(/\s+/g, charReplace);
+}
+
+// Replace whitespace with "_" and allow only HTML CDATA
+// characters: "a"-"z","A"-"Z","0"-"9", "_", ":", "-", ".",
+// and characters above ASCII 127
+function ConvertToCDATAString(string) {
+ return string
+ .replace(/\s+/g, "_")
+ .replace(/[^a-zA-Z0-9_\.\-\:\u0080-\uFFFF]+/g, "");
+}
+
+function GetSelectionAsText() {
+ try {
+ return GetCurrentEditor().outputToString(
+ "text/plain",
+ kOutputSelectionOnly
+ );
+ } catch (e) {}
+
+ return "";
+}
+
+/** *********** Get Current Editor and associated interfaces or info */
+
+function GetCurrentEditor() {
+ // Get the active editor from the <editor> tag
+ // XXX This will probably change if we support > 1 editor in main Composer window
+ // (e.g. a plaintext editor for HTMLSource)
+
+ // For dialogs: Search up parent chain to find top window with editor
+ var editor;
+ try {
+ var editorElement = GetCurrentEditorElement();
+ editor = editorElement.getEditor(editorElement.contentWindow);
+
+ // Do QIs now so editor users won't have to figure out which interface to use
+ // Using "instanceof" does the QI for us.
+ editor instanceof Ci.nsIHTMLEditor;
+ } catch (e) {
+ dump(e) + "\n";
+ }
+
+ return editor;
+}
+
+function GetCurrentTableEditor() {
+ var editor = GetCurrentEditor();
+ return editor && editor instanceof Ci.nsITableEditor ? editor : null;
+}
+
+function GetCurrentEditorElement() {
+ var tmpWindow = window;
+
+ do {
+ // Get the <editor> element(s)
+ let editorItem = tmpWindow.document.querySelector("editor");
+
+ // This will change if we support > 1 editor element
+ if (editorItem) {
+ return editorItem;
+ }
+
+ tmpWindow = tmpWindow.opener;
+ } while (tmpWindow);
+
+ return null;
+}
+
+function GetCurrentCommandManager() {
+ try {
+ return GetCurrentEditorElement().commandManager;
+ } catch (e) {
+ dump(e) + "\n";
+ }
+
+ return null;
+}
+
+function GetCurrentEditorType() {
+ try {
+ return GetCurrentEditorElement().editortype;
+ } catch (e) {
+ dump(e) + "\n";
+ }
+
+ return "";
+}
+
+/**
+ * Gets the editor's spell checker. Could return null if there are no
+ * dictionaries installed.
+ *
+ * @returns {nsIInlineSpellChecker?}
+ */
+function GetCurrentEditorSpellChecker() {
+ try {
+ return GetCurrentEditor().getInlineSpellChecker(true);
+ } catch (ex) {}
+ return null;
+}
+
+function IsHTMLEditor() {
+ // We don't have an editorElement, just return false
+ if (!GetCurrentEditorElement()) {
+ return false;
+ }
+
+ var editortype = GetCurrentEditorType();
+ switch (editortype) {
+ case "html":
+ case "htmlmail":
+ return true;
+
+ case "text":
+ case "textmail":
+ return false;
+
+ default:
+ dump("INVALID EDITOR TYPE: " + editortype + "\n");
+ break;
+ }
+ return false;
+}
+
+function PageIsEmptyAndUntouched() {
+ return IsDocumentEmpty() && !IsDocumentModified() && !IsHTMLSourceChanged();
+}
+
+function IsInHTMLSourceMode() {
+ return gEditorDisplayMode == kDisplayModeSource;
+}
+
+// are we editing HTML (i.e. neither in HTML source mode, nor editing a text file)
+function IsEditingRenderedHTML() {
+ return IsHTMLEditor() && !IsInHTMLSourceMode();
+}
+
+function IsDocumentEditable() {
+ try {
+ return GetCurrentEditor().isDocumentEditable;
+ } catch (e) {}
+ return false;
+}
+
+function IsDocumentEmpty() {
+ try {
+ return GetCurrentEditor().documentIsEmpty;
+ } catch (e) {}
+ return false;
+}
+
+function IsDocumentModified() {
+ try {
+ return GetCurrentEditor().documentModified;
+ } catch (e) {}
+ return false;
+}
+
+function IsHTMLSourceChanged() {
+ // gSourceTextEditor will not be defined if we're just a text editor.
+ return gSourceTextEditor ? gSourceTextEditor.documentModified : false;
+}
+
+function newCommandParams() {
+ try {
+ return Cu.createCommandParams();
+ } catch (e) {
+ dump("error thrown in newCommandParams: " + e + "\n");
+ }
+ return null;
+}
+
+/** *********** General editing command utilities */
+
+function GetDocumentTitle() {
+ try {
+ return GetCurrentEditorElement().contentDocument.title;
+ } catch (e) {}
+
+ return "";
+}
+
+function SetDocumentTitle(title) {
+ try {
+ GetCurrentEditorElement().contentDocument.title = title;
+
+ // Update window title (doesn't work if called from a dialog)
+ if ("UpdateWindowTitle" in window) {
+ window.UpdateWindowTitle();
+ }
+ } catch (e) {}
+}
+
+function EditorGetTextProperty(
+ property,
+ attribute,
+ value,
+ firstHas,
+ anyHas,
+ allHas
+) {
+ try {
+ return GetCurrentEditor().getInlinePropertyWithAttrValue(
+ property,
+ attribute,
+ value,
+ firstHas,
+ anyHas,
+ allHas
+ );
+ } catch (e) {}
+}
+
+function EditorSetTextProperty(property, attribute, value) {
+ try {
+ GetCurrentEditor().setInlineProperty(property, attribute, value);
+ if ("gContentWindow" in window) {
+ window.gContentWindow.focus();
+ }
+ } catch (e) {}
+}
+
+function EditorRemoveTextProperty(property, attribute) {
+ try {
+ GetCurrentEditor().removeInlineProperty(property, attribute);
+ if ("gContentWindow" in window) {
+ window.gContentWindow.focus();
+ }
+ } catch (e) {}
+}
+
+/** *********** Element enbabling/disabling */
+
+// this function takes an elementID and a flag
+// if the element can be found by ID, then it is either enabled (by removing "disabled" attr)
+// or disabled (setAttribute) as specified in the "doEnable" parameter
+function SetElementEnabledById(elementID, doEnable) {
+ SetElementEnabled(document.getElementById(elementID), doEnable);
+}
+
+function SetElementEnabled(element, doEnable) {
+ if (element) {
+ if (doEnable) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", "true");
+ }
+ } else {
+ dump("Element not found in SetElementEnabled\n");
+ }
+}
+
+/** *********** Services / Prefs */
+
+function GetFileProtocolHandler() {
+ let handler = Services.io.getProtocolHandler("file");
+ return handler.QueryInterface(Ci.nsIFileProtocolHandler);
+}
+
+function SetStringPref(aPrefName, aPrefValue) {
+ try {
+ Services.prefs.setStringPref(aPrefName, aPrefValue);
+ } catch (e) {}
+}
+
+// Set initial directory for a filepicker from URLs saved in prefs
+function SetFilePickerDirectory(filePicker, fileType) {
+ if (filePicker) {
+ try {
+ // Save current directory so we can reset it in SaveFilePickerDirectory
+ gFilePickerDirectory = filePicker.displayDirectory;
+
+ let location = Services.prefs.getComplexValue(
+ "editor.lastFileLocation." + fileType,
+ Ci.nsIFile
+ );
+ if (location) {
+ filePicker.displayDirectory = location;
+ }
+ } catch (e) {}
+ }
+}
+
+// Save the directory of the selected file to prefs
+function SaveFilePickerDirectory(filePicker, fileType) {
+ if (filePicker && filePicker.file) {
+ try {
+ var fileDir;
+ if (filePicker.file.parent) {
+ fileDir = filePicker.file.parent.QueryInterface(Ci.nsIFile);
+ }
+
+ Services.prefs.setComplexValue(
+ "editor.lastFileLocation." + fileType,
+ Ci.nsIFile,
+ fileDir
+ );
+
+ Services.prefs.savePrefFile(null);
+ } catch (e) {}
+ }
+
+ // Restore the directory used before SetFilePickerDirectory was called;
+ // This reduces interference with Browser and other module directory defaults
+ if (gFilePickerDirectory) {
+ filePicker.displayDirectory = gFilePickerDirectory;
+ }
+
+ gFilePickerDirectory = null;
+}
+
+function GetDefaultBrowserColors() {
+ var colors = {
+ TextColor: 0,
+ BackgroundColor: 0,
+ LinkColor: 0,
+ ActiveLinkColor: 0,
+ VisitedLinkColor: 0,
+ };
+ var useSysColors = Services.prefs.getBoolPref(
+ "browser.display.use_system_colors",
+ false
+ );
+
+ if (!useSysColors) {
+ colors.TextColor = Services.prefs.getCharPref(
+ "browser.display.foreground_color",
+ 0
+ );
+ colors.BackgroundColor = Services.prefs.getCharPref(
+ "browser.display.background_color",
+ 0
+ );
+ }
+ // Use OS colors for text and background if explicitly asked or pref is not set
+ if (!colors.TextColor) {
+ colors.TextColor = "windowtext";
+ }
+
+ if (!colors.BackgroundColor) {
+ colors.BackgroundColor = "window";
+ }
+
+ colors.LinkColor = Services.prefs.getCharPref("browser.anchor_color");
+ colors.ActiveLinkColor = Services.prefs.getCharPref("browser.active_color");
+ colors.VisitedLinkColor = Services.prefs.getCharPref("browser.visited_color");
+
+ return colors;
+}
+
+/** *********** URL handling */
+
+function TextIsURI(selectedText) {
+ return (
+ selectedText &&
+ /^http:\/\/|^https:\/\/|^file:\/\/|^ftp:\/\/|^about:|^mailto:|^news:|^snews:|^telnet:|^ldap:|^ldaps:|^gopher:|^finger:|^javascript:/i.test(
+ selectedText
+ )
+ );
+}
+
+function IsUrlAboutBlank(urlString) {
+ return urlString.startsWith("about:blank");
+}
+
+function MakeRelativeUrl(url) {
+ let inputUrl = url.trim();
+ if (!inputUrl) {
+ return inputUrl;
+ }
+
+ // Get the filespec relative to current document's location
+ // NOTE: Can't do this if file isn't saved yet!
+ var docUrl = GetDocumentBaseUrl();
+ var docScheme = GetScheme(docUrl);
+
+ // Can't relativize if no doc scheme (page hasn't been saved)
+ if (!docScheme) {
+ return inputUrl;
+ }
+
+ var urlScheme = GetScheme(inputUrl);
+
+ // Do nothing if not the same scheme or url is already relativized
+ if (docScheme != urlScheme) {
+ return inputUrl;
+ }
+
+ // Host must be the same
+ var docHost = GetHost(docUrl);
+ var urlHost = GetHost(inputUrl);
+ if (docHost != urlHost) {
+ return inputUrl;
+ }
+
+ // Get just the file path part of the urls
+ // XXX Should we use GetCurrentEditor().documentCharacterSet for 2nd param ?
+ let docPath = Services.io.newURI(
+ docUrl,
+ GetCurrentEditor().documentCharacterSet
+ ).pathQueryRef;
+ let urlPath = Services.io.newURI(
+ inputUrl,
+ GetCurrentEditor().documentCharacterSet
+ ).pathQueryRef;
+
+ // We only return "urlPath", so we can convert the entire docPath for
+ // case-insensitive comparisons.
+ var doCaseInsensitive = docScheme == "file" && AppConstants.platform == "win";
+ if (doCaseInsensitive) {
+ docPath = docPath.toLowerCase();
+ }
+
+ // Get document filename before we start chopping up the docPath
+ var docFilename = GetFilename(docPath);
+
+ // Both url and doc paths now begin with "/"
+ // Look for shared dirs starting after that
+ urlPath = urlPath.slice(1);
+ docPath = docPath.slice(1);
+
+ var firstDirTest = true;
+ var nextDocSlash = 0;
+ var done = false;
+
+ // Remove all matching subdirs common to both doc and input urls
+ do {
+ nextDocSlash = docPath.indexOf("/");
+ var nextUrlSlash = urlPath.indexOf("/");
+
+ if (nextUrlSlash == -1) {
+ // We're done matching and all dirs in url
+ // what's left is the filename
+ done = true;
+
+ // Remove filename for named anchors in the same file
+ if (nextDocSlash == -1 && docFilename) {
+ var anchorIndex = urlPath.indexOf("#");
+ if (anchorIndex > 0) {
+ var urlFilename = doCaseInsensitive ? urlPath.toLowerCase() : urlPath;
+
+ if (urlFilename.startsWith(docFilename)) {
+ urlPath = urlPath.slice(anchorIndex);
+ }
+ }
+ }
+ } else if (nextDocSlash >= 0) {
+ // Test for matching subdir
+ var docDir = docPath.slice(0, nextDocSlash);
+ var urlDir = urlPath.slice(0, nextUrlSlash);
+ if (doCaseInsensitive) {
+ urlDir = urlDir.toLowerCase();
+ }
+
+ if (urlDir == docDir) {
+ // Remove matching dir+"/" from each path
+ // and continue to next dir.
+ docPath = docPath.slice(nextDocSlash + 1);
+ urlPath = urlPath.slice(nextUrlSlash + 1);
+ } else {
+ // No match, we're done.
+ done = true;
+
+ // Be sure we are on the same local drive or volume
+ // (the first "dir" in the path) because we can't
+ // relativize to different drives/volumes.
+ // UNIX doesn't have volumes, so we must not do this else
+ // the first directory will be misinterpreted as a volume name.
+ if (
+ firstDirTest &&
+ docScheme == "file" &&
+ AppConstants.platform != "unix"
+ ) {
+ return inputUrl;
+ }
+ }
+ } else {
+ // No more doc dirs left, we're done
+ done = true;
+ }
+
+ firstDirTest = false;
+ } while (!done);
+
+ // Add "../" for each dir left in docPath
+ while (nextDocSlash > 0) {
+ urlPath = "../" + urlPath;
+ nextDocSlash = docPath.indexOf("/", nextDocSlash + 1);
+ }
+ return urlPath;
+}
+
+function MakeAbsoluteUrl(url) {
+ let resultUrl = TrimString(url);
+ if (!resultUrl) {
+ return resultUrl;
+ }
+
+ // Check if URL is already absolute, i.e., it has a scheme
+ let urlScheme = GetScheme(resultUrl);
+
+ if (urlScheme) {
+ return resultUrl;
+ }
+
+ let docUrl = GetDocumentBaseUrl();
+ let docScheme = GetScheme(docUrl);
+
+ // Can't relativize if no doc scheme (page hasn't been saved)
+ if (!docScheme) {
+ return resultUrl;
+ }
+
+ // Make a URI object to use its "resolve" method
+ let absoluteUrl = resultUrl;
+ let docUri = Services.io.newURI(
+ docUrl,
+ GetCurrentEditor().documentCharacterSet
+ );
+
+ try {
+ absoluteUrl = docUri.resolve(resultUrl);
+ // This is deprecated and buggy!
+ // If used, we must make it a path for the parent directory (remove filename)
+ // absoluteUrl = IOService.resolveRelativePath(resultUrl, docUrl);
+ } catch (e) {}
+
+ return absoluteUrl;
+}
+
+// Get the HREF of the page's <base> tag or the document location
+// returns empty string if no base href and document hasn't been saved yet
+function GetDocumentBaseUrl() {
+ try {
+ var docUrl;
+
+ // if document supplies a <base> tag, use that URL instead
+ let base = GetCurrentEditor().document.querySelector("base");
+ if (base) {
+ docUrl = base.getAttribute("href");
+ }
+ if (!docUrl) {
+ docUrl = GetDocumentUrl();
+ }
+
+ if (!IsUrlAboutBlank(docUrl)) {
+ return docUrl;
+ }
+ } catch (e) {}
+ return "";
+}
+
+function GetDocumentUrl() {
+ try {
+ return GetCurrentEditor().document.URL;
+ } catch (e) {}
+ return "";
+}
+
+// Extract the scheme (e.g., 'file', 'http') from a URL string
+function GetScheme(urlspec) {
+ var resultUrl = TrimString(urlspec);
+ // Unsaved document URL has no acceptable scheme yet
+ if (!resultUrl || IsUrlAboutBlank(resultUrl)) {
+ return "";
+ }
+
+ var scheme = "";
+ try {
+ // This fails if there's no scheme
+ scheme = Services.io.extractScheme(resultUrl);
+ } catch (e) {}
+
+ return scheme ? scheme.toLowerCase() : "";
+}
+
+function GetHost(urlspec) {
+ if (!urlspec) {
+ return "";
+ }
+
+ var host = "";
+ try {
+ host = Services.io.newURI(urlspec).host;
+ } catch (e) {}
+
+ return host;
+}
+
+function GetUsername(urlspec) {
+ if (!urlspec) {
+ return "";
+ }
+
+ var username = "";
+ try {
+ username = Services.io.newURI(urlspec).username;
+ } catch (e) {}
+
+ return username;
+}
+
+function GetFilename(urlspec) {
+ if (!urlspec || IsUrlAboutBlank(urlspec)) {
+ return "";
+ }
+
+ var filename;
+
+ try {
+ let uri = Services.io.newURI(urlspec);
+ if (uri) {
+ let url = uri.QueryInterface(Ci.nsIURL);
+ if (url) {
+ filename = url.fileName;
+ }
+ }
+ } catch (e) {}
+
+ return filename ? filename : "";
+}
+
+// Return the url without username and password
+// Optional output objects return extracted username and password strings
+// This uses just string routines via nsIIOServices
+function StripUsernamePassword(urlspec, usernameObj, passwordObj) {
+ urlspec = TrimString(urlspec);
+ if (!urlspec || IsUrlAboutBlank(urlspec)) {
+ return urlspec;
+ }
+
+ if (usernameObj) {
+ usernameObj.value = "";
+ }
+ if (passwordObj) {
+ passwordObj.value = "";
+ }
+
+ // "@" must exist else we will never detect username or password
+ var atIndex = urlspec.indexOf("@");
+ if (atIndex > 0) {
+ try {
+ let uri = Services.io.newURI(urlspec);
+ let username = uri.username;
+ let password = uri.password;
+
+ if (usernameObj && username) {
+ usernameObj.value = username;
+ }
+ if (passwordObj && password) {
+ passwordObj.value = password;
+ }
+ if (username) {
+ let usernameStart = urlspec.indexOf(username);
+ if (usernameStart != -1) {
+ return urlspec.slice(0, usernameStart) + urlspec.slice(atIndex + 1);
+ }
+ }
+ } catch (e) {}
+ }
+ return urlspec;
+}
+
+function StripPassword(urlspec, passwordObj) {
+ urlspec = TrimString(urlspec);
+ if (!urlspec || IsUrlAboutBlank(urlspec)) {
+ return urlspec;
+ }
+
+ if (passwordObj) {
+ passwordObj.value = "";
+ }
+
+ // "@" must exist else we will never detect password
+ var atIndex = urlspec.indexOf("@");
+ if (atIndex > 0) {
+ try {
+ let password = Services.io.newURI(urlspec).password;
+
+ if (passwordObj && password) {
+ passwordObj.value = password;
+ }
+ if (password) {
+ // Find last ":" before "@"
+ let colon = urlspec.lastIndexOf(":", atIndex);
+ if (colon != -1) {
+ // Include the "@"
+ return urlspec.slice(0, colon) + urlspec.slice(atIndex);
+ }
+ }
+ } catch (e) {}
+ }
+ return urlspec;
+}
+
+// Version to use when you have an nsIURI object
+function StripUsernamePasswordFromURI(uri) {
+ var urlspec = "";
+ if (uri) {
+ try {
+ urlspec = uri.spec;
+ var userPass = uri.userPass;
+ if (userPass) {
+ let start = urlspec.indexOf(userPass);
+ urlspec =
+ urlspec.slice(0, start) + urlspec.slice(start + userPass.length + 1);
+ }
+ } catch (e) {}
+ }
+ return urlspec;
+}
+
+function InsertUsernameIntoUrl(urlspec, username) {
+ if (!urlspec || !username) {
+ return urlspec;
+ }
+
+ try {
+ let URI = Services.io.newURI(
+ urlspec,
+ GetCurrentEditor().documentCharacterSet
+ );
+ URI.username = username;
+ return URI.spec;
+ } catch (e) {}
+
+ return urlspec;
+}
+
+function ConvertRGBColorIntoHEXColor(color) {
+ if (/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/.test(color)) {
+ var r = Number(RegExp.$1).toString(16);
+ if (r.length == 1) {
+ r = "0" + r;
+ }
+ var g = Number(RegExp.$2).toString(16);
+ if (g.length == 1) {
+ g = "0" + g;
+ }
+ var b = Number(RegExp.$3).toString(16);
+ if (b.length == 1) {
+ b = "0" + b;
+ }
+ return "#" + r + g + b;
+ }
+
+ return color;
+}
+
+/** *********** CSS */
+
+function GetHTMLOrCSSStyleValue(element, attrName, cssPropertyName) {
+ var value;
+ if (Services.prefs.getBoolPref("editor.use_css") && IsHTMLEditor()) {
+ value = element.style.getPropertyValue(cssPropertyName);
+ }
+
+ if (!value) {
+ value = element.getAttribute(attrName);
+ }
+
+ if (!value) {
+ return "";
+ }
+
+ return value;
+}
+
+/** *********** Miscellaneous */
+// Clone simple JS objects
+function Clone(obj) {
+ var clone = {};
+ for (var i in obj) {
+ if (typeof obj[i] == "object") {
+ clone[i] = Clone(obj[i]);
+ } else {
+ clone[i] = obj[i];
+ }
+ }
+ return clone;
+}
+
+/**
+ * Utility functions to handle shortended data: URLs in EdColorProps.js and EdImageOverlay.js.
+ */
+
+/**
+ * Is the passed in image URI a shortened data URI?
+ *
+ * @returns {bool}
+ */
+function isImageDataShortened(aImageData) {
+ return /^data:/i.test(aImageData) && aImageData.includes("…");
+}
+
+/**
+ * Event handler for Copy or Cut
+ *
+ * @param aEvent the event
+ */
+function onCopyOrCutShortened(aEvent) {
+ // Put the original data URI onto the clipboard in case the value
+ // is a shortened data URI.
+ let field = aEvent.target;
+ let startPos = field.selectionStart;
+ if (startPos == undefined) {
+ return;
+ }
+ let endPos = field.selectionEnd;
+ let selection = field.value.substring(startPos, endPos).trim();
+
+ // Test that a) the user selected the whole value,
+ // b) the value is a data URI,
+ // c) it contains the ellipsis we added. Otherwise it could be
+ // a new value that the user pasted in.
+ if (selection == field.value.trim() && isImageDataShortened(selection)) {
+ aEvent.clipboardData.setData("text/plain", field.fullDataURI);
+ if (aEvent.type == "cut") {
+ // We have to cut the selection manually. Since we tested that
+ // everything was selected, we can just reset the field.
+ field.value = "";
+ }
+ aEvent.preventDefault();
+ }
+}
+
+/**
+ * Set up element showing an image URI with a shortened version.
+ * and add event handler for Copy or Cut.
+ *
+ * @param aImageData the data: URL of the image to be shortened.
+ * Note: Original stored in 'aDialogField.fullDataURI'.
+ * @param aDialogField The field of the dialog to contain the data.
+ * @returns {bool} URL was shortened?
+ */
+function shortenImageData(aImageData, aDialogField) {
+ let shortened = false;
+ aDialogField.value = aImageData.replace(
+ /^(data:.+;base64,)(.*)/i,
+ function (match, nonDataPart, dataPart) {
+ if (dataPart.length <= 35) {
+ return match;
+ }
+
+ shortened = true;
+ aDialogField.addEventListener("copy", onCopyOrCutShortened);
+ aDialogField.addEventListener("cut", onCopyOrCutShortened);
+ aDialogField.fullDataURI = aImageData;
+ aDialogField.removeAttribute("tooltiptext");
+ aDialogField.setAttribute("tooltip", "shortenedDataURI");
+ return (
+ nonDataPart +
+ dataPart.substring(0, 5) +
+ "…" +
+ dataPart.substring(dataPart.length - 30)
+ );
+ }
+ );
+ return shortened;
+}
+
+/**
+ * Return full data URIs for a shortened element.
+ *
+ * @param aDialogField The field of the dialog containing the data.
+ */
+function restoredImageData(aDialogField) {
+ return aDialogField.fullDataURI;
+}
diff --git a/comm/mail/components/compose/content/images/tag-anchor.gif b/comm/mail/components/compose/content/images/tag-anchor.gif
new file mode 100644
index 0000000000..ccb809b50b
--- /dev/null
+++ b/comm/mail/components/compose/content/images/tag-anchor.gif
Binary files differ
diff --git a/comm/mail/components/compose/content/messengercompose.xhtml b/comm/mail/components/compose/content/messengercompose.xhtml
new file mode 100644
index 0000000000..6881bf7cf3
--- /dev/null
+++ b/comm/mail/components/compose/content/messengercompose.xhtml
@@ -0,0 +1,2572 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messengercompose/messengercompose.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/inContentDialog.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % messengercomposeDTD SYSTEM "chrome://messenger/locale/messengercompose/messengercompose.dtd" >
+ %messengercomposeDTD;
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+ %brandDTD;
+ <!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+ %customizeToolbarDTD;
+ <!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd">
+ %viewZoomOverlayDTD;
+ <!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+ %baseMenuOverlayDTD;
+ <!ENTITY % msgCompSMIMEDTD SYSTEM "chrome://messenger-smime/locale/msgCompSMIMEOverlay.dtd">
+ %msgCompSMIMEDTD;
+ <!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd">
+ %editorOverlayDTD;
+ <!ENTITY % utilityOverlayDTD SYSTEM
+ "chrome://communicator/locale/utilityOverlay.dtd">
+ %utilityOverlayDTD;
+ <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd">
+ %messengerDTD;
+]>
+
+<html id="msgcomposeWindow" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="msgcomposeWindow"
+ scrolling="false"
+ windowtype="msgcompose"
+ toggletoolbar="true"
+ persist="screenX screenY width height sizemode"
+ lightweightthemes="true"
+#ifdef XP_MACOSX
+ macanimationtype="document"
+ chromemargin="0,-1,-1,-1"
+#endif
+ fullscreenbutton="true">
+<head>
+ <title>&msgComposeWindow.title;</title>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="messenger/messengercompose/messengercompose.ftl" />
+ <link rel="localization" href="messenger/menubar.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/keyAssistant.ftl" />
+ <link rel="localization" href="messenger/openpgp/composeKeyStatus.ftl"/>
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="toolkit/printing/printUI.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script>
+ <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/editor.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/editorUtilities.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/ComposerCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/MsgComposeCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/bigFileObserver.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/cloudAttachmentLinkManager.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+ <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/addressbook/abDragDrop.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/addressingWidgetOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/addressbook/abCommon.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+#ifdef XP_MACOSX
+ <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script>
+#endif
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailMsgComposeOverlay.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/commonWorkflows.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/keyAssistant.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <stringbundle id="bundle_composeMsgs" src="chrome://messenger/locale/messengercompose/composeMsgs.properties"/>
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+ <stringbundle id="brandBundle" src="chrome://branding/locale/brand.properties"/>
+
+<commandset id="composeCommands">
+ <commandset id="msgComposeCommandUpdate"
+ commandupdater="true"
+ events="focus"
+ oncommandupdate="CommandUpdate_MsgCompose()"/>
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="focus"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="select"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="undoEditMenuItems"
+ commandupdater="true"
+ events="undo"
+ oncommandupdate="goUpdateUndoEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+
+ <!-- commands updated when the editor gets created -->
+ <commandset id="commonEditorMenuItems"
+ commandupdater="true"
+ events="create"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_print" oncommand="goDoCommand('cmd_print')"/>
+ <command id="cmd_quitApplication" oncommand="goDoCommand('cmd_quitApplication')"/>
+ </commandset>
+
+ <commandset id="composerMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <!-- format menu -->
+ <command id="cmd_listProperties" oncommand="goDoCommand('cmd_listProperties')"/>
+ <command id="cmd_colorProperties" oncommand="goDoCommand('cmd_colorProperties')"/>
+
+ <command id="cmd_link" oncommand="goDoCommand('cmd_link')"/>
+ <command id="cmd_anchor" oncommand="goDoCommand('cmd_anchor')"/>
+ <command id="cmd_image" oncommand="goDoCommand('cmd_image')"/>
+ <command id="cmd_hline" oncommand="goDoCommand('cmd_hline')"/>
+ <command id="cmd_table" oncommand="goDoCommand('cmd_table')"/>
+ <command id="cmd_objectProperties" oncommand="goDoCommand('cmd_objectProperties')"/>
+ <command id="cmd_insertChars" oncommand="goDoCommand('cmd_insertChars')" label="&insertCharsCmd.label;"/>
+ <command id="cmd_insertHTMLWithDialog" oncommand="goDoCommand('cmd_insertHTMLWithDialog')" label="&insertHTMLCmd.label;"/>
+ <command id="cmd_insertMathWithDialog" oncommand="goDoCommand('cmd_insertMathWithDialog')" label="&insertMathCmd.label;"/>
+
+ <command id="cmd_insertBreakAll" oncommand="goDoCommand('cmd_insertBreakAll')"/>
+
+ <!-- dummy command used just to disable things in non-HTML modes -->
+ <command id="cmd_renderedHTMLEnabler"/>
+ </commandset>
+
+ <!-- edit menu commands. These get updated by code in globalOverlay.js -->
+ <commandset id="composerEditMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_pasteNoFormatting" oncommand="goDoCommand('cmd_pasteNoFormatting')"
+ label="&pasteNoFormatting.label;" accesskey="&pasteNoFormatting.accesskey;"/>
+ <command id="cmd_findReplace" oncommand="goDoCommand('cmd_findReplace')"/>
+ <command id="cmd_find" oncommand="goDoCommand('cmd_find')"/>
+ <command id="cmd_findNext" oncommand="goDoCommand('cmd_findNext');"/>
+ <command id="cmd_findPrev" oncommand="goDoCommand('cmd_findPrev');"/>
+ <command id="cmd_spelling" oncommand="goDoCommand('cmd_spelling')"/>
+ <command id="cmd_pasteQuote" oncommand="goDoCommand('cmd_pasteQuote')" label="&pasteAsQuotationCmd.label;"/>
+ </commandset>
+
+ <!-- style related commands that update on creation, and on selection change -->
+ <commandset id="composerStyleMenuItems"
+ commandupdater="true"
+ events="create, style, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_bold" state="false" oncommand="doStyleUICommand('cmd_bold')"/>
+ <command id="cmd_italic" state="false" oncommand="doStyleUICommand('cmd_italic')"/>
+ <command id="cmd_underline" state="false" oncommand="doStyleUICommand('cmd_underline')"/>
+ <command id="cmd_tt" state="false" oncommand="goDoCommand('cmd_tt')"/>
+ <command id="cmd_smiley"/>
+
+ <command id="cmd_strikethrough" state="false" oncommand="doStyleUICommand('cmd_strikethrough');"/>
+ <command id="cmd_superscript" state="false" oncommand="doStyleUICommand('cmd_superscript');"/>
+ <command id="cmd_subscript" state="false" oncommand="doStyleUICommand('cmd_subscript');"/>
+ <command id="cmd_nobreak" state="false" oncommand="goDoCommand('cmd_nobreak');"/>
+
+ <command id="cmd_em" state="false" oncommand="goDoCommand('cmd_em')"/>
+ <command id="cmd_strong" state="false" oncommand="goDoCommand('cmd_strong')"/>
+ <command id="cmd_cite" state="false" oncommand="goDoCommand('cmd_cite')"/>
+ <command id="cmd_abbr" state="false" oncommand="goDoCommand('cmd_abbr')"/>
+ <command id="cmd_acronym" state="false" oncommand="goDoCommand('cmd_acronym')"/>
+ <command id="cmd_code" state="false" oncommand="goDoCommand('cmd_code')"/>
+ <command id="cmd_samp" state="false" oncommand="goDoCommand('cmd_samp')"/>
+ <command id="cmd_var" state="false" oncommand="goDoCommand('cmd_var')"/>
+
+ <command id="cmd_ul" state="false" oncommand="doStyleUICommand('cmd_ul')"/>
+ <command id="cmd_ol" state="false" oncommand="doStyleUICommand('cmd_ol')"/>
+
+ <command id="cmd_indent" oncommand="goDoCommand('cmd_indent')"/>
+ <command id="cmd_outdent" oncommand="goDoCommand('cmd_outdent')"/>
+
+ <command id="cmd_paragraphState" state=""/>
+ <command id="cmd_fontFace" state="" oncommand="doStatefulCommand('cmd_fontFace', event.target.value)"/>
+
+ <!-- No "oncommand", use EditorSelectColor() to bring up color dialog -->
+ <command id="cmd_fontColor" state="" disabled="false"/>
+ <command id="cmd_backgroundColor" state="" disabled="false"/>
+ <command id="cmd_highlight" state="transparent" oncommand="EditorSelectColor('Highlight', event);"/>
+
+ <command id="cmd_align" state=""/>
+
+ <command id="cmd_increaseFontStep" oncommand="goDoCommand('cmd_increaseFontStep')"/>
+ <command id="cmd_decreaseFontStep" oncommand="goDoCommand('cmd_decreaseFontStep')"/>
+
+ <command id="cmd_removeStyles" oncommand="editorRemoveTextStyling();"/>
+ <command id="cmd_removeLinks" oncommand="goDoCommand('cmd_removeLinks')"/>
+ <command id="cmd_removeNamedAnchors" oncommand="goDoCommand('cmd_removeNamedAnchors')"/>
+ </commandset>
+
+ <commandset id="composerTableMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateTableMenuItems(this)">
+ <!-- Table menu -->
+ <command id="cmd_SelectTable" oncommand="goDoCommand('cmd_SelectTable')"/>
+ <command id="cmd_SelectRow" oncommand="goDoCommand('cmd_SelectRow')"/>
+ <command id="cmd_SelectColumn" oncommand="goDoCommand('cmd_SelectColumn')"/>
+ <command id="cmd_SelectCell" oncommand="goDoCommand('cmd_SelectCell')"/>
+ <command id="cmd_SelectAllCells" oncommand="goDoCommand('cmd_SelectAllCells')"/>
+ <command id="cmd_InsertTable" oncommand="goDoCommand('cmd_InsertTable')"/>
+ <command id="cmd_InsertRowAbove" oncommand="goDoCommand('cmd_InsertRowAbove')"/>
+ <command id="cmd_InsertRowBelow" oncommand="goDoCommand('cmd_InsertRowBelow')"/>
+ <command id="cmd_InsertColumnBefore" oncommand="goDoCommand('cmd_InsertColumnBefore')"/>
+ <command id="cmd_InsertColumnAfter" oncommand="goDoCommand('cmd_InsertColumnAfter')"/>
+ <command id="cmd_InsertCellBefore" oncommand="goDoCommand('cmd_InsertCellBefore')"/>
+ <command id="cmd_InsertCellAfter" oncommand="goDoCommand('cmd_InsertCellAfter')"/>
+ <command id="cmd_DeleteTable" oncommand="goDoCommand('cmd_DeleteTable')"/>
+ <command id="cmd_DeleteRow" oncommand="goDoCommand('cmd_DeleteRow')"/>
+ <command id="cmd_DeleteColumn" oncommand="goDoCommand('cmd_DeleteColumn')"/>
+ <command id="cmd_DeleteCell" oncommand="goDoCommand('cmd_DeleteCell')"/>
+ <command id="cmd_DeleteCellContents" oncommand="goDoCommand('cmd_DeleteCellContents')"/>
+ <command id="cmd_JoinTableCells" oncommand="goDoCommand('cmd_JoinTableCells')"/>
+ <command id="cmd_SplitTableCell" oncommand="goDoCommand('cmd_SplitTableCell')"/>
+ <command id="cmd_ConvertToTable" oncommand="goDoCommand('cmd_ConvertToTable')"/>
+ <command id="cmd_TableOrCellColor" oncommand="goDoCommand('cmd_TableOrCellColor')"/>
+ <command id="cmd_editTable" oncommand="goDoCommand('cmd_editTable')"/>
+ </commandset>
+
+ <!-- commands updated only when the menu gets created -->
+ <commandset id="composerListMenuItems"
+ commandupdater="true"
+ events="create, mode_switch"
+ oncommandupdate="goUpdateComposerMenuItems(this)">
+ <!-- List menu -->
+ <command id="cmd_dt" oncommand="goDoCommand('cmd_dt')"/>
+ <command id="cmd_dd" oncommand="goDoCommand('cmd_dd')"/>
+ <command id="cmd_removeList" oncommand="goDoCommand('cmd_removeList')"/>
+ <!-- cmd_ul and cmd_ol are shared with toolbar and are in composerStyleMenuItems commandset -->
+ </commandset>
+
+ <!-- File Menu -->
+ <command id="cmd_new" oncommand="goDoCommand('cmd_newMessage')"/>
+ <command id="cmd_attachFile" oncommand="goDoCommand('cmd_attachFile')"/>
+ <command id="cmd_attachCloud" oncommand="attachToCloud(event)"/>
+ <command id="cmd_attachPage" oncommand="goDoCommand('cmd_attachPage')"/>
+ <command id="cmd_attachVCard" checked="false"
+ oncommand="ToggleAttachVCard(event.target)"/>
+ <command id="cmd_attachPublicKey" checked="false"
+ oncommand="toggleAttachMyPublicKey(event.target)"/>
+ <command id="cmd_remindLater" checked="false"
+ oncommand="toggleAttachmentReminder()"/>
+ <command id="cmd_close" oncommand="goDoCommand('cmd_close')"/>
+ <command id="cmd_saveDefault" oncommand="goDoCommand('cmd_saveDefault')"/>
+ <command id="cmd_saveAsFile" oncommand="goDoCommand('cmd_saveAsFile')"/>
+ <command id="cmd_saveAsDraft" oncommand="goDoCommand('cmd_saveAsDraft')"/>
+ <command id="cmd_saveAsTemplate" oncommand="goDoCommand('cmd_saveAsTemplate')"/>
+ <command id="cmd_sendButton" oncommand="goDoCommand('cmd_sendButton')"/>
+ <command id="cmd_sendNow" oncommand="goDoCommand('cmd_sendNow')"/>
+ <command id="cmd_sendWithCheck" oncommand="goDoCommand('cmd_sendWithCheck')"/>
+ <command id="cmd_sendLater" oncommand="goDoCommand('cmd_sendLater')"/>
+ <command id="cmd_print" oncommand="goDoCommand('cmd_print')"/>
+
+ <!-- Edit Menu -->
+ <!--command id="cmd_pasteQuote"/ DO NOT INCLUDE THOSE COMMANDS ELSE THE EDIT MENU WILL BE BROKEN! -->
+ <!--command id="cmd_find"/-->
+ <!--command id="cmd_findNext"/-->
+ <command id="cmd_undo" oncommand="goDoCommand('cmd_undo')" disabled="true"/>
+ <command id="cmd_redo" oncommand="goDoCommand('cmd_redo')" disabled="true"/>
+ <command id="cmd_cut" oncommand="goDoCommand('cmd_cut')" disabled="true"/>
+ <command id="cmd_copy" oncommand="goDoCommand('cmd_copy')" disabled="true"/>
+ <command id="cmd_paste" oncommand="goDoCommand('cmd_paste')" disabled="true"/>
+ <command id="cmd_rewrap" oncommand="goDoCommand('cmd_rewrap')"/>
+ <command id="cmd_delete"
+ oncommand="goDoCommand('cmd_delete')"
+ valueDefault="&deleteCmd.label;"
+ valueDefaultAccessKey="&deleteCmd.accesskey;"
+ valueRemoveAttachmentAccessKey="&removeAttachment.accesskey;"
+ disabled="true"/>
+ <command id="cmd_selectAll"
+ oncommand="goDoCommand('cmd_selectAll')" disabled="true"/>
+ <command id="cmd_removeAllAttachments"
+ oncommand="goDoCommand('cmd_removeAllAttachments')"/>
+ <command id="cmd_openAttachment"
+ oncommand="goDoCommand('cmd_openAttachment')" disabled="true"/>
+ <command id="cmd_renameAttachment"
+ oncommand="goDoCommand('cmd_renameAttachment')" disabled="true"/>
+ <command id="cmd_reorderAttachments"
+ oncommand="goDoCommand('cmd_reorderAttachments')" disabled="true"/>
+ <command id="cmd_toggleAttachmentPane"
+ oncommand="goDoCommand('cmd_toggleAttachmentPane')"/>
+ <command id="cmd_account"
+ oncommand="goDoCommand('cmd_account')"/>
+
+ <!-- Reorder Attachments Panel -->
+ <command id="cmd_moveAttachmentLeft"
+ oncommand="goDoCommand('cmd_moveAttachmentLeft')" disabled="true"/>
+ <command id="cmd_moveAttachmentRight"
+ oncommand="goDoCommand('cmd_moveAttachmentRight')" disabled="true"/>
+ <command id="cmd_moveAttachmentBundleUp"
+ oncommand="goDoCommand('cmd_moveAttachmentBundleUp')" disabled="true"/>
+ <command id="cmd_moveAttachmentBundleDown"
+ oncommand="goDoCommand('cmd_moveAttachmentBundleDown')" disabled="true"/>
+ <command id="cmd_moveAttachmentTop"
+ oncommand="goDoCommand('cmd_moveAttachmentTop')" disabled="true"/>
+ <command id="cmd_moveAttachmentBottom"
+ oncommand="goDoCommand('cmd_moveAttachmentBottom')" disabled="true"/>
+ <command id="cmd_sortAttachmentsToggle"
+ sortdirection="ascending"
+ oncommand="goDoCommand('cmd_sortAttachmentsToggle')" disabled="true"/>
+
+ <!-- View Menu -->
+ <command id="cmd_showFormatToolbar"
+ oncommand="goDoCommand('cmd_showFormatToolbar')"/>
+
+ <commandset id="viewZoomCommands"
+ commandupdater="false"
+ events="create-menu-view"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+ <command id="cmd_fullZoomReduce"
+ oncommand="goDoCommand('cmd_fullZoomReduce');"/>
+ <command id="cmd_fullZoomEnlarge"
+ oncommand="goDoCommand('cmd_fullZoomEnlarge');"/>
+ <command id="cmd_fullZoomReset"
+ oncommand="goDoCommand('cmd_fullZoomReset');"/>
+ <command id="cmd_fullZoomToggle"
+ oncommand="goDoCommand('cmd_fullZoomToggle');"/>
+ </commandset>
+
+ <!-- Options Menu -->
+ <command id="cmd_quoteMessage" oncommand="goDoCommand('cmd_quoteMessage')"/>
+ <command id="cmd_toggleReturnReceipt"
+ oncommand="goDoCommand('cmd_toggleReturnReceipt')"/>
+ <command id="cmd_insert"/>
+ <command id="cmd_viewSecurityStatus"
+ oncommand="showMessageComposeSecurityStatus();"/>
+
+#ifdef XP_MACOSX
+ <!-- Mac Window menu -->
+ <command id="minimizeWindow" label="&minimizeWindow.label;" oncommand="window.minimize();"/>
+ <command id="zoomWindow" label="&zoomWindow.label;" oncommand="zoomWindow();"/>
+#endif
+
+ <command id="cmd_CustomizeComposeToolbar"
+ oncommand="CustomizeMailToolbar('compose-toolbox', 'CustomizeComposeToolbar')"/>
+
+ <command id="cmd_convertCloud" oncommand="convertSelectedToCloudAttachment(event.target.cloudFileAccount); event.stopPropagation();"/>
+ <command id="cmd_convertAttachment" oncommand="goDoCommand('cmd_convertAttachment')"/>
+ <command id="cmd_cancelUpload" oncommand="goDoCommand('cmd_cancelUpload')"/>
+ <command id="cmd_customizeFromAddress" oncommand="MakeFromFieldEditable();"
+ checked="false" label="&customizeFromAddress.label;"/>
+</commandset>
+
+ <commandset>
+ <command id="cmd_reload" oncommand="document.getElementById('requestFrame').reload()"/>
+ <command id="cmd_stop" oncommand="document.getElementById('requestFrame').stop()"/>
+ <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/>
+ <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/>
+ </commandset>
+
+<keyset id="tasksKeys">
+ <!-- File Menu -->
+ <key id="key_newMessage" key="&newMessageCmd2.key;" oncommand="goOpenNewMessage(null);" modifiers="accel"/>
+ <key id="key_close" key="&closeCmd.key;" command="cmd_close" modifiers="accel"/>
+ <key id="key_save" key="&saveCmd.key;" command="cmd_saveDefault" modifiers="accel"/>
+ <key id="key_send" keycode="&sendCmd.keycode;" observes="cmd_sendWithCheck" modifiers="accel"/>
+ <key id="key_sendLater" keycode="&sendLaterCmd.keycode;" observes="cmd_sendLater" modifiers="accel, shift"/>
+ <key id="key_print" key="&printCmd.key;" command="cmd_print" modifiers="accel"/>
+ <key id="printKb" key="&printCmd.key;" command="cmd_print" modifiers="accel"/>
+
+ <!-- Edit Menu -->
+ <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_redo"
+#ifdef XP_UNIX
+ data-l10n-id="text-action-undo-shortcut"
+ modifiers="accel,shift"
+#else
+ data-l10n-id="text-action-redo-shortcut"
+ modifiers="accel"
+#endif
+ internal="true"/>
+ <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" internal="true"/>
+ <key id="pastequotationkb" key="&pasteAsQuotationCmd.key;"
+ observes="cmd_pasteQuote" modifiers="accel, shift"/>
+ <key id="pastenoformattingkb" key="&pasteNoFormattingCmd.key;"
+ modifiers="accel, shift" observes="cmd_pasteNoFormatting"/>
+ <key id="key_rewrap" key="&editRewrapCmd.key;" command="cmd_rewrap" modifiers="accel"/>
+#ifdef XP_MACOSX
+ <key id="key_delete" keycode="VK_BACK" command="cmd_delete"/>
+ <key id="key_delete2" keycode="VK_DELETE" command="cmd_delete"/>
+#else
+ <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/>
+ <key id="key_renameAttachment" keycode="VK_F2"
+ command="cmd_renameAttachment"/>
+#endif
+ <key id="key_reorderAttachments"
+ key="&reorderAttachmentsCmd.key;" modifiers="accel,shift"
+ command="cmd_reorderAttachments"/>
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_find" key="&findBarCmd.key;" command="cmd_find" modifiers="accel"/>
+#ifndef XP_MACOSX
+ <key id="key_findReplace" key="&findReplaceCmd.key;" command="cmd_findReplace" modifiers="accel"/>
+#endif
+ <key id="key_findNext" key="&findAgainCmd.key;" command="cmd_findNext" modifiers="accel"/>
+ <key id="key_findPrev" key="&findPrevCmd.key;" command="cmd_findPrev" modifiers="accel, shift"/>
+ <key keycode="&findAgainCmd.key2;" command="cmd_findNext"/>
+ <key keycode="&findPrevCmd.key2;" command="cmd_findPrev" modifiers="shift"/>
+
+ <!-- Reorder Attachments Panel -->
+ <key id="key_moveAttachmentLeft" keycode="VK_LEFT" modifiers="alt"
+ command="cmd_moveAttachmentLeft"/>
+ <key id="key_moveAttachmentRight" keycode="VK_RIGHT" modifiers="alt"
+ command="cmd_moveAttachmentRight"/>
+ <key id="key_moveAttachmentBundleUp" keycode="VK_UP" modifiers="alt"
+ command="cmd_moveAttachmentBundleUp"/>
+ <key id="key_moveAttachmentBundleDown" keycode="VK_DOWN" modifiers="alt"
+ command="cmd_moveAttachmentBundleDown"/>
+#ifdef XP_MACOSX
+ <key id="key_moveAttachmentTop" keycode="VK_UP" modifiers="accel alt"
+ command="cmd_moveAttachmentTop"/>
+ <key id="key_moveAttachmentBottom" keycode="VK_DOWN" modifiers="accel alt"
+ command="cmd_moveAttachmentBottom"/>
+ <key id="key_moveAttachmentTop2" keycode="VK_Home" modifiers="alt"
+ command="cmd_moveAttachmentTop"/>
+ <key id="key_moveAttachmentBottom2" keycode="VK_End" modifiers="alt"
+ command="cmd_moveAttachmentBottom"/>
+#else
+ <key id="key_moveAttachmentTop" keycode="VK_Home" modifiers="alt"
+ command="cmd_moveAttachmentTop"/>
+ <key id="key_moveAttachmentBottom" keycode="VK_End" modifiers="alt"
+ command="cmd_moveAttachmentBottom"/>
+#endif
+ <key id="key_sortAttachmentsToggle" key="&sortAttachmentsPanelBtn.key;"
+ modifiers="alt" command="cmd_sortAttachmentsToggle"/>
+
+ <!-- View Menu -->
+ <key id="key_addressSidebar" keycode="VK_F9" oncommand="toggleContactsSidebar();"/>
+
+ <keyset id="viewZoomKeys">
+ <key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key key="&fullZoomReduceCmd.commandkey2;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey2;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey3;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key id="key_fullZoomReset" key="&fullZoomResetCmd.commandkey;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ <key key="&fullZoomResetCmd.commandkey2;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ </keyset>
+
+ <!-- Options Menu -->
+ <key id="key_checkspelling" key="&checkSpellingCmd2.key;" command="cmd_spelling" modifiers="accel,shift"/>
+
+#ifdef XP_WIN
+ <key keycode="&checkSpellingCmd2.key2;" command="cmd_spelling"/>
+#endif
+
+ <!-- Tools Menu -->
+ <key id="key_mail" key="&messengerCmd.commandkey;" oncommand="toMessengerWindow();" modifiers="accel"/>
+
+ <!-- Tab/F6 Keys -->
+ <key keycode="VK_TAB" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control"/>
+ <key keycode="VK_TAB" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control,shift"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control,shift"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="shift"/>
+ <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);"/>
+
+#ifdef XP_MACOSX
+ <!-- Mac Window Menu -->
+ <key id="key_minimizeWindow" command="minimizeWindow" key="&minimizeWindow.key;" modifiers="accel"/>
+ <key id="key_openHelp" oncommand="openSupportURL();" key="&productHelpMac.commandkey;" modifiers="&productHelpMac.modifiers;"/>
+#else
+ <key id="key_openHelp" oncommand="openSupportURL();" keycode="&productHelp.commandkey;"/>
+#endif
+ <key keycode="VK_ESCAPE" oncommand="handleEsc();"/>
+</keyset>
+
+<keyset id="editorKeys">
+ <key id="boldkb" key="&styleBoldCmd.key;" observes="cmd_bold" modifiers="accel"/>
+ <key id="italickb" key="&styleItalicCmd.key;" observes="cmd_italic" modifiers="accel"/>
+ <key id="underlinekb" key="&styleUnderlineCmd.key;" observes="cmd_underline" modifiers="accel"/>
+ <key id="fixedwidthkb" key="&fontFixedWidth.key;" observes="cmd_tt" modifiers="accel"/>
+
+ <key id="increaseindentkb" key="&increaseIndent.key;" observes="cmd_indent" modifiers="accel"/>
+ <key id="decreaseindentkb" key="&decreaseIndent.key;" observes="cmd_outdent" modifiers="accel"/>
+
+ <key id="removestyleskb" key="&formatRemoveStyles.key;" observes="cmd_removeStyles" modifiers="accel, shift"/>
+ <key id="removestyleskb2" key=" " observes="cmd_removeStyles" modifiers="accel"/>
+ <key id="removelinkskb" key="&formatRemoveLinks.key;" observes="cmd_removeLinks" modifiers="accel, shift"/>
+ <key id="removenamedanchorskb" key="&formatRemoveNamedAnchors2.key;" observes="cmd_removeNamedAnchors" modifiers="accel, shift"/>
+ <key id="decreasefontsizekb" key="&decrementFontSize.key;" observes="cmd_decreaseFontStep" modifiers="accel"/>
+ <key key="&decrementFontSize.key;" observes="cmd_decreaseFontStep" modifiers="accel, shift"/>
+ <key key="&decrementFontSize.key2;" observes="cmd_decreaseFontStep" modifiers="accel"/>
+
+ <key id="increasefontsizekb" key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel"/>
+ <key key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel, shift"/>
+ <key key="&incrementFontSize.key2;" observes="cmd_increaseFontStep" modifiers="accel"/>
+
+ <key id="insertlinkkb" key="&insertLinkCmd2.key;" observes="cmd_link" modifiers="accel"/>
+</keyset>
+
+<popupset id="mainPopupSet">
+#include ../../../base/content/widgets/browserPopups.inc.xhtml
+</popupset>
+
+<!-- Reorder Attachments Panel -->
+<panel id="reorderAttachmentsPanel"
+ orient="vertical"
+ type="arrow"
+ flip="slide"
+ onpopupshowing="reorderAttachmentsPanelOnPopupShowing();"
+ consumeoutsideclicks="false"
+ noautohide="true">
+ <description class="panelTitle">&reorderAttachmentsPanel.label;</description>
+ <toolbarbutton id="btn_moveAttachmentFirst"
+ class="panelButton"
+ data-l10n-id="move-attachment-first-panel-button"
+ key="key_moveAttachmentTop"
+ command="cmd_moveAttachmentTop"/>
+ <toolbarbutton id="btn_moveAttachmentLeft"
+ class="panelButton"
+ data-l10n-id="move-attachment-left-panel-button"
+ key="key_moveAttachmentLeft"
+ command="cmd_moveAttachmentLeft"/>
+ <toolbarbutton id="btn_moveAttachmentBundleUp"
+ class="panelButton"
+ label="&moveAttachmentBundleUpPanelBtn.label;"
+ key="key_moveAttachmentBundleUp"
+ command="cmd_moveAttachmentBundleUp"/>
+ <toolbarbutton id="btn_moveAttachmentRight"
+ class="panelButton"
+ data-l10n-id="move-attachment-right-panel-button"
+ key="key_moveAttachmentRight"
+ command="cmd_moveAttachmentRight"/>
+ <toolbarbutton id="btn_moveAttachmentLast"
+ class="panelButton"
+ data-l10n-id="move-attachment-last-panel-button"
+ key="key_moveAttachmentBottom"
+ command="cmd_moveAttachmentBottom"/>
+ <toolbarbutton id="btn_sortAttachmentsToggle"
+ class="panelButton"
+ label="&sortAttachmentsPanelBtn.Sort.AZ.label;"
+ label-AZ="&sortAttachmentsPanelBtn.Sort.AZ.label;"
+ label-ZA="&sortAttachmentsPanelBtn.Sort.ZA.label;"
+ label-selection-AZ="&sortAttachmentsPanelBtn.SortSelection.AZ.label;"
+ label-selection-ZA="&sortAttachmentsPanelBtn.SortSelection.ZA.label;"
+ key="key_sortAttachmentsToggle"
+ command="cmd_sortAttachmentsToggle"/>
+</panel>
+
+<menupopup id="extraAddressRowsMenu"
+ class="no-icon-menupopup no-accel-menupopup"
+ onpopupshown="extraAddressRowsMenuOpened();"
+ onpopuphidden="extraAddressRowsMenuClosed();">
+ <!-- Default set up is for a mail account, where we prefer showing the
+ - buttons, rather than the menu items, for the mail rows.
+ - For the news rows, we prefer the menu items over the buttons. -->
+ <menuitem id="addr_replyShowAddressRowMenuItem"
+ class="menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowReply')"
+ label="&replyAddr2.label;"/>
+ <menuitem id="addr_toShowAddressRowMenuItem" disableonsend="true"
+ class="mail-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowTo')"
+ hidden="true"
+ data-button-id="addr_toShowAddressRowButton"
+ data-prefer-button="true"/>
+ <menuitem id="addr_ccShowAddressRowMenuItem" disableonsend="true"
+ class="mail-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowCc')"
+ hidden="true"
+ data-button-id="addr_ccShowAddressRowButton"
+ data-prefer-button="true"/>
+ <menuitem id="addr_bccShowAddressRowMenuItem" disableonsend="true"
+ class="mail-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowBcc')"
+ hidden="true"
+ data-button-id="addr_bccShowAddressRowButton"
+ data-prefer-button="true"/>
+ <menuitem id="addr_newsgroupsShowAddressRowMenuItem"
+ class="news-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowNewsgroups')"
+ data-button-id="addr_newsgroupsShowAddressRowButton"
+ label="&newsgroupsAddr2.label;"
+ data-prefer-button="false"/>
+ <menuitem id="addr_followupShowAddressRowMenuItem"
+ class="news-show-row-menuitem menuitem-iconic"
+ oncommand="showAndFocusAddressRow('addressRowFollowup')"
+ data-button-id="addr_followupShowAddressRowButton"
+ label="&followupAddr2.label;"
+ data-prefer-button="false"/>
+</menupopup>
+
+<menupopup id="msgComposeContext"
+ onpopupshowing="msgComposeContextOnShowing(event);"
+ onpopuphiding="msgComposeContextOnHiding(event);">
+
+ <!-- Spellchecking menu items -->
+ <menuitem id="spellCheckNoSuggestions"
+ data-l10n-id="text-action-spell-no-suggestions"
+ disabled="true"/>
+ <menuseparator id="spellCheckAddSep" />
+ <menuitem id="spellCheckAddToDictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="gSpellChecker.addToDictionary();"/>
+ <menuitem id="spellCheckUndoAddToDictionary"
+ data-l10n-id="text-action-spell-undo-add-to-dictionary"
+ oncommand="gSpellChecker.undoAddToDictionary();" />
+ <menuitem id="spellCheckIgnoreWord" label="&spellCheckIgnoreWord.label;"
+ accesskey="&spellCheckIgnoreWord.accesskey;"
+ oncommand="gSpellChecker.ignoreWord();"/>
+ <menuseparator id="spellCheckSuggestionsSeparator"/>
+
+ <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/>
+ <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/>
+ <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/>
+ <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/>
+ <menuitem command="cmd_pasteNoFormatting"/>
+ <menuitem label="&pasteQuote.label;" accesskey="&pasteQuote.accesskey;" command="cmd_pasteQuote"/>
+ <menuitem data-l10n-id="text-action-delete" command="cmd_delete"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/>
+
+ <!-- Spellchecking general menu items (enable, add dictionaries...) -->
+ <menuseparator id="spellCheckSeparator"/>
+ <menuitem id="spellCheckEnable"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="toggleSpellCheckingEnabled();"/>
+ <menuitem id="spellCheckAddDictionariesMain"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="openDictionaryList();"/>
+ <menu id="spellCheckDictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="spellCheckDictionariesMenu">
+ <menuseparator id="spellCheckLanguageSeparator"/>
+ <menuitem id="spellCheckAddDictionaries"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="openDictionaryList();"/>
+ </menupopup>
+ </menu>
+
+</menupopup>
+
+<menupopup id="msgComposeAttachmentItemContext"
+ onpopupshowing="updateAttachmentItems();">
+ <menuitem id="composeAttachmentContext_openItem"
+ label="&openAttachment.label;"
+ accesskey="&openAttachment.accesskey;"
+ command="cmd_openAttachment"/>
+ <menuitem id="composeAttachmentContext_renameItem"
+ label="&renameAttachment.label;"
+ accesskey="&renameAttachment.accesskey;"
+ command="cmd_renameAttachment"/>
+ <menuitem id="composeAttachmentContext_reorderItem"
+ label="&reorderAttachments.label;"
+ accesskey="&reorderAttachments.accesskey;"
+ command="cmd_reorderAttachments"/>
+ <menuseparator id="composeAttachmentContext_beforeRemoveSeparator"/>
+ <menuitem id="composeAttachmentContext_deleteItem"
+ label="&removeAttachment.label;"
+ accesskey="&removeAttachment.accesskey;"
+ command="cmd_delete"/>
+ <menu id="composeAttachmentContext_convertCloudMenu"
+ label="&convertCloud.label;"
+ accesskey="&convertCloud.accesskey;"
+ command="cmd_convertCloud">
+ <menupopup id="convertCloudMenuItems_popup"
+ onpopupshowing="addConvertCloudMenuItems(this, 'convertCloudSeparator', 'context_convertCloud');">
+ <menuitem id="convertCloudMenuItems_popup_convertAttachment"
+ type="radio" name="context_convertCloud"
+ label="&convertRegularAttachment.label;"
+ accesskey="&convertRegularAttachment.accesskey;"
+ command="cmd_convertAttachment"/>
+ <menuseparator id="convertCloudSeparator"/>
+ </menupopup>
+ </menu>
+ <menuitem id="composeAttachmentContext_cancelUploadItem"
+ label="&cancelUpload.label;"
+ accesskey="&cancelUpload.accesskey;"
+ command="cmd_cancelUpload"/>
+ <menuseparator/>
+ <menuitem id="composeAttachmentContext_selectAllItem"
+ label="&selectAll.label;"
+ accesskey="&selectAll.accesskey;"
+ command="cmd_selectAll"/>
+</menupopup>
+
+<menupopup id="msgComposeAttachmentListContext"
+ onpopupshowing="updateAttachmentItems();">
+ <menuitem id="attachmentListContext_selectAllItem"
+ label="&selectAll.label;"
+ accesskey="&selectAll.accesskey;"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="attachmentListContext_attachFileItem"
+ data-l10n-id="context-menuitem-attach-files"
+ data-l10n-attrs="acceltext"
+ command="cmd_attachFile"/>
+ <menu id="attachmentListContext_attachCloudMenu"
+ label="&attachCloud.label;"
+ accesskey="&attachCloud.accesskey;"
+ command="cmd_attachCloud">
+ <menupopup id="attachCloudMenu_attachCloudPopup" onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/>
+ </menu>
+ <menuitem id="attachmentListContext_attachPageItem"
+ label="&attachPage.label;"
+ accesskey="&attachPage.accesskey;"
+ command="cmd_attachPage"/>
+ <menuseparator id="attachmentListContext_remindLaterSeparator"/>
+ <menuitem id="attachmentListContext_remindLaterItem"
+ type="checkbox"
+ label="&remindLater.label;"
+ accesskey="&remindLater.accesskey;"
+ command="cmd_remindLater"/>
+ <menuitem id="attachmentListContext_reorderItem"
+ label="&reorderAttachments.label;"
+ accesskey="&reorderAttachments.accesskey;"
+ command="cmd_reorderAttachments"/>
+ <menuseparator id="attachmentListContext_removeAllSeparator"/>
+ <menuitem id="attachmentListContext_removeAllItem"
+ label="&removeAllAttachments.label;"
+ accesskey="&removeAllAttachments.accesskey;"
+ command="cmd_removeAllAttachments"/>
+</menupopup>
+
+<menupopup id="attachmentHeaderContext"
+ onpopupshowing="attachmentHeaderContextOnPopupShowing();">
+ <menuitem id="attachmentHeaderContext_initiallyShowItem"
+ type="checkbox"
+ label="&initiallyShowAttachmentPane.label;"
+ accesskey="&initiallyShowAttachmentPane.accesskey;"
+ oncommand="toggleInitiallyShowAttachmentPane(this);"/>
+</menupopup>
+
+<menupopup id="format-toolbar-context-menu"
+ onpopupshowing="ToolbarContextMenu.updateExtension(this);">
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+
+<menupopup id="toolbar-context-menu"
+ onpopupshowing="onViewToolbarsPopupShowing(event, 'compose-toolbox'); ToolbarContextMenu.updateExtension(this);">
+ <menuseparator/>
+ <menuitem id="CustomizeComposeToolbar"
+ command="cmd_CustomizeComposeToolbar"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ <menuseparator id="extensionsMailToolbarMenuSeparator"/>
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+
+<menupopup id="blockedContentOptions" value=""
+ onpopupshowing="onBlockedContentOptionsShowing(event);">
+</menupopup>
+
+<menupopup id="languageMenuList"
+ oncommand="ChangeLanguage(event);"
+ onpopupshowing="OnShowDictionaryMenu(event.target);"
+ onpopupshown="languageMenuListOpened();"
+ onpopuphidden="languageMenuListClosed();">
+</menupopup>
+
+<menupopup id="emailAddressPillPopup"
+ class="emailAddressPopup"
+ onpopupshowing="onPillPopupShowing(event);">
+ <menuitem id="editAddressPill"
+ class="pill-action-edit"
+ data-l10n-id="pill-action-edit"
+ oncommand="editAddressPill(this.parentNode.triggerNode, event)"/>
+ <menuitem id="menu_delete"
+ data-l10n-id="text-action-delete"
+ oncommand="deleteSelectedPillsOnCommand()"/>
+ <menuseparator/>
+ <menuitem id="menu_cut"
+ data-l10n-id="text-action-cut"
+ oncommand="cutSelectedPillsOnCommand()"/>
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ oncommand="copySelectedPillsOnCommand()"/>
+ <menuseparator id="pillContextBeforeSelectAllSeparator"/>
+ <menuitem id="menu_selectAllSiblingPills"
+ oncommand="selectAllSiblingPillsOnCommand(this.parentNode.triggerNode)"/>
+ <menuitem id="menu_selectAllPills"
+ data-l10n-id="pill-action-select-all-pills"
+ oncommand="selectAllPillsOnCommand()"/>
+ <menuseparator id="pillContextBeforeMoveItemsSeparator"/>
+ <menuitem id="moveAddressPillTo"
+ class="pill-action-move"
+ data-l10n-id="pill-action-move-to"
+ oncommand="moveSelectedPillsOnCommand('addressRowTo')"/>
+ <menuitem id="moveAddressPillCc"
+ class="pill-action-move"
+ data-l10n-id="pill-action-move-cc"
+ oncommand="moveSelectedPillsOnCommand('addressRowCc')"/>
+ <menuitem id="moveAddressPillBcc"
+ class="pill-action-move"
+ data-l10n-id="pill-action-move-bcc"
+ oncommand="moveSelectedPillsOnCommand('addressRowBcc')"/>
+ <menuseparator id="pillContextBeforeExpandListSeparator"/>
+ <menuitem id="expandList"
+ class="pill-action-edit"
+ data-l10n-id="pill-action-expand-list"
+ hidden="true"
+ oncommand="expandList(this.parentNode.triggerNode)"/>
+</menupopup>
+
+ <toolbox id="compose-toolbox"
+ class="toolbox-top"
+ mode="full"
+ defaultmode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+ defaulticonsize="small"
+#endif
+ labelalign="end"
+ defaultlabelalign="end">
+
+#ifdef XP_MACOSX
+ <hbox id="titlebar">
+ <hbox id="titlebar-title" align="center" flex="1">
+ <label id="titlebar-title-label" value="&msgComposeWindow.title;" flex="1" crop="end"/>
+ </hbox>
+#include ../../../base/content/messenger-titlebar-items.inc.xhtml
+ </hbox>
+#endif
+ <!-- Menu -->
+ <!-- if you change the id of the menubar, be sure to update mailCore.js::CustomizeMailToolbar and MailToolboxCustomizeDone -->
+ <toolbar is="customizable-toolbar" id="compose-toolbar-menubar2"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+ customizable="true"
+#ifdef XP_MACOSX
+ defaultset="menubar-items"
+#else
+ defaultset="menubar-items,spring"
+#endif
+#ifdef XP_WIN
+ toolbarname="&menubarCmd.label;"
+ accesskey="&menubarCmd.accesskey;"
+#endif
+ context="toolbar-context-menu" mode="full">
+
+ <toolbaritem id="menubar-items" align="center">
+ <menubar id="mail-menubar">
+ <menu id="menu_File" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;">
+ <menupopup id="menu_FilePopup">
+ <menu id="menu_New"
+ label="&newMenu.label;" accesskey="&newMenu.accesskey;">
+ <menupopup id="menu_NewPopup">
+ <menuitem id="menu_NewMessage"
+ label="&newMessage.label;" accesskey="&newMessage.accesskey;"
+ key="key_newMessage" oncommand="goOpenNewMessage(event);"/>
+ <menuseparator/>
+ <menuitem id="menu_NewContact" label="&newContact.label;"
+ accesskey="&newContact.accesskey;"
+ oncommand="toAddressBook({ action: 'create' });"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_Attach" label="&attachMenu.label;" accesskey="&attachMenu.accesskey;">
+ <menupopup id="menu_AttachPopup" onpopupshowing="updateAttachmentItems();">
+ <menuitem data-l10n-id="menuitem-attach-files"
+ data-l10n-attrs="acceltext"
+ command="cmd_attachFile"/>
+ <menu label="&attachCloudCmd.label;" accesskey="&attachCloudCmd.accesskey;"
+ command="cmd_attachCloud">
+ <menupopup onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/>
+ </menu>
+ <menuitem label="&attachPageCmd.label;"
+ accesskey="&attachPageCmd.accesskey;" command="cmd_attachPage"/>
+ <menuseparator/>
+ <menuitem type="checkbox"
+ data-l10n-id="context-menuitem-attach-vcard"
+ command="cmd_attachVCard"/>
+ <menuitem id="menu_AttachPopup_attachPublicKey" type="checkbox"
+ data-l10n-id="context-menuitem-attach-openpgp-key"
+ command="cmd_attachPublicKey"/>
+ <menuseparator id="menu_Attach_RemindLaterSeparator"/>
+ <menuitem id="menu_Attach_RemindLaterItem" type="checkbox" label="&remindLater.label;"
+ accesskey="&remindLater.accesskey;" command="cmd_remindLater"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem id="menu_SaveCmd" label="&saveCmd.label;" accesskey="&saveCmd.accesskey;"
+ key="key_save" command="cmd_saveDefault"/>
+ <menu id="menu_SaveAsCmd"
+ label="&saveAsCmd.label;" accesskey="&saveAsCmd.accesskey;">
+ <menupopup id="menu_SaveAsCmdPopup" onpopupshowing="InitFileSaveAsMenu();">
+ <menuitem id="menu_SaveAsFileCmd"
+ label="&saveAsFileCmd.label;" accesskey="&saveAsFileCmd.accesskey;"
+ command="cmd_saveAsFile" type="radio" name="radiogroup_SaveAs"/>
+ <menuseparator/>
+ <menuitem label="&saveAsDraftCmd.label;" accesskey="&saveAsDraftCmd.accesskey;"
+ command="cmd_saveAsDraft" type="radio" name="radiogroup_SaveAs"/>
+ <menuitem label="&saveAsTemplateCmd.label;" accesskey="&saveAsTemplateCmd.accesskey;"
+ command="cmd_saveAsTemplate" type="radio" name="radiogroup_SaveAs"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem label="&sendNowCmd.label;"
+ accesskey="&sendNowCmd.accesskey;" key="key_send"
+ command="cmd_sendNow" id="menu-item-send-now"/>
+ <menuitem label="&sendLaterCmd.label;"
+ accesskey="&sendLaterCmd.accesskey;" key="key_sendLater"
+ command="cmd_sendLater"/>
+ <menuseparator/>
+ <menuitem id="printMenuItem"
+ label="&printCmd.label;" accesskey="&printCmd.accesskey;"
+ key="key_print" command="cmd_print"/>
+ <menuseparator id="menu_FileCloseSeparator"/>
+ <menuitem id="menu_close"
+ label="&closeCmd.label;"
+ key="key_close"
+ accesskey="&closeCmd.accesskey;"
+ command="cmd_close"/>
+ </menupopup>
+ </menu>
+
+ <!-- Edit Menu -->
+ <menu id="menu_Edit" label="&editMenu.label;" accesskey="&editMenu.accesskey;">
+ <menupopup id="menu_EditPopup" onpopupshowing="updateEditItems();">
+ <menuitem id="menu_undo"
+ data-l10n-id="text-action-undo"
+ key="key_undo" command="cmd_undo"/>
+ <menuitem id="menu_redo"
+ data-l10n-id="text-action-redo"
+ key="key_redo" command="cmd_redo"/>
+ <menuseparator/>
+ <menuitem id="menu_cut"
+ data-l10n-id="text-action-cut"
+ key="key_cut" command="cmd_cut"/>
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy" command="cmd_copy"/>
+ <menuitem id="menu_paste"
+ data-l10n-id="text-action-paste"
+ key="key_paste" command="cmd_paste"/>
+ <menuitem id="menu_pasteNoFormatting"
+ command="cmd_pasteNoFormatting" key="pastenoformattingkb"/>
+ <menuitem id="menu_pasteQuote"
+ accesskey="&pasteAsQuotationCmd.accesskey;"
+ command="cmd_pasteQuote"
+ key="pastequotationkb"/>
+ <menuitem id="menu_delete"
+ data-l10n-id="text-action-delete"
+ key="key_delete"
+ command="cmd_delete"/>
+ <menuseparator/>
+ <menuitem id="menu_rewrap"
+ label="&editRewrapCmd.label;"
+ accesskey="&editRewrapCmd.accesskey;"
+ key="key_rewrap"
+ command="cmd_rewrap"/>
+ <menuitem id="menu_RenameAttachment"
+ label="&renameAttachmentCmd.label;"
+ accesskey="&renameAttachmentCmd.accesskey;"
+ key="key_renameAttachment"
+ command="cmd_renameAttachment"/>
+ <menuitem id="menu_reorderAttachments"
+ label="&reorderAttachmentsCmd.label;"
+ accesskey="&reorderAttachmentsCmd.accesskey;"
+ key="key_reorderAttachments"
+ command="cmd_reorderAttachments"/>
+ <menuseparator/>
+ <menuitem id="menu_selectAll"
+ data-l10n-id="text-action-select-all"
+ key="key_selectAll"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="menu_findBar"
+ label="&findBarCmd.label;"
+ accesskey="&findBarCmd.accesskey;"
+ key="key_find"
+ command="cmd_find"/>
+#ifndef XP_MACOSX
+ <menuitem id="menu_findReplace"
+ label="&findReplaceCmd.label;"
+ accesskey="&findReplaceCmd.accesskey;"
+ key="key_findReplace"
+ command="cmd_findReplace"/>
+#else
+ <menuitem id="menu_findReplace"
+ label="&findReplaceCmd.label;"
+ accesskey="&findReplaceCmd.accesskey;"
+ command="cmd_findReplace"/>
+#endif
+ <menuitem id="menu_findNext"
+ label="&findAgainCmd.label;"
+ accesskey="&findAgainCmd.accesskey;"
+ key="key_findNext"
+ command="cmd_findNext"/>
+ <menuitem id="menu_findPrev"
+ label="&findPrevCmd.label;"
+ accesskey="&findPrevCmd.accesskey;"
+ key="key_findPrev"
+ command="cmd_findPrev"/>
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmdUnix2.accesskey;"
+ command="cmd_account"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-tools-settings"
+ oncommand="openOptionsDialog('paneCompose');"/>
+#endif
+#endif
+ </menupopup>
+ </menu>
+
+ <!-- View Menu -->
+ <menu id="menu_View" label="&viewMenu.label;" accesskey="&viewMenu.accesskey;">
+ <menupopup id="menu_View_Popup" onpopupshowing="updateViewItems();">
+ <menu id="menu_ToolbarsNew"
+ label="&viewToolbarsMenuNew.label;"
+ accesskey="&viewToolbarsMenuNew.accesskey;"
+ onpopupshowing="onViewToolbarsPopupShowing(event, 'compose-toolbox');">
+ <menupopup id="view_toolbars_popup">
+ <menuitem id="menu_showFormatToolbar"
+ type="checkbox"
+ label="&showFormattingBarCmd.label;"
+ accesskey="&showFormattingBarCmd.accesskey;"
+ command="cmd_showFormatToolbar"
+ checked="true"/>
+ <menuitem id="menu_showTaskbar"
+ type="checkbox"
+ label="&showTaskbarCmd.label;"
+ accesskey="&showTaskbarCmd.accesskey;"
+ oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')"
+ checked="true"/>
+ <menuseparator id="viewMenuBeforeCustomizeComposeToolbarsSeparator"/>
+ <menuitem id="customizeComposeToolbars"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"
+ command="cmd_CustomizeComposeToolbar"/>
+ </menupopup>
+ </menu>
+ <menu id="viewFullZoomMenu" label="&fullZoom.label;" accesskey="&fullZoom.accesskey;"
+ onpopupshowing="UpdateFullZoomMenu()">
+ <menupopup id="viewFullZoomPopupMenu">
+ <menuitem id="menu_fullZoomEnlarge" key="key_fullZoomEnlarge"
+ label="&fullZoomEnlargeCmd.label;"
+ accesskey="&fullZoomEnlargeCmd.accesskey;"
+ command="cmd_fullZoomEnlarge"/>
+ <menuitem id="menu_fullZoomReduce" key="key_fullZoomReduce"
+ label="&fullZoomReduceCmd.label;"
+ accesskey="&fullZoomReduceCmd.accesskey;"
+ command="cmd_fullZoomReduce"/>
+ <menuseparator id="fullZoomAfterReduceSeparator"/>
+ <menuitem id="menu_fullZoomReset" key="key_fullZoomReset"
+ label="&fullZoomResetCmd.label;"
+ accesskey="&fullZoomResetCmd.accesskey;"
+ command="cmd_fullZoomReset"/>
+ <menuseparator id="fullZoomAfterResetSeparator"/>
+ <menuitem id="menu_fullZoomToggle" label="&fullZoomToggleCmd.label;"
+ accesskey="&fullZoomToggleCmd.accesskey;"
+ type="checkbox" command="cmd_fullZoomToggle" checked="false"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="viewMenuBeforeShowToFieldSeparator"/>
+ <menuitem id="menu_showToField"
+ data-l10n-attrs="acceltext"
+ oncommand="showAndFocusAddressRow('addressRowTo')"/>
+ <menuitem id="menu_showCcField"
+ data-l10n-attrs="acceltext"
+ oncommand="showAndFocusAddressRow('addressRowCc')"/>
+ <menuitem id="menu_showBccField"
+ data-l10n-attrs="acceltext"
+ oncommand="showAndFocusAddressRow('addressRowBcc')"/>
+ <menuseparator id="viewMenuBeforeAddressSidebarSeparator"/>
+ <menuitem id="menu_AddressSidebar"
+ label="&addressSidebar.label;" accesskey="&addressSidebar.accesskey;"
+ type="checkbox"
+ key="key_addressSidebar"
+ oncommand="toggleContactsSidebar();"/>
+ <menuitem id="menu_toggleAttachmentPane"
+ data-l10n-id="menuitem-toggle-attachment-pane"
+ data-l10n-attrs="acceltext"
+ type="checkbox"
+ command="cmd_toggleAttachmentPane"/>
+ </menupopup>
+ </menu>
+
+ <menu id="insertMenu" label="&insertMenu.label;"
+ accesskey="&insertMenu.accesskey;" command="cmd_renderedHTMLEnabler">
+ <menupopup id="insertMenuPopup">
+ <menuitem id="insertImage"
+ label="&insertImageCmd.label;"
+ accesskey="&insertImageCmd.accesskey;"
+ observes="cmd_image"/>
+ <menuitem id="insertTable"
+ label="&insertTableCmd.label;"
+ accesskey="&insertTableCmd.accesskey;"
+ observes="cmd_InsertTable"/>
+ <menuitem id="insertLink"
+ label="&insertLinkCmd2.label;"
+ accesskey="&insertLinkCmd2.accesskey;"
+ key="insertlinkkb"
+ observes="cmd_link"/>
+ <menuitem id="insertAnchor"
+ label="&insertAnchorCmd.label;"
+ accesskey="&insertAnchorCmd.accesskey;"
+ observes="cmd_anchor"/>
+ <menuitem id="insertHline"
+ label="&insertHLineCmd.label;"
+ accesskey="&insertHLineCmd.accesskey;"
+ observes="cmd_hline"/>
+ <menuitem id="insertHTMLSource"
+ accesskey="&insertHTMLCmd.accesskey;"
+ observes="cmd_insertHTMLWithDialog"/>
+ <menuitem id="insertMath"
+ accesskey="&insertMathCmd.accesskey;"
+ observes="cmd_insertMathWithDialog"/>
+ <menuitem id="insertChars"
+ accesskey="&insertCharsCmd.accesskey;"
+ command="cmd_insertChars"/>
+
+ <menu id="insertTOC" label="&tocMenu.label;" accesskey="&tocMenu.accesskey;">
+ <menupopup id="insertTOCPopup" onpopupshowing="InitTOCMenu()">
+ <menuitem id="insertTOCMenuitem"
+ label="&insertTOC.label;"
+ accesskey="&insertTOC.accesskey;"
+ oncommand="UpdateTOC()"/>
+ <menuitem id="updateTOCMenuitem"
+ label="&updateTOC.label;"
+ accesskey="&updateTOC.accesskey;"
+ oncommand="UpdateTOC()"/>
+ <menuitem id="removeTOCMenuitem"
+ label="&removeTOC.label;"
+ accesskey="&removeTOC.accesskey;"
+ oncommand="RemoveTOC()"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="insertMenuSeparator"/>
+ <menuitem id="insertBreakAll"
+ accesskey="&insertBreakAllCmd.accesskey;"
+ observes="cmd_insertBreakAll"
+ label="&insertBreakAllCmd.label;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="formatMenu" label="&formatMenu.label;" accesskey="&formatMenu.accesskey;" command="cmd_renderedHTMLEnabler">
+ <menupopup id="formatMenuPopup" onpopupshowing="EditorInitFormatMenu()">
+ <!-- Font face submenu -->
+ <menu id="fontFaceMenu"
+ label="&fontfaceMenu.label;" accesskey="&fontfaceMenu.accesskey;"
+ position="1">
+ <menupopup id="fontFaceMenuPopup"
+ oncommand="if (event.target.localName == 'menuitem') {
+ doStatefulCommand('cmd_fontFace', event.target.getAttribute('value'));
+ }"
+ onpopupshowing="initFontFaceMenu(this);">
+ <menuitem id="menu_fontFaceVarWidth"
+ label="&fontVarWidth.label;"
+ accesskey="&fontVarWidth.accesskey;"
+ value=""
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_fontFaceFixedWidth"
+ label="&fontFixedWidth.label;"
+ accesskey="&fontFixedWidth.accesskey;"
+ value="monospace"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuseparator id="fontFaceMenuAfterGenericFontsSeparator"/>
+ <menuitem id="menu_fontFaceHelvetica"
+ label="&fontHelvetica.label;"
+ accesskey="&fontHelvetica.accesskey;"
+ value="Helvetica, Arial, sans-serif"
+ value_parsed="helvetica,arial,sans-serif"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_fontFaceTimes"
+ label="&fontTimes.label;"
+ accesskey="&fontTimes.accesskey;"
+ value="Times New Roman, Times, serif"
+ value_parsed="times new roman,times,serif"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_fontFaceCourier"
+ label="&fontCourier.label;"
+ accesskey="&fontCourier.accesskey;"
+ value="Courier New, Courier, monospace"
+ value_parsed="courier new,courier,monospace"
+ type="radio"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuseparator id="fontFaceMenuAfterDefaultFontsSeparator"
+ class="fontFaceMenuAfterDefaultFonts"/>
+ <menuseparator id="fontFaceMenuAfterUsedFontsSeparator"
+ class="fontFaceMenuAfterUsedFonts"
+ hidden="true"/>
+ <!-- Local font face items added here by initLocalFontFaceMenu() -->
+ </menupopup>
+ </menu>
+
+ <!-- Font size submenu -->
+ <menu id="fontSizeMenu" label="&fontSizeMenu.label;"
+ accesskey="&fontSizeMenu.accesskey;"
+ position="2">
+ <menupopup id="fontSizeMenuPopup"
+ onpopupshowing="initFontSizeMenu(this)"
+ oncommand="setFontSize(event)">
+ <menuitem id="menu_decreaseFontSize"
+ label="&decreaseFontSize.label;"
+ accesskey="&decreaseFontSize.accesskey;"
+ key="decreasefontsizekb"
+ observes="cmd_decreaseFontStep"
+ type="radio" name="decreaseFontSize" autocheck="false"/>
+ <menuitem id="menu_increaseFontSize"
+ label="&increaseFontSize.label;"
+ accesskey="&increaseFontSize.accesskey;"
+ key="increasefontsizekb"
+ observes="cmd_increaseFontStep"
+ type="radio" name="increaseFontSize" autocheck="false"/>
+ <menuseparator id="fontSizeMenuAfterIncreaseFontSizeSeparator"/>
+ <menuitem id="menu_size-x-small"
+ label="&size-tinyCmd.label;"
+ accesskey="&size-tinyCmd.accesskey;"
+ value="1"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-small"
+ label="&size-smallCmd.label;"
+ accesskey="&size-smallCmd.accesskey;"
+ value="2"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-medium"
+ label="&size-mediumCmd.label;"
+ accesskey="&size-mediumCmd.accesskey;"
+ value="3"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-large"
+ label="&size-largeCmd.label;"
+ accesskey="&size-largeCmd.accesskey;"
+ value="4"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-x-large"
+ label="&size-extraLargeCmd.label;"
+ accesskey="&size-extraLargeCmd.accesskey;"
+ value="5"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_size-xx-large"
+ label="&size-hugeCmd.label;"
+ accesskey="&size-hugeCmd.accesskey;"
+ value="6"
+ type="radio" name="fontSize"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+
+ <!-- Font style submenu -->
+ <menu id="fontStyleMenu" label="&fontStyleMenu.label;"
+ accesskey="&fontStyleMenu.accesskey;"
+ position="3">
+ <menupopup id="fontStyleMenuPopup" onpopupshowing="initFontStyleMenu(this)">
+ <menuitem id="menu_styleBold"
+ label="&styleBoldCmd.label;"
+ accesskey="&styleBoldCmd.accesskey;"
+ observes="cmd_bold"
+ type="checkbox"
+ key="boldkb"/>
+ <menuitem id="menu_styleItalic"
+ label="&styleItalicCmd.label;"
+ accesskey="&styleItalicCmd.accesskey;"
+ observes="cmd_italic"
+ type="checkbox"
+ key="italickb"/>
+ <menuitem id="menu_styleUnderline"
+ label="&styleUnderlineCmd.label;"
+ accesskey="&styleUnderlineCmd.accesskey;"
+ observes="cmd_underline"
+ type="checkbox"
+ key="underlinekb"/>
+ <menuitem id="menu_styleStrikeThru"
+ label="&styleStrikeThruCmd.label;"
+ accesskey="&styleStrikeThruCmd.accesskey;"
+ observes="cmd_strikethrough"
+ type="checkbox"/>
+ <menuitem id="menu_styleSuperscript"
+ label="&styleSuperscriptCmd.label;"
+ accesskey="&styleSuperscriptCmd.accesskey;"
+ observes="cmd_superscript"
+ type="checkbox"/>
+ <menuitem id="menu_styleSubscript"
+ label="&styleSubscriptCmd.label;"
+ accesskey="&styleSubscriptCmd.accesskey;"
+ observes="cmd_subscript"
+ type="checkbox"/>
+ <menuitem id="menu_fontFixedWidth"
+ label="&fontFixedWidth.label;"
+ accesskey="&fontFixedWidth.accesskey;"
+ observes="cmd_tt"
+ type="checkbox"
+ key="fixedwidthkb"/>
+ <menuitem id="menu_styleNonbreaking"
+ label="&styleNonbreakingCmd.label;"
+ accesskey="&styleNonbreakingCmd.accesskey;"
+ observes="cmd_nobreak"
+ type="checkbox"/>
+ <menuseparator id="fontStyleMenuAfterNonbreakingSeparator"/>
+ <menuitem id="menu_styleEm"
+ label="&styleEm.label;"
+ accesskey="&styleEm.accesskey;"
+ observes="cmd_em"
+ type="checkbox"/>
+ <menuitem id="menu_styleStrong"
+ label="&styleStrong.label;"
+ accesskey="&styleStrong.accesskey;"
+ observes="cmd_strong"
+ type="checkbox"/>
+ <menuitem id="menu_styleCite"
+ label="&styleCite.label;"
+ accesskey="&styleCite.accesskey;"
+ observes="cmd_cite"
+ type="checkbox"/>
+ <menuitem id="menu_styleAbbr"
+ label="&styleAbbr.label;"
+ accesskey="&styleAbbr.accesskey;"
+ observes="cmd_abbr"
+ type="checkbox"/>
+ <menuitem id="menu_styleAcronym"
+ label="&styleAcronym.label;"
+ accesskey="&styleAcronym.accesskey;"
+ observes="cmd_acronym"
+ type="checkbox"/>
+ <menuitem id="menu_styleCode"
+ label="&styleCode.label;"
+ accesskey="&styleCode.accesskey;"
+ observes="cmd_code"
+ type="checkbox"/>
+ <menuitem id="menu_styleSamp"
+ label="&styleSamp.label;"
+ accesskey="&styleSamp.accesskey;"
+ observes="cmd_samp"
+ type="checkbox"/>
+ <menuitem id="menu_styleVar"
+ label="&styleVar.label;"
+ accesskey="&styleVar.accesskey;"
+ observes="cmd_var"
+ type="checkbox"/>
+ </menupopup>
+ </menu>
+
+ <!-- Note: "cmd_fontColor" only monitors color state, it doesn't execute the command
+ (We should use "cmd_fontColorState" and "cmd_backgroundColorState" ?) -->
+ <menuitem id="fontColor" label="&formatFontColor.label;"
+ accesskey="&formatFontColor.accesskey;"
+ observes="cmd_fontColor"
+ oncommand="EditorSelectColor('Text', null);"
+ position="4"/>
+ <menuseparator id="removeSep" position="5"/>
+
+ <!-- label and accesskey set at runtime from strings -->
+ <menuitem id="removeStylesMenuitem" key="removestyleskb"
+ observes="cmd_removeStyles"
+ position="6"/>
+ <menuitem id="removeLinksMenuitem" key="removelinkskb"
+ observes="cmd_removeLinks"
+ position="7"/>
+ <menuitem id="removeNamedAnchorsMenuitem" label="&formatRemoveNamedAnchors.label;"
+ key="removenamedanchorskb"
+ accesskey="&formatRemoveNamedAnchors.accesskey;"
+ observes="cmd_removeNamedAnchors"
+ position="8"/>
+ <menuseparator id="tabSep" position="9"/>
+
+ <!-- Note: the 'Init' menu methods for Paragraph, List, and Align
+ assume that the id = 'menu_'+tagName (the 'value' label),
+ except for the first ('none') item
+ -->
+ <!-- Paragraph Style submenu -->
+ <menu id="paragraphMenu" label="&paragraphMenu.label;"
+ accesskey="&paragraphMenu.accesskey;"
+ position="10" onpopupshowing="InitParagraphMenu()">
+ <menupopup id="paragraphMenuPopup"
+ oncommand="setParagraphState(event);">
+ <menuitem id="menu_bodyText"
+ type="radio"
+ name="1"
+ label="&bodyTextCmd.label;"
+ accesskey="&bodyTextCmd.accesskey;"
+ value=""
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_p"
+ type="radio"
+ name="1"
+ label="&paragraphParagraphCmd.label;"
+ accesskey="&paragraphParagraphCmd.accesskey;"
+ value="p"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h1"
+ type="radio"
+ name="1"
+ label="&heading1Cmd.label;"
+ accesskey="&heading1Cmd.accesskey;"
+ value="h1"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h2"
+ type="radio"
+ name="1"
+ label="&heading2Cmd.label;"
+ accesskey="&heading2Cmd.accesskey;"
+ value="h2"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h3"
+ type="radio"
+ name="1"
+ label="&heading3Cmd.label;"
+ accesskey="&heading3Cmd.accesskey;"
+ value="h3"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h4"
+ type="radio"
+ name="1"
+ label="&heading4Cmd.label;"
+ accesskey="&heading4Cmd.accesskey;"
+ value="h4"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h5"
+ type="radio"
+ name="1"
+ label="&heading5Cmd.label;"
+ accesskey="&heading5Cmd.accesskey;"
+ value="h5"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_h6"
+ type="radio"
+ name="1"
+ label="&heading6Cmd.label;"
+ accesskey="&heading6Cmd.accesskey;"
+ value="h6"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_address"
+ type="radio"
+ name="1"
+ label="&paragraphAddressCmd.label;"
+ accesskey="&paragraphAddressCmd.accesskey;"
+ value="address"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_pre"
+ type="radio"
+ name="1"
+ label="&paragraphPreformatCmd.label;"
+ accesskey="&paragraphPreformatCmd.accesskey;"
+ value="pre"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+
+ <!-- List Style submenu -->
+ <menu id="listMenu" label="&formatlistMenu.label;"
+ accesskey="&formatlistMenu.accesskey;"
+ position="11" onpopupshowing="InitListMenu()">
+ <menupopup id="listMenuPopup">
+ <menuitem id="menu_noList"
+ type="radio"
+ name="1"
+ label="&noneCmd.label;"
+ accesskey="&noneCmd.accesskey;"
+ observes="cmd_removeList"/>
+ <menuitem id="menu_ul"
+ type="radio"
+ name="1"
+ label="&listBulletCmd.label;"
+ accesskey="&listBulletCmd.accesskey;"
+ observes="cmd_ul"/>
+ <menuitem id="menu_ol"
+ type="radio"
+ name="1"
+ label="&listNumberedCmd.label;"
+ accesskey="&listNumberedCmd.accesskey;"
+ observes="cmd_ol"/>
+ <menuitem id="menu_dt"
+ type="radio"
+ name="1"
+ label="&listTermCmd.label;"
+ accesskey="&listTermCmd.accesskey;"
+ observes="cmd_dt"/>
+ <menuitem id="menu_dd"
+ type="radio"
+ name="1"
+ label="&listDefinitionCmd.label;"
+ accesskey="&listDefinitionCmd.accesskey;"
+ observes="cmd_dd"/>
+ <menuseparator/>
+ <menuitem id="listProps"
+ label="&listPropsCmd.label;"
+ accesskey="&listPropsCmd.accesskey;"
+ observes="cmd_listProperties"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="identingSep" position="12"/>
+
+ <menuitem id="increaseIndent"
+ label="&increaseIndent.label;"
+ accesskey="&increaseIndent.accesskey;"
+ key="increaseindentkb"
+ observes="cmd_indent"
+ position="13"/>
+ <menuitem id="decreaseIndent"
+ label="&decreaseIndent.label;"
+ accesskey="&decreaseIndent.accesskey;"
+ key="decreaseindentkb"
+ observes="cmd_outdent"
+ position="14"/>
+
+ <menu id="alignMenu" label="&alignMenu.label;" accesskey="&alignMenu.accesskey;"
+ onpopupshowing="InitAlignMenu()"
+ position="15">
+ <!-- Align submenu -->
+ <menupopup id="alignMenuPopup"
+ oncommand="doStatefulCommand('cmd_align', event.target.getAttribute('value'))">
+ <menuitem id="menu_left"
+ label="&alignLeft.label;"
+ accesskey="&alignLeft.accesskey;"
+ type="radio"
+ name="1"
+ value="left"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_center"
+ label="&alignCenter.label;"
+ accesskey="&alignCenter.accesskey;"
+ type="radio"
+ name="1"
+ value="center"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_right"
+ label="&alignRight.label;"
+ accesskey="&alignRight.accesskey;"
+ type="radio"
+ name="1"
+ value="right"
+ observes="cmd_renderedHTMLEnabler"/>
+ <menuitem id="menu_justify"
+ label="&alignJustify.label;"
+ accesskey="&alignJustify.accesskey;"
+ type="radio"
+ name="1"
+ value="justify"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="tableSep" position="16"/>
+ <menu id="tableMenu" label="&tableMenu.label;" accesskey="&tableMenu.accesskey;">
+ <menupopup id="tableMenuPopup" onpopupshowing="EditorInitTableMenu()">
+ <menu id="tableInsertMenu" label="&tableInsertMenu.label;" accesskey="&tableInsertMenu.accesskey;">
+ <menupopup id="tableMenuPopup">
+ <menuitem id="menu_insertTable"
+ label="&insertTableCmd.label;"
+ accesskey="&insertTableCmd.accesskey;"
+ observes="cmd_InsertTable"/>
+ <menuseparator id="tableMenuAfterInsertTableSeparator"/>
+ <menuitem id="menu_tableRowAbove"
+ label="&tableRowAbove.label;"
+ accesskey="&tableRowAbove.accesskey;"
+ observes="cmd_InsertRowAbove"/>
+ <menuitem id="menu_tableRowBelow"
+ label="&tableRowBelow.label;"
+ accesskey="&tableRowBelow.accesskey;"
+ observes="cmd_InsertRowBelow"/>
+ <menuseparator id="tableMenuAfterTableRowSeparator"/>
+ <menuitem id="menu_tableColumnBefore"
+ label="&tableColumnBefore.label;"
+ accesskey="&tableColumnBefore.accesskey;"
+ observes="cmd_InsertColumnBefore"/>
+ <menuitem id="menu_tableColumnAfter"
+ label="&tableColumnAfter.label;"
+ accesskey="&tableColumnAfter.accesskey;"
+ observes="cmd_InsertColumnAfter"/>
+ <menuseparator id="tableMenuAfterInsertColumnSeparator"/>
+ <menuitem id="menu_tableCellBefore"
+ label="&tableCellBefore.label;"
+ accesskey="&tableCellBefore.accesskey;"
+ observes="cmd_InsertCellBefore"/>
+ <menuitem id="menu_tableCellAfter"
+ label="&tableCellAfter.label;"
+ accesskey="&tableCellAfter.accesskey;"
+ observes="cmd_InsertCellAfter"/>
+ </menupopup>
+ </menu>
+ <menu id="tableSelectMenu"
+ label="&tableSelectMenu.label;"
+ accesskey="&tableSelectMenu.accesskey;" >
+ <menupopup id="tableSelectPopup">
+ <menuitem id="menu_SelectTable"
+ label="&tableTable.label;"
+ accesskey="&tableTable.accesskey;"
+ observes="cmd_SelectTable"/>
+ <menuitem id="menu_SelectRow"
+ label="&tableRow.label;"
+ accesskey="&tableRow.accesskey;"
+ observes="cmd_SelectRow"/>
+ <menuitem id="menu_SelectColumn"
+ label="&tableColumn.label;"
+ accesskey="&tableColumn.accesskey;"
+ observes="cmd_SelectColumn"/>
+ <menuitem id="menu_SelectCell"
+ label="&tableCell.label;"
+ accesskey="&tableCell.accesskey;"
+ observes="cmd_SelectCell"/>
+ <menuitem id="menu_SelectAllCells"
+ label="&tableAllCells.label;"
+ accesskey="&tableAllCells.accesskey;"
+ observes="cmd_SelectAllCells"/>
+ </menupopup>
+ </menu>
+ <menu id="tableDeleteMenu"
+ label="&tableDeleteMenu.label;"
+ accesskey="&tableDeleteMenu.accesskey;">
+ <menupopup id="tableDeletePopup">
+ <menuitem id="menu_DeleteTable"
+ label="&tableTable.label;"
+ accesskey="&tableTable.accesskey;"
+ observes="cmd_DeleteTable"/>
+ <menuitem id="menu_DeleteRow"
+ label="&tableRows.label;"
+ accesskey="&tableRow.accesskey;"
+ observes="cmd_DeleteRow"/>
+ <menuitem id="menu_DeleteColumn"
+ label="&tableColumns.label;"
+ accesskey="&tableColumn.accesskey;"
+ observes="cmd_DeleteColumn"/>
+ <menuitem id="menu_DeleteCell"
+ label="&tableCells.label;"
+ accesskey="&tableCell.accesskey;"
+ observes="cmd_DeleteCell"/>
+ <menuitem id="menu_DeleteCellContents"
+ label="&tableCellContents.label;"
+ accesskey="&tableCellContents.accesskey;"
+ observes="cmd_DeleteCellContents"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <!-- menu label is set in InitTableMenu -->
+ <menuitem id="menu_JoinTableCells"
+ label="&tableJoinCells.label;"
+ accesskey="&tableJoinCells.accesskey;"
+ observes="cmd_JoinTableCells"/>
+ <menuitem id="menu_SlitTableCell"
+ label="&tableSplitCell.label;"
+ accesskey="&tableSplitCell.accesskey;"
+ observes="cmd_SplitTableCell"/>
+ <menuitem id="menu_ConvertToTable"
+ label="&convertToTable.label;"
+ accesskey="&convertToTable.accesskey;"
+ observes="cmd_ConvertToTable"/>
+ <menuseparator/>
+ <menuitem id="menu_TableOrCellColor"
+ label="&tableOrCellColor.label;"
+ accesskey="&tableOrCellColor.accesskey;"
+ observes="cmd_TableOrCellColor"/>
+ <menuitem id="menu_tableProperties"
+ label="&tableProperties.label;"
+ accesskey="&tableProperties.accesskey;"
+ observes="cmd_editTable"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <!-- label and accesskey filled in during menu creation -->
+ <menuitem id="objectProperties"
+ command="cmd_objectProperties"/>
+ <!-- Don't use 'observes', must call command correctly -->
+ <menuitem id="colorsAndBackground"
+ label="&colorsAndBackground.label;"
+ accesskey="&colorsAndBackground.accesskey;"
+ oncommand="goDoCommand('cmd_colorProperties')"
+ observes="cmd_renderedHTMLEnabler"/>
+ </menupopup>
+ </menu>
+
+ <menu id="optionsMenu" label="&optionsMenu.label;" accesskey="&optionsMenu.accesskey;">
+ <menupopup id="optionsMenuPopup" onpopupshowing="updateOptionsMenu();">
+ <menuitem id="menu_checkspelling"
+ label="&checkSpellingCmd2.label;"
+ accesskey="&checkSpellingCmd2.accesskey;"
+ key="key_checkspelling"
+ command="cmd_spelling"/>
+ <menuitem id="menu_inlineSpellCheck"
+ label="&enableInlineSpellChecker.label;"
+ accesskey="&enableInlineSpellChecker.accesskey;"
+ type="checkbox"
+ oncommand="toggleSpellCheckingEnabled();"/>
+ <menuitem id="menu_quoteMessage"
+ label="&quoteCmd.label;"
+ accesskey="&quoteCmd.accesskey;"
+ command="cmd_quoteMessage"/>
+ <menuseparator/>
+ <menuitem id="returnReceiptMenu" type="checkbox"
+ label="&returnReceiptMenu.label;"
+ accesskey="&returnReceiptMenu.accesskey;"
+ checked="false"
+ command="cmd_toggleReturnReceipt"/>
+ <menuitem id="dsnMenu" type="checkbox" label="&dsnMenu.label;" accesskey="&dsnMenu.accesskey;" oncommand="ToggleDSN(event.target)"/>
+ <menuseparator/>
+ <menu id="outputFormatMenu" data-l10n-id="compose-send-format-menu">
+ <menupopup id="outputFormatMenuPopup">
+ <menuitem type="radio" name="output_format" id="format_auto" data-l10n-id="compose-send-auto-menu-item"/>
+ <menuitem type="radio" name="output_format" id="format_both" data-l10n-id="compose-send-both-menu-item"/>
+ <menuitem type="radio" name="output_format" id="format_html" data-l10n-id="compose-send-html-menu-item"/>
+ <menuitem type="radio" name="output_format" id="format_plain" data-l10n-id="compose-send-plain-menu-item"/>
+ </menupopup>
+ </menu>
+ <menu id="priorityMenu" label="&priorityMenu.label;" accesskey="&priorityMenu.accesskey;" onpopupshowing="updatePriorityMenu();" oncommand="PriorityMenuSelect(event.target);">
+ <menupopup id="priorityMenuPopup">
+ <menuitem type="radio" name="priority" label="&highestPriorityCmd.label;" accesskey="&highestPriorityCmd.accesskey;" value="Highest" id="priority_highest"/>
+ <menuitem type="radio" name="priority" label="&highPriorityCmd.label;" accesskey="&highPriorityCmd.accesskey;" value="High" id="priority_high"/>
+ <menuitem type="radio" name="priority" label="&normalPriorityCmd.label;" accesskey="&normalPriorityCmd.accesskey;" value="" id="priority_normal" checked="true"/>
+ <menuitem type="radio" name="priority" label="&lowPriorityCmd.label;" accesskey="&lowPriorityCmd.accesskey;" value="Low" id="priority_low"/>
+ <menuitem type="radio" name="priority" label="&lowestPriorityCmd.label;" accesskey="&lowestPriorityCmd.accesskey;" value="Lowest" id="priority_lowest"/>
+ </menupopup>
+ </menu>
+ <menu id="fccMenu" label="&fileCarbonCopyCmd.label;"
+ accesskey="&fileCarbonCopyCmd.accesskey;"
+ oncommand="MessageFcc(event.target._folder)">
+ <menupopup is="folder-menupopup" id="fccMenuPopup" mode="filing"
+ showFileHereLabel="true" fileHereLabel="&fileHereMenu.label;"/>
+ </menu>
+ <menuseparator/>
+ <menuitem type="checkbox" command="cmd_customizeFromAddress"
+ accesskey="&customizeFromAddress.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="encryptionMenu" data-l10n-id="encryption-menu">
+ <menupopup onpopupshowing="setSecuritySettings('_Menubar');">
+
+ <menuitem id="encTech_OpenPGP_Menubar"
+ label="&menu_techPGP.label;" accesskey="&menu_techPGP.accesskey;"
+ value="OpenPGP" type="radio" name="radiogroup_encTech"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="encTech_SMIME_Menubar"
+ label="&menu_techSMIME.label;" accesskey="&menu_techSMIME.accesskey;"
+ value="SMIME" type="radio" name="radiogroup_encTech"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+
+ <menuseparator id="encryptionOptionsSeparator_Menubar"/>
+
+ <menuitem id="menu_securityEncrypt_Menubar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt"
+ value="enc"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="menu_securityEncryptSubject_Menubar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt-subject"
+ value="encsub"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="menu_securitySign_Menubar"
+ type="checkbox"
+ data-l10n-id="menu-sign"
+ value="sig"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+
+ <menuseparator id="statusInfoSeparator"/>
+
+ <menuitem id="menu_recipientStatus_Menubar"
+ data-l10n-id="menu-manage-keys"
+ value="status"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+ <menuitem id="menu_openManager_Menubar"
+ data-l10n-id="menu-open-key-manager"
+ value="manager"
+ oncommand="onEncryptionChoice(event.target.value);"/>
+
+ </menupopup>
+ </menu>
+
+ <menu id="tasksMenu" label="&tasksMenu.label;" accesskey="&tasksMenu.accesskey;">
+ <menupopup id="taskPopup">
+ <menuitem id="tasksMenuMail" accesskey="&messengerCmd.accesskey;"
+ label="&messengerCmd.label;" key="key_mail"
+ oncommand="toMessengerWindow();"/>
+ <menuitem id="tasksMenuAddressBook"
+ label="&addressBookCmd.label;"
+ accesskey="&addressBookCmd.accesskey;"
+ oncommand="toAddressBook();"/>
+#ifndef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmd2.accesskey;"
+ command="cmd_account"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-tools-settings"
+ oncommand="openOptionsDialog('paneCompose');"/>
+#endif
+ </menupopup>
+ </menu>
+
+#ifdef XP_MACOSX
+#include ../../../base/content/macWindowMenu.inc.xhtml
+#endif
+
+ <!-- Help -->
+#include ../../../base/content/helpMenu.inc.xhtml
+ </menubar>
+ </toolbaritem>
+ </toolbar>
+
+ <toolbarpalette id="MsgComposeToolbarPalette">
+
+ <toolbarbutton class="toolbarbutton-1"
+ id="button-send" label="&sendButton.label;"
+ tooltiptext="&sendButton.tooltip;"
+ command="cmd_sendButton"
+ now_label="&sendButton.label;"
+ now_tooltiptext="&sendButton.tooltip;"
+ later_label="&sendLaterCmd.label;"
+ later_tooltiptext="&sendlaterButton.tooltip;">
+ </toolbarbutton>
+
+ <toolbarbutton class="toolbarbutton-1"
+ id="button-contacts" label="&addressButton.label;"
+ tooltiptext="&addressButton.tooltip;"
+ autoCheck="false" type="checkbox"
+ oncommand="toggleContactsSidebar();"/>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-attach"
+ data-l10n-id="toolbar-button-add-attachment"
+ type="menu"
+ class="toolbarbutton-1"
+ command="cmd_attachFile">
+ <menupopup id="button-attachPopup" onpopupshowing="updateAttachmentItems();">
+ <menuitem id="button-attachPopup_attachFileItem"
+ data-l10n-id="menuitem-attach-files"
+ data-l10n-attrs="acceltext"
+ command="cmd_attachFile"/>
+ <menu id="button-attachPopup_attachCloudMenu"
+ label="&attachCloudCmd.label;"
+ accesskey="&attachCloudCmd.accesskey;"
+ command="cmd_attachCloud">
+ <menupopup id="attachCloudMenu_popup" onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/>
+ </menu>
+ <menuitem id="button-attachPopup_attachPageItem"
+ label="&attachPageCmd.label;"
+ accesskey="&attachPageCmd.accesskey;"
+ command="cmd_attachPage"/>
+ <menuseparator/>
+ <menuitem id="button-attachPopup_attachVCardItem"
+ type="checkbox"
+ data-l10n-id="context-menuitem-attach-vcard"
+ command="cmd_attachVCard"/>
+ <menuitem id="button-attachPopup_attachPublicKey"
+ type="checkbox"
+ data-l10n-id="context-menuitem-attach-openpgp-key"
+ command="cmd_attachPublicKey"/>
+ <menuseparator id="button-attachPopup_remindLaterSeparator"/>
+ <menuitem id="button-attachPopup_remindLaterItem"
+ type="checkbox"
+ label="&remindLater.label;"
+ accesskey="&remindLater.accesskey;"
+ command="cmd_remindLater"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="button-encryption"
+ type="checkbox" autoCheck="false"
+ class="toolbarbutton-1"
+ data-l10n-id="encryption-toggle"
+ oncommand="toggleEncryptMessage();"/>
+
+ <toolbarbutton id="button-signing"
+ type="checkbox" autoCheck="false"
+ class="toolbarbutton-1"
+ data-l10n-id="signing-toggle"
+ oncommand="toggleGlobalSignMessage();"/>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-encryption-options"
+ type="menu"
+ class="toolbarbutton-1"
+ data-l10n-id="encryption-options-openpgp"
+ oncommand="showPopupById('encryptionToolbarMenu', 'button-encryption-options');">
+ <menupopup id="encryptionToolbarMenu"
+ onpopupshowing="setSecuritySettings('_Toolbar');"
+ oncommand="onEncryptionChoice(event.target.value);">
+
+ <menuitem id="encTech_OpenPGP_Toolbar"
+ label="&menu_techPGP.label;" accesskey="&menu_techPGP.accesskey;"
+ value="OpenPGP" type="radio" name="radiogroup_encTech"/>
+ <menuitem id="encTech_SMIME_Toolbar"
+ label="&menu_techSMIME.label;" accesskey="&menu_techSMIME.accesskey;"
+ value="SMIME" type="radio" name="radiogroup_encTech"/>
+
+ <menuseparator id="encryptionOptionsSeparator_Toolbar"/>
+
+ <menuitem id="menu_securityEncrypt_Toolbar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt"
+ value="enc"/>
+ <menuitem id="menu_securityEncryptSubject_Toolbar"
+ type="checkbox"
+ data-l10n-id="menu-encrypt-subject"
+ value="encsub"/>
+ <menuitem id="menu_securitySign_Toolbar"
+ type="checkbox"
+ data-l10n-id="menu-sign"
+ value="sig"/>
+
+ <menuseparator id="statusInfoSeparator"/>
+
+ <menuitem id="menu_recipientStatus_Toolbar"
+ data-l10n-id="menu-manage-keys"
+ value="status"/>
+ <menuitem id="menu_openManager_Toolbar"
+ data-l10n-id="menu-open-key-manager"
+ value="manager"/>
+
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="spellingButton"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&spellingButton.label;"
+ tooltiptext="&spellingButton.tooltip;"
+ command="cmd_spelling">
+ <!-- workaround for the bug that split menu doesn't take popup="popupID" -->
+ <menupopup onpopupshowing="event.preventDefault();
+ showPopupById('languageMenuList',
+ 'spellingButton');"/>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-save"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&saveButton.label;"
+ tooltiptext="&saveButton.tooltip;"
+ command="cmd_saveDefault">
+ <menupopup id="button-savePopup" onpopupshowing="InitFileSaveAsMenu();">
+ <menuitem id="savePopup_saveAsFile"
+ label="&saveAsFileCmd.label;" accesskey="&saveAsFileCmd.accesskey;"
+ command="cmd_saveAsFile" type="radio" name="radiogroup_SaveAs"/>
+ <menuseparator/>
+ <menuitem id="savePopup_saveAsDraft"
+ label="&saveAsDraftCmd.label;" accesskey="&saveAsDraftCmd.accesskey;"
+ command="cmd_saveAsDraft" type="radio" name="radiogroup_SaveAs"/>
+ <menuitem id="savePopup_saveAsTemplate"
+ label="&saveAsTemplateCmd.label;" accesskey="&saveAsTemplateCmd.accesskey;"
+ command="cmd_saveAsTemplate" type="radio" name="radiogroup_SaveAs"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton id="button-print"
+ class="toolbarbutton-1"
+ label="&printButton.label;"
+ command="cmd_print"
+ tooltiptext="&printButton.tooltip;"/>
+ <toolbarbutton class="toolbarbutton-1"
+ id="quoteButton" label="&quoteButton.label;"
+ tooltiptext="&quoteButton.tooltip;"
+ command="cmd_quoteMessage"/>
+
+ <toolbarbutton id="cut-button" class="toolbarbutton-1"
+ data-l10n-id="text-action-cut"
+ command="cmd_cut"
+ tooltiptext="&cutButton.tooltip;"/>
+ <toolbarbutton id="copy-button" class="toolbarbutton-1"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"
+ tooltiptext="&copyButton.tooltip;"/>
+ <toolbarbutton id="paste-button" class="toolbarbutton-1"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"
+ tooltiptext="&pasteButton.tooltip;"/>
+
+ <toolbaritem id="priority-button"
+ align="center"
+ pack="center"
+ title="&priorityButton.title;"
+ tooltiptext="&priorityButton.tooltiptext;">
+ <label value="&priorityButton.label;" control="priorityMenu-button"/>
+ <menulist id="priorityMenu-button" value="" oncommand="PriorityMenuSelect(event.target);">
+ <menupopup id="priorityMenu-buttonPopup">
+ <menuitem id="list_priority_highest"
+ name="priority"
+ label="&highestPriorityCmd.label;"
+ value="Highest"/>
+ <menuitem id="list_priority_high"
+ name="priority"
+ label="&highPriorityCmd.label;"
+ value="High"/>
+ <menuitem id="list_priority_normal"
+ name="priority"
+ selected="true"
+ label="&normalPriorityCmd.label;"
+ value=""/>
+ <menuitem id="list_priority_low"
+ name="priority"
+ label="&lowPriorityCmd.label;"
+ value="Low"/>
+ <menuitem id="list_priority_lowest"
+ name="priority"
+ label="&lowestPriorityCmd.label;"
+ value="Lowest"/>
+ </menupopup>
+ </menulist>
+ </toolbaritem>
+
+ <toolbarbutton id="button-returnReceipt"
+ class="toolbarbutton-1"
+ data-l10n-id="button-return-receipt"
+ type="checkbox" autoCheck="false"
+ command="cmd_toggleReturnReceipt"/>
+ </toolbarpalette>
+ <toolbar is="customizable-toolbar"
+ id="composeToolbar2"
+ class="chromeclass-toolbar themeable-full"
+ toolbarname="&showCompositionToolbarCmd.label;"
+ accesskey="&showCompositionToolbarCmd.accesskey;"
+ fullscreentoolbar="true" mode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+#endif
+ defaultset="button-send,separator,button-encryption,button-encryption-options,button-address,spellingButton,button-save,button-contacts,spring,button-attach"
+ customizable="true"
+ context="toolbar-context-menu">
+ </toolbar>
+</toolbox>
+ <html:div id="composeContentBox" class="printPreviewStack attachment-area-hidden">
+ <html:div id="contactsSidebar">
+ <box class="sidebar-header" align="center">
+ <label id="contactsTitle" value="&addressesSidebarTitle.label;"/>
+ <spacer flex="1"/>
+ <toolbarbutton class="close-icon"
+ oncommand="toggleContactsSidebar();"/>
+ </box>
+ <browser id="contactsBrowser" src="" disablehistory="true"/>
+ </html:div>
+
+ <html:hr is="pane-splitter" id="contactsSplitter"
+ resize-direction="horizontal"
+ resize-id="contactsSidebar" />
+
+ <toolbar is="customizable-toolbar" id="MsgHeadersToolbar"
+ class="themeable-full"
+ customizable="true" nowindowdrag="true"
+ ondragover="envelopeDragObserver.onDragOver(event);"
+ ondrop="envelopeDragObserver.onDrop(event);"
+ ondragleave="envelopeDragObserver.onDragLeave(event);">
+ <hbox id="top-gradient-box" class="address-identity-recipient">
+ <hbox class="aw-firstColBox"/>
+ <hbox id="identityLabel-box" align="center"
+ pack="end" style="&headersSpace2.style;">
+ <label id="identityLabel" value="&fromAddr2.label;"
+ accesskey="&fromAddr.accesskey;" control="msgIdentity"/>
+ </hbox>
+ <menulist is="menulist-editable" id="msgIdentity"
+ type="description"
+ disableautoselect="true" onkeypress="fromKeyPress(event);"
+ oncommand="LoadIdentity(false);" disableonsend="true">
+ <menupopup id="msgIdentityPopup"/>
+ </menulist>
+
+ <html:div id="extraAddressRowsArea">
+ <!-- Default set up is for a mail account, where we prefer
+ - showing the buttons, rather than the menu items, for
+ - the mail rows.
+ - The To field is already shown, so the button is hidden.
+ - For the news rows, we prefer the menu items over the
+ - buttons, so we hide them. -->
+ <html:button id="addr_toShowAddressRowButton"
+ disableonsend="true"
+ class="recipient-button plain-button"
+ data-address-row="addressRowTo"
+ onclick="showAndFocusAddressRow('addressRowTo');"
+ ondrop="showAddressRowButtonOnDrop(event);"
+ ondragover="showAddressRowButtonOnDragover(event);"
+ hidden="hidden">
+ </html:button>
+ <html:button id="addr_ccShowAddressRowButton"
+ disableonsend="true"
+ class="recipient-button plain-button"
+ data-address-row="addressRowCc"
+ onclick="showAndFocusAddressRow('addressRowCc');"
+ ondrop="showAddressRowButtonOnDrop(event);"
+ ondragover="showAddressRowButtonOnDragover(event);">
+ </html:button>
+ <html:button id="addr_bccShowAddressRowButton"
+ disableonsend="true"
+ class="recipient-button plain-button"
+ data-address-row="addressRowBcc"
+ onclick="showAndFocusAddressRow('addressRowBcc');"
+ ondrop="showAddressRowButtonOnDrop(event);"
+ ondragover="showAddressRowButtonOnDragover(event);">
+ </html:button>
+ <html:button id="addr_newsgroupsShowAddressRowButton"
+ class="recipient-button plain-button"
+ hidden="hidden"
+ onclick="showAndFocusAddressRow('addressRowNewsgroups')">
+ &newsgroupsAddr2.label;
+ </html:button>
+ <html:button id="addr_followupShowAddressRowButton"
+ class="recipient-button plain-button"
+ hidden="hidden"
+ onclick="showAndFocusAddressRow('addressRowFollowup')">
+ &followupAddr2.label;
+ </html:button>
+ <html:button id="extraAddressRowsMenuButton"
+ data-l10n-id="extra-address-rows-menu-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="extraAddressRowsMenu"
+ disableonsend="true"
+ class="plain-button"
+ onclick="openExtraAddressRowsMenu();">
+ <!-- NOTE: button title should provide the accessibility
+ - context. -->
+ <html:img class="overflow-icon"
+ src="chrome://messenger/skin/icons/new/compact/overflow.svg"
+ alt="" />
+ </html:button>
+ </html:div>
+ </hbox>
+
+ <mail-recipients-area id="recipientsContainer" orient="vertical"
+ class="recipients-container">
+ <hbox id="addressRowReply"
+ class="address-row hidden"
+ data-recipienttype="addr_reply"
+ data-show-self-menuitem="addr_replyShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="replyAddrLabel" value="&replyAddr2.label;"
+ control="replyAddrInput"/>
+ </hbox>
+ <hbox id="replyAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="replyAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowTo"
+ class="address-row"
+ data-recipienttype="addr_to"
+ data-show-self-menuitem="addr_toShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);"
+ hidden="hidden">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="toAddrLabel"
+ data-l10n-id="to-address-row-label"
+ control="toAddrInput"/>
+ </hbox>
+ <hbox id="toAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="toAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input mail-primary-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowCc"
+ class="address-row hidden"
+ data-recipienttype="addr_cc"
+ data-show-self-menuitem="addr_ccShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="ccAddrLabel"
+ data-l10n-id="cc-address-row-label"
+ control="ccAddrInput"/>
+ </hbox>
+ <hbox id="ccAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="ccAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowBcc"
+ class="address-row hidden"
+ data-recipienttype="addr_bcc"
+ data-show-self-menuitem="addr_bccShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="bccAddrLabel"
+ data-l10n-id="bcc-address-row-label"
+ control="bccAddrInput"/>
+ </hbox>
+ <hbox id="bccAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="bccAddrInput"
+ type="text"
+ class="plain address-input address-row-input mail-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowNewsgroups"
+ class="address-row hidden"
+ data-recipienttype="addr_newsgroups"
+ data-show-self-menuitem="addr_newsgroupsShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="newsgroupsAddrLabel" value="&newsgroupsAddr2.label;"
+ control="newsgroupsAddrInput"/>
+ </hbox>
+ <hbox id="newsgroupsAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="newsgroupsAddrInput"
+ type="text"
+ class="plain address-input address-row-input news-input news-primary-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox id="addressRowFollowup"
+ class="address-row hidden"
+ data-recipienttype="addr_followup"
+ data-show-self-menuitem="addr_followupShowAddressRowMenuItem">
+ <hbox class="aw-firstColBox">
+ <html:button class="remove-field-button plain-button"
+ onclick="closeLabelOnClick(event);">
+ <html:img src="chrome://global/skin/icons/close.svg"
+ alt="" />
+ </html:button>
+ </hbox>
+ <hbox class="address-label-container" align="top" pack="end"
+ style="&headersSpace2.style;">
+ <label id="followupAddrLabel" value="&followupAddr2.label;"
+ control="followupAddrInput"/>
+ </hbox>
+ <hbox id="followupAddrContainer" flex="1" align="center"
+ class="input-container wrap-container address-container"
+ onclick="focusAddressInputOnClick(event);">
+ <html:input is="autocomplete-input" id="followupAddrInput"
+ type="text"
+ class="plain address-input address-row-input news-input"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ onfocus="addressInputOnFocus(this);"
+ onblur="addressInputOnBlur(this);"
+ size="1"/>
+ </hbox>
+ </hbox>
+ </mail-recipients-area>
+
+ <hbox id="subject-box">
+ <hbox class="aw-firstColBox"/>
+ <hbox id="subjectLabel-box" align="center"
+ pack="end" style="&headersSpace2.style;">
+ <label id="subjectLabel" value="&subject2.label;"
+ accesskey="&subject.accesskey;" control="msgSubject"/>
+ </hbox>
+ <hbox id="msgSubjectContainer" flex="1" align="center"
+ class="input-container">
+ <moz-input-box spellcheck="true" style="flex: 1;">
+ <html:img id="msgEncryptedSubjectIcon"
+ src="chrome://messenger/skin/icons/message-encrypted-notok.svg"
+ onclick="toggleEncryptedSubject(event);"
+ hidden="hidden"
+ alt="" />
+ <html:input id="msgSubject"
+ type="text"
+ class="input-inline textbox-input"
+ disableonsend="true"
+ oninput="msgSubjectOnInput(event);"
+ onkeypress="subjectKeyPress(event);"
+ aria-labelledby="subjectLabel"
+ style="flex: 1;"/>
+ </moz-input-box>
+ </hbox>
+ </hbox>
+ </toolbar>
+
+ <toolbox id="FormatToolbox" mode="icons">
+ <toolbar id="FormatToolbar"
+ class="chromeclass-toolbar themeable-brighttext"
+ persist="collapsed"
+ nowindowdrag="true">
+#include editFormatButtons.inc.xhtml
+ <spacer flex="1"/>
+ </toolbar>
+ </toolbox>
+
+ <html:hr is="pane-splitter" id="headersSplitter"
+ resize-direction="vertical"
+ resize-id="MsgHeadersToolbar" />
+ <html:div id="messageArea">
+ <html:div id="dropAttachmentOverlay" class="drop-attachment-overlay">
+ <html:aside id="addInline" class="drop-attachment-box">
+ <html:span id="addInlineLabel"
+ data-l10n-id="drop-file-label-inline"
+ data-l10n-args='{"count": 1}'
+ class="drop-inline"></html:span>
+ </html:aside>
+ <html:aside id="addAsAttachment"
+ class="drop-attachment-box">
+ <html:span id="addAsAttachmentLabel"
+ data-l10n-id="drop-file-label-attachment"
+ data-l10n-args='{"count": 1}'
+ class="drop-as-attachment"></html:span>
+ </html:aside>
+ </html:div>
+ <!--
+ - The mail message body frame. The src does not exactly match
+ - "about:blank" so that WebExtension content scripts are not loaded
+ - here in the moments before navigation to about:blank?compose occurs.
+ -->
+ <editor id="messageEditor"
+ type="content"
+ primary="true"
+ src="about:blank?"
+ name="browser.message.body"
+ aria-label="&aria.message.bodyName;"
+ messagemanagergroup="browsers"
+ oncontextmenu="this._contextX = event.pageX; this._contextY = event.pageY;"
+ onclick="EditorClick(event);"
+ ondblclick="EditorDblClick(event);"
+ context="msgComposeContext"/>
+
+ <html:div id="linkPreviewSettings" xmlns="http://www.w3.org/1999/xhtml" hidden="hidden">
+ <span class="close">+</span>
+ <h2 data-l10n-id="link-preview-title"></h2>
+ <p data-l10n-id="link-preview-description"></p>
+ <p>
+ <input class="preview-autoadd" id="link-preview-autoadd" type="checkbox" />
+ <label data-l10n-id="link-preview-autoadd" for="link-preview-autoadd"></label>
+ </p>
+ <p class="bottom">
+ <span data-l10n-id="link-preview-replace-now"></span>
+ <button class="preview-replace" data-l10n-id="link-preview-yes-replace"></button>
+ </p>
+ </html:div>
+
+ <findbar id="FindToolbar" browserid="messageEditor"/>
+ </html:div>
+
+ <!-- NOTE: The splitter controls #attachmentBucket's size directly. -->
+ <html:hr is="pane-splitter" id="attachmentSplitter"
+ resize-direction="vertical"
+ resize-id="attachmentBucket" />
+ <html:details id="attachmentArea">
+ <html:summary>
+ <!-- Hide from accessibility tree since this is only used for a brief
+ - animation effect. -->
+ <html:span id="newAttachmentIndicator" aria-hidden="true"></html:span>
+ <html:img id="attachmentToggle"
+ src="chrome://messenger/skin/icons/new/nav-down-sm.svg"
+ alt="" />
+ <html:span id="attachmentBucketCount"></html:span>
+ <html:span id="attachmentBucketSize" role="note"></html:span>
+ </html:summary>
+
+ <richlistbox is="attachment-list" id="attachmentBucket"
+ aria-describedby="attachmentBucketCount"
+ class="attachmentList"
+ disableonsend="true"
+ seltype="multiple"
+ flex="1"
+ role="listbox"
+ context="msgComposeAttachmentListContext"
+ itemcontext="msgComposeAttachmentItemContext"
+ onclick="attachmentBucketOnClick(event);"
+ onkeypress="attachmentBucketOnKeyPress(event);"
+ onselect="attachmentBucketOnSelect();"
+ ondragstart="attachmentBucketDNDObserver.onDragStart(event);"
+ ondragover="envelopeDragObserver.onDragOver(event);"
+ ondrop="envelopeDragObserver.onDrop(event);"
+ ondragleave="envelopeDragObserver.onDragLeave(event);"
+ onblur="attachmentBucketOnBlur();"/>
+ </html:details>
+ </html:div>
+
+ <panel id="customizeToolbarSheetPopup" noautohide="true">
+ <iframe id="customizeToolbarSheetIFrame"
+ style="&dialog.dimensions;"
+ hidden="true"/>
+ </panel>
+
+ <vbox id="compose-notification-bottom">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+ <html:div id="status-bar" class="statusbar" role="status">
+ <html:div id="statusText"></html:div>
+ <html:progress id="compose-progressmeter"
+ class="progressmeter-statusbar"
+ value="0" max="100"
+ hidden="hidden">
+ </html:progress>
+ <html:button id="languageStatusButton"
+ class="plain-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="languageMenuList"
+ title="&languageStatusButton.tooltip;"
+ onclick="showPopupById('languageMenuList', 'languageStatusButton', 'before_start');"
+ hidden="hidden">
+ </html:button>
+ </html:div>
+
+#include ../../../base/content/tabDialogs.inc.xhtml
+#include ../../../extensions/openpgp/content/ui/keyAssistant.inc.xhtml
+
+<html:template id="dataCardTemplate" xmlns="http://www.w3.org/1999/xhtml">
+ <aside class="moz-card" style="width:600px; display:flex; align-items:center; justify-content:center; flex-direction:row; flex-wrap:wrap; border-radius:10px; border:1px solid silver;">
+ <a class="remove-card">+</a>
+ <div class="card-pic" style="display:flex; flex-direction:column; flex-basis:100%; flex:1;">
+ <div style="margin:0 5px;">
+ <img src="IMAGE" style="width:120px;" alt="" />
+ </div>
+ </div>
+ <div class="card-content" style="display:flex; flex-direction:column; flex-basis:100%; flex:3;">
+ <div style="margin:0 1em;">
+ <p><small class="site" style="font-weight:lighter;">SITE</small></p>
+ <p>
+ <a href="#" style="font-weight:600; text-decoration:none;"><big class="title">TITLE</big></a>
+ </p>
+ <p class="description">DESCRIPTION</p>
+ <p>
+ <a href="#" class="url" style="display:inline-block; text-decoration:none; text-indent:-2ch; margin-inline:2ch;">URL</a>
+ </p>
+ </div>
+ </div>
+ </aside>
+</html:template>
+</html:body>
+</html>
diff --git a/comm/mail/components/compose/jar.mn b/comm/mail/components/compose/jar.mn
new file mode 100644
index 0000000000..81761972d1
--- /dev/null
+++ b/comm/mail/components/compose/jar.mn
@@ -0,0 +1,58 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+* content/messenger/messengercompose/messengercompose.xhtml (content/messengercompose.xhtml)
+ content/messenger/messengercompose/MsgComposeCommands.js (content/MsgComposeCommands.js)
+ content/messenger/messengercompose/bigFileObserver.js (content/bigFileObserver.js)
+ content/messenger/messengercompose/cloudAttachmentLinkManager.js (content/cloudAttachmentLinkManager.js)
+ content/messenger/messengercompose/addressingWidgetOverlay.js (content/addressingWidgetOverlay.js)
+ content/messenger/messengercompose/editor.js (content/editor.js)
+ content/messenger/messengercompose/editorUtilities.js (content/editorUtilities.js)
+ content/messenger/messengercompose/ComposerCommands.js (content/ComposerCommands.js)
+ content/messenger/messengercompose/images/tag-anchor.gif (content/images/tag-anchor.gif)
+ content/messenger/messengercompose/EdDialogCommon.js (content/dialogs/EdDialogCommon.js)
+ content/messenger/messengercompose/EdLinkProps.xhtml (content/dialogs/EdLinkProps.xhtml)
+ content/messenger/messengercompose/EdLinkProps.js (content/dialogs/EdLinkProps.js)
+ content/messenger/messengercompose/EdImageProps.xhtml (content/dialogs/EdImageProps.xhtml)
+ content/messenger/messengercompose/EdImageProps.js (content/dialogs/EdImageProps.js)
+ content/messenger/messengercompose/EdImageLinkLoader.js (content/dialogs/EdImageLinkLoader.js)
+ content/messenger/messengercompose/EdImageDialog.js (content/dialogs/EdImageDialog.js)
+ content/messenger/messengercompose/EdHLineProps.xhtml (content/dialogs/EdHLineProps.xhtml)
+ content/messenger/messengercompose/EdHLineProps.js (content/dialogs/EdHLineProps.js)
+ content/messenger/messengercompose/EdReplace.xhtml (content/dialogs/EdReplace.xhtml)
+ content/messenger/messengercompose/EdReplace.js (content/dialogs/EdReplace.js)
+ content/messenger/messengercompose/EdSpellCheck.xhtml (content/dialogs/EdSpellCheck.xhtml)
+ content/messenger/messengercompose/EdSpellCheck.js (content/dialogs/EdSpellCheck.js)
+ content/messenger/messengercompose/EdDictionary.xhtml (content/dialogs/EdDictionary.xhtml)
+ content/messenger/messengercompose/EdDictionary.js (content/dialogs/EdDictionary.js)
+ content/messenger/messengercompose/EdNamedAnchorProps.xhtml (content/dialogs/EdNamedAnchorProps.xhtml)
+ content/messenger/messengercompose/EdNamedAnchorProps.js (content/dialogs/EdNamedAnchorProps.js)
+ content/messenger/messengercompose/EdInsertTOC.xhtml (content/dialogs/EdInsertTOC.xhtml)
+ content/messenger/messengercompose/EdInsertTOC.js (content/dialogs/EdInsertTOC.js)
+ content/messenger/messengercompose/EdInsertTable.xhtml (content/dialogs/EdInsertTable.xhtml)
+ content/messenger/messengercompose/EdInsertTable.js (content/dialogs/EdInsertTable.js)
+ content/messenger/messengercompose/EdInsertMath.xhtml (content/dialogs/EdInsertMath.xhtml)
+ content/messenger/messengercompose/EdInsertMath.js (content/dialogs/EdInsertMath.js)
+ content/messenger/messengercompose/EdTableProps.xhtml (content/dialogs/EdTableProps.xhtml)
+ content/messenger/messengercompose/EdTableProps.js (content/dialogs/EdTableProps.js)
+ content/messenger/messengercompose/EdInsSrc.xhtml (content/dialogs/EdInsSrc.xhtml)
+ content/messenger/messengercompose/EdInsSrc.js (content/dialogs/EdInsSrc.js)
+ content/messenger/messengercompose/EdInsertChars.xhtml (content/dialogs/EdInsertChars.xhtml)
+ content/messenger/messengercompose/EdInsertChars.js (content/dialogs/EdInsertChars.js)
+ content/messenger/messengercompose/EdAdvancedEdit.xhtml (content/dialogs/EdAdvancedEdit.xhtml)
+ content/messenger/messengercompose/EdAdvancedEdit.js (content/dialogs/EdAdvancedEdit.js)
+ content/messenger/messengercompose/EdListProps.xhtml (content/dialogs/EdListProps.xhtml)
+ content/messenger/messengercompose/EdListProps.js (content/dialogs/EdListProps.js)
+ content/messenger/messengercompose/EdColorProps.xhtml (content/dialogs/EdColorProps.xhtml)
+ content/messenger/messengercompose/EdColorProps.js (content/dialogs/EdColorProps.js)
+ content/messenger/messengercompose/EdColorPicker.xhtml (content/dialogs/EdColorPicker.xhtml)
+ content/messenger/messengercompose/EdColorPicker.js (content/dialogs/EdColorPicker.js)
+ content/messenger/messengercompose/EdAECSSAttributes.js (content/dialogs/EdAECSSAttributes.js)
+ content/messenger/messengercompose/EdAEHTMLAttributes.js (content/dialogs/EdAEHTMLAttributes.js)
+ content/messenger/messengercompose/EdAEJSEAttributes.js (content/dialogs/EdAEJSEAttributes.js)
+ content/messenger/messengercompose/EdAEAttributes.js (content/dialogs/EdAEAttributes.js)
+ content/messenger/messengercompose/EdConvertToTable.xhtml (content/dialogs/EdConvertToTable.xhtml)
+ content/messenger/messengercompose/EdConvertToTable.js (content/dialogs/EdConvertToTable.js)
+ content/messenger/messengercompose/TeXZilla.js (texzilla/TeXZilla.js)
diff --git a/comm/mail/components/compose/moz.build b/comm/mail/components/compose/moz.build
new file mode 100644
index 0000000000..2e5de88212
--- /dev/null
+++ b/comm/mail/components/compose/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+JS_PREFERENCE_PP_FILES += ["composer.js"]
diff --git a/comm/mail/components/compose/texzilla/TeXZilla.js b/comm/mail/components/compose/texzilla/TeXZilla.js
new file mode 100644
index 0000000000..0f8e3e5b29
--- /dev/null
+++ b/comm/mail/components/compose/texzilla/TeXZilla.js
@@ -0,0 +1,339 @@
+/* THIS IS A GENERATED FILE. DO NOT EDIT THIS DIRECTLY. */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+(function() {
+"using strict";
+var nb=void 0,tb=!0,xb=null,yb=!1,zb=function(){function c(b,a,c){var $a;c=c||{};for($a=b.length;$a--;c[b[$a]]=a);return c}function Fb(b){return b.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function sb(b){b="negativeveryverythinmathspace negativeverythinmathspace negativemediummathspace negativethickmathspace negativeverythickmathspace negativeveryverythickmathspace veryverythinmathspace verythinmathspace thinmathspace mediummathspace thickmathspace verythickmathspace veryverythickmathspace".split(" ").indexOf(b);
+return(-1===b?0:b-6)/18}function tc(b){b=b.trim();var a=/(-?[0-9]*(?:[0-9]\.?|\.[0-9])[0-9]*)(e[mx]|in|cm|mm|p[xtc]|%)?/.exec(b);return a?(a[1]=parseFloat(a[1]),a[2]||(a[1]*=100,a[2]="%"),{i:a[1],k:a[2]}):{i:sb(b),k:"em"}}function Pb(b){var a="<"+b.tag,c;for(c in b.attributes)b.attributes[c]!==nb&&(a+=" "+c+'="'+b.attributes[c]+'"');b.content?(a+=">",Array.isArray(b.content)?b.content.forEach(function(b){a+=Pb(b)}):a+=b.content,a+="</"+b.tag+">"):a+="/>";return a}function e(b,a,c){return{tag:b,content:a,
+attributes:c}}function ab(b,a,c){return e("mo",Fb(b),{lspace:a!==nb?a+"em":nb,rspace:c!==nb?c+"em":nb})}function Ub(b,a){return e("mi",Fb(b),a?{mathvariant:"normal"}:nb)}function Gb(b){return e("mspace",xb,{width:b+"em"})}function uc(b,a){var c="bold italic bold-italic script bold-script fraktur double-struck bold-fraktur sans-serif bold-sans-serif sans-serif-italic sans-serif-bold-italic monospace initial tailed looped stretched".split(" ").indexOf(a);if(930==b)return b;if(988==b)return 0==c?120778:
+b;if(989==b)return 0==c?120779:b;if(305==b)return 1==c?120484:b;if(567==b)return 1==c?120485:b;var $a;if(65<=b&&90>=b||97<=b&&122>=b){if(12<c)return b;c=(90>=b?b-65:26+b-97)+119808+52*c;$a={119893:8462,119965:8492,119968:8496,119969:8497,119971:8459,119972:8464,119975:8466,119976:8499,119981:8475,119994:8495,119996:8458,120004:8500,120070:8493,120075:8460,120076:8465,120085:8476,120093:8488,120122:8450,120127:8461,120133:8469,120135:8473,120136:8474,120137:8477,120145:8484};return $a[c]?$a[c]:c}if(48<=
+b&&57>=b){switch(c){case 0:c=0;break;case 6:c=1;break;case 8:c=2;break;case 9:c=3;break;case 12:c=4;break;default:return b}return b-48+10*c+120782}if(1536<=b&&1791>=b){switch(c){case 13:$a={1576:126497,1578:126517,1579:126518,1580:126498,1581:126503,1582:126519,1587:126510,1588:126516,1589:126513,1590:126521,1593:126511,1594:126523,1601:126512,1602:126514,1603:126506,1604:126507,1605:126508,1606:126509,1607:126500,1610:126505};break;case 14:$a={1580:126530,1581:126535,1582:126551,1587:126542,1588:126548,
+1589:126545,1590:126553,1593:126543,1594:126555,1602:126546,1604:126539,1606:126541,1610:126537,1647:126559,1722:126557};break;case 16:$a={1576:126561,1578:126581,1579:126582,1580:126562,1581:126567,1582:126583,1587:126574,1588:126580,1589:126577,1590:126585,1591:126568,1592:126586,1593:126575,1594:126587,1601:126576,1602:126578,1603:126570,1605:126572,1606:126573,1607:126564,1610:126569,1646:126588,1697:126590};break;case 15:$a={1575:126592,1576:126593,1578:126613,1579:126614,1580:126594,1581:126599,
+1582:126615,1583:126595,1584:126616,1585:126611,1586:126598,1587:126606,1588:126612,1589:126609,1590:126617,1591:126600,1592:126618,1593:126607,1594:126619,1601:126608,1602:126610,1604:126603,1605:126604,1606:126605,1607:126596,1608:126597,1610:126601};break;case 6:$a={1576:126625,1578:126645,1579:126646,1580:126626,1581:126631,1582:126647,1583:126627,1584:126648,1585:126643,1586:126630,1587:126638,1588:126644,1589:126641,1590:126649,1591:126632,1592:126650,1593:126639,1594:126651,1601:126640,1602:126642,
+1604:126635,1605:126636,1606:126637,1608:126629,1610:126633};break;default:return b}return $a[b]?$a[b]:b}if(913<=b&&937>=b)$a=b-913;else if(945<=b&&969>=b)$a=26+b-945;else switch(b){case 1012:$a=17;break;case 8711:$a=25;break;case 8706:$a=51;break;case 1013:$a=52;break;case 977:$a=53;break;case 1008:$a=54;break;case 981:$a=55;break;case 1009:$a=56;break;case 982:$a=57;break;default:return b}switch(c){case 0:c=0;break;case 1:c=1;break;case 2:c=2;break;case 9:c=3;break;case 11:c=4;break;default:return b}return $a+
+120488+58*c}function vc(b,a){var c=tb,$a;for($a in a)-1!==["mathcolor","mathbackground","mathvariant"].indexOf($a)?"mathvariant"!==$a&&1!=b.length?c=yb:b.forEach(function(b){if(-1!==["mi","mn","mo","mtext","ms"].indexOf(b.tag)){if(b.attributes||(b.attributes={}),!b.attributes[$a])if("mathvariant"===$a){var d;if(!(d="normal"!==a[$a])){if("mi"!==b.tag)d=yb;else{d=b.content;var e=d.codePointAt(0);d=1===d.length&&65535>=e||2===d.length&&65535<e}d=!d}if(d){if(d=a[$a],"normal"!==d){for(var e=b.content,
+g="",m=0;m<e.length;m++){var s=e.codePointAt(m);65535<s?(g+=e[m],m++,g+=e[m]):g+=String.fromCodePoint(uc(s,d))}b.content=g}}else b.attributes[$a]=a[$a]}else b.attributes[$a]=a[$a]}else c=yb}):c=yb;return c}function db(b,a,c){a=a||"mrow";if("mstyle"===a){if(1==b.length&&"mrow"===b[0].tag&&!b[0].attributes)return db(b[0].content,a,c);if(vc(b,c))return db(b)}return 1==b.length&&"mrow"===a&&!c?b[0]:e(a,b,c)}function Ib(b,a,c,$a){return e("math",[e("semantics",[db(b),e("annotation",Fb($a),{encoding:"TeX"})])],
+{xmlns:Qb,display:a?"block":nb,dir:c?"rtl":nb})}function Vb(b){if(!b||b.namespaceURI!==Qb)return xb;if("semantics"===b.tagName)for(b=b.firstElementChild;b;b=b.nextElementSibling){if(b.namespaceURI===Qb&&"annotation"===b.localName&&-1!==wc.indexOf(b.getAttribute("encoding")))return b.textContent}else if(1===b.childElementCount)return Vb(b.firstElementChild);return xb}function xc(b){for(var a="",c,$a,e=0;e<b.length;e++)c=b.charCodeAt(e),128>c?a+=b.charAt(e):55296<=c&&56319>=c?(e++,$a=b.charCodeAt(e),
+a+="&#x"+(1024*(c-55296)+$a-56320+65536).toString(16)+";"):a+="&#x"+c.toString(16)+";";return a}function Rb(){this.e={}}var Wb=[1,4],Xb=[1,6],Yb=[1,7],Zb=[1,8],$b=[1,9],Ab=[68,195,198,200,202,204],m=[1,27],s=[1,124],v=[1,52],x=[1,48],h=[1,28],q=[1,29],p=[1,30],y=[1,31],f=[1,32],u=[1,33],n=[1,34],k=[1,35],r=[1,37],t=[1,38],l=[1,39],w=[1,40],z=[1,41],A=[1,42],B=[1,43],C=[1,44],D=[1,45],E=[1,46],F=[1,47],G=[1,49],H=[1,50],I=[1,51],J=[1,53],K=[1,54],L=[1,55],M=[1,56],N=[1,57],O=[1,58],P=[1,59],Q=[1,60],
+R=[1,61],S=[1,62],T=[1,63],U=[1,64],V=[1,65],W=[1,66],X=[1,67],Y=[1,68],Z=[1,69],$=[1,70],aa=[1,71],ba=[1,72],ca=[1,73],da=[1,74],ea=[1,75],fa=[1,76],ga=[1,77],ha=[1,78],ia=[1,79],ja=[1,80],ka=[1,81],la=[1,82],ma=[1,83],na=[1,84],oa=[1,85],pa=[1,86],qa=[1,87],ra=[1,88],sa=[1,89],ta=[1,90],ua=[1,91],va=[1,92],wa=[1,93],xa=[1,94],ya=[1,95],za=[1,96],Aa=[1,97],Ba=[1,98],Ca=[1,99],Da=[1,100],Ea=[1,101],Fa=[1,102],Ga=[1,103],Ha=[1,104],Ia=[1,105],Ja=[1,106],Ka=[1,107],eb=[1,24],La=[1,108],Ma=[1,109],Na=
+[1,110],Oa=[1,111],Pa=[1,112],Qa=[1,113],Ra=[1,114],Sa=[1,115],Ta=[1,116],Ua=[1,117],Va=[1,118],Wa=[1,119],Xa=[1,120],Ya=[1,121],bb=[1,122],cb=[1,123],fb=[1,16],gb=[1,17],hb=[1,18],ib=[1,19],jb=[1,20],kb=[1,21],lb=[1,22],ac=[6,10,53,64,65,66,144,146,148,150,152,154,156,158,160,162,164,189,192,199,201,203,205],Db=[8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,
+114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,145,147,149,151,153,155,157,159,161,163,165,166,173,174,179,180,181,182,183,184,185],mb=[1,134],ub=[6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,
+141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,173,174,189,192,199,201,203,205],Za=[1,137],g=[6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,141,142,144,145,146,147,148,149,150,151,152,153,154,
+155,156,157,158,159,160,161,162,163,164,165,166,169,170,171,173,174,189,192,199,201,203,205],Sb=[1,161],pb=[2,197],qb=[1,217],vb=[1,214],Eb=[6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,
+157,158,159,160,161,162,163,164,165,166,169,170,173,174,189,192,199,201,203,205],bc=[1,241],Bb=[1,243],Cb=[1,244],Mb=[1,259],cc=[4,8],dc=[1,275],ec=[8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,141,142,145,147,149,151,153,155,157,159,161,163,165,166],wb=[1,286],
+Nb=[10,144,146,148,150,152,154,156,158,160,162,164,192],fc=[1,288],Jb=[10,144,146,148,150,152,154,156,158,160,162,164,189,192],gc=[164,189,192],Tb=[10,189,192],Kb=[1,343],Lb=[1,344],hc=[1,352],ic=[1,353],jc=[4,8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,
+145,147,149,151,153,155,157,159,161,163,165,166],Ob=[10,21,23],Hb=[10,21,23,25,27],kc=[1,399],lc=[1,400],mc=[1,401],nc=[1,402],oc=[1,403],pc=[1,404],qc=[1,405],rc=[1,406],sc=[10,19,21,23,25,27,29,31,33,35,37,39,41],ob=[10,19,21,23,29,31,33,35,37,39,41],rb={trace:function(){},e:{},la:{error:2,textOptArg:3,"[":4,TEXTOPTARG:5,"]":6,textArg:7,"{":8,TEXTARG:9,"}":10,lengthOptArg:11,lengthArg:12,attrOptArg:13,attrArg:14,tokenContent:15,arrayAlign:16,columnAlign:17,collayout:18,COLLAYOUT:19,colalign:20,
+COLALIGN:21,rowalign:22,ROWALIGN:23,rowspan:24,ROWSPAN:25,colspan:26,COLSPAN:27,align:28,ALIGN:29,eqrows:30,EQROWS:31,eqcols:32,EQCOLS:33,rowlines:34,ROWLINES:35,collines:36,COLLINES:37,frame:38,FRAME:39,padding:40,PADDING:41,cellopt:42,celloptList:43,rowopt:44,arrayopt:45,arrayoptList:46,rowoptList:47,left:48,LEFT:49,OPFS:50,".":51,right:52,RIGHT:53,closedTerm:54,styledExpression:55,BIG:56,BBIG:57,BIGG:58,BBIGG:59,BIGL:60,BBIGL:61,BIGGL:62,BBIGGL:63,TEXATOP:64,TEXOVER:65,TEXCHOOSE:66,NUM:67,TEXT:68,
+A:69,AILL:70,AIUL:71,AILG:72,AIUG:73,F:74,MI:75,MN:76,MO:77,OP:78,OPS:79,OPAS:80,MS:81,MTEXT:82,HIGH_SURROGATE:83,LOW_SURROGATE:84,BMP_CHARACTER:85,OPERATORNAME:86,MATHOP:87,MATHBIN:88,MATHREL:89,FRAC:90,ROOT:91,SQRT:92,UNDERSET:93,OVERSET:94,UNDEROVERSET:95,XARROW:96,MATHRLAP:97,MATHLLAP:98,MATHCLAP:99,PHANTOM:100,TFRAC:101,BINOM:102,TBINOM:103,PMOD:104,UNDERBRACE:105,UNDERLINE:106,OVERBRACE:107,ACCENT:108,ACCENTNS:109,BOXED:110,SLASH:111,QUAD:112,QQUAD:113,NEGSPACE:114,NEGMEDSPACE:115,NEGTHICKSPACE:116,
+THINSPACE:117,MEDSPACE:118,THICKSPACE:119,SPACE:120,MATHRAISEBOX:121,MATHBB:122,MATHBF:123,MATHBIT:124,MATHSCR:125,MATHBSCR:126,MATHSF:127,MATHFRAK:128,MATHIT:129,MATHTT:130,MATHRM:131,HREF:132,STATUSLINE:133,TOOLTIP:134,TOGGLE:135,BTOGGLE:136,closedTermList:137,ETOGGLE:138,TENSOR:139,subsupList:140,MULTI:141,BMATRIX:142,tableRowList:143,EMATRIX:144,BGATHERED:145,EGATHERED:146,BPMATRIX:147,EPMATRIX:148,BBMATRIX:149,EBMATRIX:150,BVMATRIX:151,EVMATRIX:152,BBBMATRIX:153,EBBMATRIX:154,BVVMATRIX:155,EVVMATRIX:156,
+BSMALLMATRIX:157,ESMALLMATRIX:158,BCASES:159,ECASES:160,BALIGNED:161,EALIGNED:162,BARRAY:163,EARRAY:164,SUBSTACK:165,ARRAY:166,ARRAYOPTS:167,compoundTerm:168,_:169,"^":170,OPP:171,opm:172,OPM:173,FM:174,compoundTermList:175,subsupTermScript:176,subsupTerm:177,textstyle:178,DISPLAYSTYLE:179,TEXTSTYLE:180,TEXTSIZE:181,SCRIPTSIZE:182,SCRIPTSCRIPTSIZE:183,COLOR:184,BGCOLOR:185,tableCell:186,CELLOPTS:187,tableCellList:188,COLSEP:189,tableRow:190,ROWOPTS:191,ROWSEP:192,document:193,documentItemList:194,
+EOF:195,documentItem:196,mathItem:197,STARTMATH0:198,ENDMATH0:199,STARTMATH1:200,ENDMATH1:201,STARTMATH2:202,ENDMATH2:203,STARTMATH3:204,ENDMATH3:205,$accept:0,$end:1},z:{2:"error",4:"[",5:"TEXTOPTARG",6:"]",8:"{",9:"TEXTARG",10:"}",19:"COLLAYOUT",21:"COLALIGN",23:"ROWALIGN",25:"ROWSPAN",27:"COLSPAN",29:"ALIGN",31:"EQROWS",33:"EQCOLS",35:"ROWLINES",37:"COLLINES",39:"FRAME",41:"PADDING",49:"LEFT",50:"OPFS",51:".",53:"RIGHT",56:"BIG",57:"BBIG",58:"BIGG",59:"BBIGG",60:"BIGL",61:"BBIGL",62:"BIGGL",63:"BBIGGL",
+64:"TEXATOP",65:"TEXOVER",66:"TEXCHOOSE",67:"NUM",68:"TEXT",69:"A",70:"AILL",71:"AIUL",72:"AILG",73:"AIUG",74:"F",75:"MI",76:"MN",77:"MO",78:"OP",79:"OPS",80:"OPAS",81:"MS",82:"MTEXT",83:"HIGH_SURROGATE",84:"LOW_SURROGATE",85:"BMP_CHARACTER",86:"OPERATORNAME",87:"MATHOP",88:"MATHBIN",89:"MATHREL",90:"FRAC",91:"ROOT",92:"SQRT",93:"UNDERSET",94:"OVERSET",95:"UNDEROVERSET",96:"XARROW",97:"MATHRLAP",98:"MATHLLAP",99:"MATHCLAP",100:"PHANTOM",101:"TFRAC",102:"BINOM",103:"TBINOM",104:"PMOD",105:"UNDERBRACE",
+106:"UNDERLINE",107:"OVERBRACE",108:"ACCENT",109:"ACCENTNS",110:"BOXED",111:"SLASH",112:"QUAD",113:"QQUAD",114:"NEGSPACE",115:"NEGMEDSPACE",116:"NEGTHICKSPACE",117:"THINSPACE",118:"MEDSPACE",119:"THICKSPACE",120:"SPACE",121:"MATHRAISEBOX",122:"MATHBB",123:"MATHBF",124:"MATHBIT",125:"MATHSCR",126:"MATHBSCR",127:"MATHSF",128:"MATHFRAK",129:"MATHIT",130:"MATHTT",131:"MATHRM",132:"HREF",133:"STATUSLINE",134:"TOOLTIP",135:"TOGGLE",136:"BTOGGLE",138:"ETOGGLE",139:"TENSOR",141:"MULTI",142:"BMATRIX",144:"EMATRIX",
+145:"BGATHERED",146:"EGATHERED",147:"BPMATRIX",148:"EPMATRIX",149:"BBMATRIX",150:"EBMATRIX",151:"BVMATRIX",152:"EVMATRIX",153:"BBBMATRIX",154:"EBBMATRIX",155:"BVVMATRIX",156:"EVVMATRIX",157:"BSMALLMATRIX",158:"ESMALLMATRIX",159:"BCASES",160:"ECASES",161:"BALIGNED",162:"EALIGNED",163:"BARRAY",164:"EARRAY",165:"SUBSTACK",166:"ARRAY",167:"ARRAYOPTS",169:"_",170:"^",171:"OPP",173:"OPM",174:"FM",179:"DISPLAYSTYLE",180:"TEXTSTYLE",181:"TEXTSIZE",182:"SCRIPTSIZE",183:"SCRIPTSCRIPTSIZE",184:"COLOR",185:"BGCOLOR",
+187:"CELLOPTS",189:"COLSEP",191:"ROWOPTS",192:"ROWSEP",195:"EOF",198:"STARTMATH0",199:"ENDMATH0",200:"STARTMATH1",201:"ENDMATH1",202:"STARTMATH2",203:"ENDMATH2",204:"STARTMATH3",205:"ENDMATH3"},W:[0,[3,3],[7,3],[11,3],[12,3],[13,1],[14,1],[15,1],[16,1],[17,1],[18,2],[20,2],[22,2],[24,2],[26,2],[28,2],[30,2],[32,2],[34,2],[36,2],[38,2],[40,2],[42,1],[42,1],[42,1],[42,1],[43,1],[43,2],[44,1],[44,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[45,1],[46,1],[46,2],[47,1],[47,2],[48,
+2],[48,2],[52,2],[52,2],[54,2],[54,3],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,3],[54,5],[54,5],[54,5],[54,5],[54,5],[54,5],[54,1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,2],[54,2],[54,2],[54,1],[54,1],[54,1],[54,1],[54,1],[54,2],[54,4],[54,2],[54,2],[54,1],[54,2],[54,2],[54,2],[54,2],[54,3],[54,3],[54,2],[54,5],[54,3],[54,3],[54,4],[54,5],[54,2],[54,2],[54,2],[54,2],[54,2],[54,3],[54,3],[54,3],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,1],[54,1],[54,
+1],[54,1],[54,1],[54,1],[54,1],[54,1],[54,4],[54,5],[54,4],[54,3],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,2],[54,3],[54,3],[54,3],[54,3],[54,3],[54,5],[54,8],[54,7],[54,7],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,3],[54,5],[54,4],[54,4],[54,4],[54,8],[137,1],[137,2],[168,3],[168,5],[168,4],[168,5],[168,4],[168,3],[168,3],[168,2],[168,1],[168,5],[168,5],[168,3],[168,3],[168,1],[172,1],[172,1],[175,1],[175,2],[176,1],[176,1],[177,4],[177,2],[177,
+2],[177,3],[140,1],[140,2],[178,1],[178,1],[178,1],[178,1],[178,1],[178,2],[178,2],[55,2],[55,1],[186,0],[186,5],[186,1],[188,1],[188,3],[190,5],[190,1],[143,1],[143,3],[193,2],[194,1],[194,2],[196,1],[196,1],[197,2],[197,3],[197,2],[197,3],[197,3],[197,3]],H:function(b,a,c,$a,g,d){b=d.length-1;switch(g){case 1:this.b=d[b-1].replace(/\\[\\\]]/g,function(a){return a.slice(1)});this.b=Fb(this.b);break;case 2:this.b=d[b-1].replace(/\\[\\\}]/g,function(a){return a.slice(1)});this.b=Fb(this.b);break;case 3:case 4:this.b=
+tc(d[b-1]);break;case 5:case 6:this.b=d[b].replace(/"/g,"&#x22;");break;case 7:this.b=d[b].replace(/\s+/g," ").replace(/^ | $/g," ");break;case 8:d[b]=d[b].trim();if("t"===d[b])this.b="axis 1";else if("c"===d[b])this.b="center";else if("b"===d[b])this.b="axis -1";else throw"Unknown array alignment";break;case 9:this.b="";d[b]=d[b].replace(/\s+/g,"");for($a=0;$a<d[b].length;$a++)"c"===d[b][$a]?this.b+=" center":"l"===d[b][$a]?this.b+=" left":"r"===d[b][$a]&&(this.b+=" right");if(this.b.length)this.b=
+this.b.slice(1);else throw"Invalid column alignments";break;case 10:case 11:this.b={columnalign:d[b]};break;case 12:this.b={rowalign:d[b]};break;case 13:this.b={rowspan:d[b]};break;case 14:this.b={colspan:d[b]};break;case 15:this.b={align:d[b]};break;case 16:this.b={equalrows:d[b]};break;case 17:this.b={equalcolumns:d[b]};break;case 18:this.b={rowlines:d[b]};break;case 19:this.b={columnlines:d[b]};break;case 20:this.b={frame:d[b]};break;case 21:this.b={rowspacing:d[b],columnspacing:d[b]};break;case 22:case 23:case 24:case 25:case 26:case 28:case 29:case 30:case 31:case 32:case 33:case 34:case 35:case 36:case 37:case 38:case 39:case 40:case 42:case 170:case 175:case 180:case 181:case 186:case 196:case 207:case 209:this.b=
+d[b];break;case 27:case 41:case 43:this.b=Object.assign(d[b-1],d[b]);break;case 44:case 46:this.b=ab(d[b]);break;case 45:case 47:this.b="";break;case 48:this.b=e("mrow");break;case 49:this.b=db(d[b-1]);break;case 50:case 54:this.b=e("mo",d[b],{maxsize:"1.2em",minsize:"1.2em"});break;case 51:case 55:this.b=e("mo",d[b],{maxsize:"1.8em",minsize:"1.8em"});break;case 52:case 56:this.b=e("mo",d[b],{maxsize:"2.4em",minsize:"2.4em"});break;case 53:case 57:this.b=e("mo",d[b],{maxsize:"3em",minsize:"3em"});
+break;case 58:this.b=e("mrow",[d[b-2],db(d[b-1]),d[b]]);break;case 59:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});break;case 60:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});this.b=e("mrow",[d[b-4],this.b,d[b]]);break;case 61:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])]);break;case 62:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])]);this.b=e("mrow",[d[b-4],this.b,d[b]]);break;case 63:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});this.b=e("mrow",[ab("("),
+this.b,ab(")")]);break;case 64:this.b=e("mfrac",[db(d[b-3]),db(d[b-1])],{linethickness:"0px"});this.b=e("mrow",[d[b-4],this.b,d[b]]);this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 65:case 74:this.b=e("mn",d[b]);break;case 66:case 83:case 85:this.b=e("mtext",d[b]);break;case 67:case 68:case 69:case 70:this.b=Ub(d[b]);break;case 71:this.b=Ub(d[b],tb);break;case 72:case 177:this.b=ab(d[b],0,0);break;case 73:this.b=e("mi",d[b]);break;case 75:case 76:case 77:case 176:this.b=ab(d[b]);break;case 78:case 79:case 80:this.b=
+e("mo",d[b],{stretchy:"false"});break;case 81:this.b=e("ms",d[b]);break;case 82:this.b=e("ms",d[b],{lquote:d[b-2],rquote:d[b-1]});break;case 84:this.b=e("mtext",d[b-1]+d[b]);break;case 86:this.b=ab(d[b],0,sb("thinmathspace"));break;case 87:this.b=ab(d[b],sb("thinmathspace"),sb("thinmathspace"));break;case 88:this.b=ab(d[b],sb("mediummathspace"),sb("mediummathspace"));break;case 89:this.b=ab(d[b],sb("thickmathspace"),sb("thickmathspace"));break;case 90:this.b=e("mfrac",[d[b-1],d[b]]);break;case 91:this.b=
+e("mroot",[d[b],d[b-1]]);break;case 92:this.b=e("msqrt",[d[b]]);break;case 93:this.b=e("mroot",[d[b],db(d[b-2])]);break;case 94:this.b=e("munder",[d[b],d[b-1]]);break;case 95:this.b=e("mover",[d[b],d[b-1]]);break;case 96:this.b=e("munderover",[d[b],d[b-2],d[b-1]]);break;case 97:this.b="mrow"===d[b].tag&&!d[b].content&&!d[b].attributes?e("munder",[ab(d[b-4]),db(d[b-2])]):e("munderover",[ab(d[b-4]),db(d[b-2]),d[b]]);break;case 98:this.b=e("mover",[ab(d[b-1]),d[b]]);break;case 99:this.b=e("mpadded",
+[d[b]],{width:"0em"});break;case 100:this.b=e("mpadded",[d[b]],{width:"0em",lspace:"-100%width"});break;case 101:this.b=e("mpadded",[d[b]],{width:"0em",lspace:"-50%width"});break;case 102:this.b=e("mphantom",[d[b]]);break;case 103:this.b=e("mfrac",[d[b-1],d[b]]);this.b=db([this.b],"mstyle",{displaystyle:"false"});break;case 104:this.b=e("mfrac",[d[b-1],d[b]],{linethickness:"0px"});this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 105:this.b=e("mfrac",[d[b-1],d[b]],{linethickness:"0px"});this.b=
+db([this.b],"mstyle",{displaystyle:"false"});this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 106:this.b=e("mrow",[ab("(",sb("mediummathspace")),ab("mod",nb,sb("thinmathspace")),d[b],ab(")",nb,sb("mediummathspace"))]);break;case 107:this.b=e("munder",[d[b],ab("⏟")]);break;case 108:this.b=e("munder",[d[b],ab("_")]);break;case 109:this.b=e("mover",[d[b],ab("⏞")]);break;case 110:this.b=e("mover",[d[b],ab(d[b-1])]);break;case 111:this.b=e("mover",[d[b],e("mo",d[b-1],{stretchy:"false"})]);break;case 112:this.b=
+e("menclose",[d[b]],{notation:"box"});break;case 113:this.b=e("menclose",[d[b]],{notation:"updiagonalstrike"});break;case 114:this.b=Gb(1);break;case 115:this.b=Gb(2);break;case 116:this.b=Gb(sb("negativethinmathspace"));break;case 117:this.b=Gb(sb("negativemediummathspace"));break;case 118:this.b=Gb(sb("negativethickmathspace"));break;case 119:this.b=Gb(sb("thinmathspace"));break;case 120:this.b=Gb(sb("mediummathspace"));break;case 121:this.b=Gb(sb("thickmathspace"));break;case 122:this.b=e("mspace",
+xb,{height:"."+d[b-2]+"ex",depth:"."+d[b-1]+"ex",width:"."+d[b]+"em"});break;case 123:this.b=e("mpadded",[d[b]],{voffset:d[b-3].i+d[b-3].k,height:d[b-2].i+d[b-2].k,depth:d[b-1].i+d[b-1].k});break;case 124:this.b=e("mpadded",[d[b]],{voffset:d[b-2].i+d[b-2].k,height:d[b-1].i+d[b-1].k,depth:0>d[b-2].i?"+"+-d[b-2].i+d[b-2].k:"depth"});break;case 125:$a={voffset:d[b-1].i+d[b-1].k};0<=d[b-1].i?$a.height="+"+d[b-1].i+d[b-1].k:($a.height="0pt",$a.depth="+"+-d[b-1].i+d[b-1].k);this.b=e("mpadded",[d[b]],$a);
+break;case 126:this.b=db([d[b]],"mstyle",{mathvariant:"double-struck"});break;case 127:this.b=db([d[b]],"mstyle",{mathvariant:"bold"});break;case 128:this.b=db([d[b]],"mstyle",{mathvariant:"bold-italic"});break;case 129:this.b=db([d[b]],"mstyle",{mathvariant:"script"});break;case 130:this.b=db([d[b]],"mstyle",{mathvariant:"bold-script"});break;case 131:this.b=db([d[b]],"mstyle",{mathvariant:"sans-serif"});break;case 132:this.b=db([d[b]],"mstyle",{mathvariant:"fraktur"});break;case 133:this.b=db([d[b]],
+"mstyle",{mathvariant:"italic"});break;case 134:this.b=db([d[b]],"mstyle",{mathvariant:"monospace"});break;case 135:this.b=db([d[b]],"mstyle",{mathvariant:"normal"});break;case 136:this.b=e("mrow",[d[b]],$a.v?xb:{href:d[b-1]});break;case 137:this.b=$a.v?d[b]:e("maction",[d[b],e("mtext",d[b-1])],{actiontype:"statusline"});break;case 138:this.b=$a.v?d[b]:e("maction",[d[b],e("mtext",d[b-1])],{actiontype:"tooltip"});break;case 139:this.b=$a.v?d[b]:e("maction",[d[b-1],d[b]],{actiontype:"toggle",selection:"2"});
+break;case 140:this.b=$a.v?e("mrow",d[b-1]):e("maction",d[b-1],{actiontype:"toggle"});break;case 141:case 144:this.b=e("mmultiscripts",[d[b-3]].concat(d[b-1]));break;case 142:this.b=e("mmultiscripts",[d[b-3]].concat(d[b-1]).concat(e("mprescripts")).concat(d[b-5]));break;case 143:this.b=e("mmultiscripts",[d[b-2],e("mprescripts")].concat(d[b-4]));break;case 145:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});break;case 146:this.b=e("mtable",d[b-1],{displaystyle:"true",rowspacing:"1.0ex"});
+break;case 147:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("("),this.b,ab(")")]);break;case 148:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("["),this.b,ab("]")]);break;case 149:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("|"),this.b,ab("|")]);break;case 150:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("{"),this.b,ab("}")]);break;
+case 151:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=e("mrow",[ab("‖"),this.b,ab("‖")]);break;case 152:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex"});this.b=db([this.b],"mstyle",{scriptlevel:"2"});break;case 153:this.b=e("mtable",d[b-1],{displaystyle:"false",columnalign:"left left"});this.b=e("mrow",[ab("{"),this.b]);break;case 154:this.b=e("mtable",d[b-1],{displaystyle:"true",columnalign:"right left right left right left right left right left",
+columnspacing:"0em"});break;case 155:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex",align:d[b-3],columnalign:d[b-2]});break;case 156:this.b=e("mtable",d[b-1],{displaystyle:"false",rowspacing:"0.5ex",columnalign:d[b-2]});break;case 157:this.b=e("mtable",d[b-1],{displaystyle:"false",columnalign:"center",rowspacing:"0.5ex"});break;case 158:this.b=e("mtable",d[b-1],{displaystyle:"false"});break;case 159:this.b=e("mtable",d[b-1],Object.assign(d[b-3],{displaystyle:"false"}));break;case 160:this.b=
+[d[b]];break;case 161:this.b=d[b-1].concat([d[b]]);break;case 162:this.b=e("mmultiscripts",[d[b-1]].concat(d[b]));break;case 163:this.b=e("msubsup",[d[b-4],d[b-2],d[b]]);break;case 164:this.b=e("msubsup",[d[b-3],d[b-1],ab(d[b])]);break;case 165:this.b=e("msubsup",[d[b-4],d[b],d[b-2]]);break;case 166:this.b=e("msubsup",[d[b-3],d[b],ab(d[b-2])]);break;case 167:this.b=e("msub",[d[b-2],d[b]]);break;case 168:this.b=e("msup",[d[b-2],d[b]]);break;case 169:this.b=e("msup",[d[b-1],ab(d[b])]);break;case 171:this.b=
+e("munderover",[d[b-4],d[b-2],d[b]]);break;case 172:this.b=e("munderover",[d[b-4],d[b],d[b-2]]);break;case 173:this.b=e("munder",[d[b-2],d[b]]);break;case 174:this.b=e("mover",[d[b-2],d[b]]);break;case 178:case 200:case 204:this.b=[d[b]];break;case 179:this.b=d[b-1].concat([d[b]]);break;case 182:this.b=[d[b-2],d[b]];break;case 183:this.b=[d[b],e("none")];break;case 184:case 185:this.b=[e("none"),d[b]];break;case 187:this.b=d[b-1].concat(d[b]);break;case 188:this.b={displaystyle:"true"};break;case 189:this.b=
+{displaystyle:"false"};break;case 190:this.b={scriptlevel:"0"};break;case 191:this.b={scriptlevel:"1"};break;case 192:this.b={scriptlevel:"2"};break;case 193:this.b={mathcolor:d[b]};break;case 194:this.b={mathbackground:d[b]};break;case 195:this.b=[db(d[b],"mstyle",d[b-1])];break;case 197:this.b=e("mtd",[]);break;case 198:this.b=db(d[b],"mtd",d[b-2]);break;case 199:this.b=db(d[b],"mtd");break;case 201:case 205:this.b=d[b-2].concat([d[b]]);break;case 202:this.b=this.b=e("mtr",d[b],d[b-2]);break;case 203:this.b=
+e("mtr",d[b]);break;case 206:return this.b=d[b-1];case 208:this.b=d[b-1]+d[b];break;case 210:this.b=Pb(d[b]);break;case 211:this.b=Ib([e("mrow")],yb,yb,$a.t);break;case 212:this.b=Ib(d[b-1],yb,yb,$a.t);break;case 213:this.b=Ib([e("mrow")],tb,yb,$a.t);break;case 214:this.b=Ib(d[b-1],tb,yb,$a.t);break;case 215:this.b=Ib(d[b-1],yb,yb,$a.t);break;case 216:this.b=Ib(d[b-1],tb,yb,$a.t)}},ma:[{68:Wb,193:1,194:2,196:3,197:5,198:Xb,200:Yb,202:Zb,204:$b},{1:[3]},{68:Wb,195:[1,10],196:11,197:5,198:Xb,200:Yb,
+202:Zb,204:$b},c(Ab,[2,207]),c(Ab,[2,209]),c(Ab,[2,210]),{8:m,48:36,49:s,50:v,51:x,54:25,55:13,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,199:[1,12]},{8:m,48:36,49:s,50:v,51:x,54:25,55:126,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,
+101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,201:[1,125]},{8:m,48:36,49:s,50:v,51:x,54:25,55:127,56:h,
+57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,
+153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:128,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,
+117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{1:[2,206]},c(Ab,[2,208]),c(Ab,[2,211]),{199:[1,129]},{8:m,48:36,49:s,50:v,51:x,54:25,55:130,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,
+74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,
+173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(ac,[2,196],{54:25,172:26,48:36,168:131,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,
+124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb}),c(Db,[2,188]),c(Db,[2,189]),c(Db,[2,190]),c(Db,[2,191]),c(Db,[2,192]),{7:133,8:mb,14:132},{7:133,8:mb,14:135},c(ub,[2,178]),{8:m,48:36,49:s,50:v,51:x,54:136,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,
+87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,170],{169:[1,138],170:[1,139],171:[1,140]}),c(ub,[2,175],{169:[1,
+141],170:[1,142]}),{8:m,10:[1,143],48:36,49:s,50:v,51:x,54:25,55:144,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,
+134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{50:[1,145]},{50:[1,146]},{50:[1,147]},{50:[1,148]},{50:[1,149]},{50:[1,150]},{50:[1,151]},{50:[1,152]},{8:m,48:36,49:s,50:v,51:x,54:25,55:153,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,
+88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,
+184:kb,185:lb},c(g,[2,65]),c(g,[2,66]),c(g,[2,67]),c(g,[2,68]),c(g,[2,69]),c(g,[2,70]),c(g,[2,71]),c(g,[2,72]),{7:155,8:mb,15:154},{7:155,8:mb,15:156},{7:155,8:mb,15:157},c(g,[2,76]),c(g,[2,77]),c(g,[2,78]),c(g,[2,79]),c(g,[2,80]),{3:160,4:Sb,7:155,8:mb,13:159,15:158},{7:155,8:mb,15:162},{84:[1,163]},c(g,[2,85]),{7:164,8:mb},{7:165,8:mb},{7:166,8:mb},{7:167,8:mb},{8:m,48:36,49:s,50:v,51:x,54:168,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,
+80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:169,56:h,57:q,
+58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,
+155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{4:[1,171],8:m,48:36,49:s,50:v,51:x,54:170,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:172,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,
+117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:173,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,
+104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:174,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,
+87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{4:[1,175],8:m,48:36,49:s,50:v,51:x,54:176,56:h,57:q,58:p,59:y,60:f,61:u,
+62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,
+161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:177,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,
+135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:178,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,
+122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:179,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,
+109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:180,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,
+94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:181,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,
+74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,
+49:s,50:v,51:x,54:182,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,
+145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:183,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,
+127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:184,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:185,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,
+101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:186,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,
+81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:187,56:h,57:q,58:p,
+59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,
+157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:188,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,
+133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:189,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,
+120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:190,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,
+107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:191,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,
+91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,114]),c(g,[2,115]),c(g,[2,116]),c(g,[2,117]),c(g,[2,118]),c(g,[2,119]),c(g,[2,120]),
+c(g,[2,121]),{7:192,8:mb},{8:[1,194],12:193},{8:m,48:36,49:s,50:v,51:x,54:195,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,
+132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:196,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,
+119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:197,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,
+106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:198,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,
+89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:199,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,
+69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,
+166:Ya},{8:m,48:36,49:s,50:v,51:x,54:200,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,
+141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:201,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,
+125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:202,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,
+112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:203,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,
+98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:204,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,
+78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{7:133,8:mb,14:205},{7:206,8:mb},
+{7:207,8:mb},{8:m,48:36,49:s,50:v,51:x,54:208,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,
+139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:210,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,
+124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,137:209,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:[1,211]},c([144,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,143:212,190:213,188:215,186:216,55:218,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,
+94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([146,189,192],pb,{178:14,175:15,
+168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:219,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,
+131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([148,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:220,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,
+92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([150,189,192],pb,
+{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:221,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,
+129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([152,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:222,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,
+89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([154,
+189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:223,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,
+128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([156,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:224,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,
+88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),
+c([158,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:225,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,
+127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c([160,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:226,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,
+86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,
+191:vb}),c([162,189,192],pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:227,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,
+125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{3:230,4:Sb,7:231,8:mb,16:228,17:229},{8:[1,232]},{8:[1,233]},c(Eb,[2,176]),c(Eb,[2,177]),{50:[1,234],51:[1,235]},c(Ab,[2,213]),{201:[1,236]},{203:[1,237]},{205:[1,238]},c(Ab,[2,212]),c(ac,[2,195]),c(ub,[2,179]),c(Db,[2,193]),c([8,10,
+19,21,23,25,27,29,31,33,35,37,39,41,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,145,147,149,151,153,155,157,159,161,163,165,166,173,174,179,180,181,182,183,184,185],[2,6]),{9:[1,239]},c(Db,[2,194]),{8:bc,140:240,169:Bb,170:Cb,177:242},{8:m,48:36,49:s,50:v,
+51:x,54:245,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,
+149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:246,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,
+129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:247,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,
+116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,169],{169:[1,248]}),{8:m,48:36,49:s,50:v,51:x,54:249,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,
+98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:250,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,
+78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,48]),{10:[1,251],64:[1,252],
+65:[1,253],66:[1,254]},c(g,[2,50]),c(g,[2,51]),c(g,[2,52]),c(g,[2,53]),c(g,[2,54]),c(g,[2,55]),c(g,[2,56]),c(g,[2,57]),{52:255,53:Mb,64:[1,256],65:[1,257],66:[1,258]},c(g,[2,73]),c(g,[2,7]),c(g,[2,74]),c(g,[2,75]),c(g,[2,81]),{3:160,4:Sb,13:260},c(cc,[2,5]),{5:[1,261]},c(g,[2,83]),c(g,[2,84]),c(g,[2,86]),c(g,[2,87]),c(g,[2,88]),c(g,[2,89]),{8:m,48:36,49:s,50:v,51:x,54:262,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,
+86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:263,56:h,57:q,58:p,59:y,60:f,61:u,62:n,
+63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,
+161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,92]),{8:m,48:36,49:s,50:v,51:x,54:25,55:264,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,
+132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:265,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,
+106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:266,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,
+89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:267,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,
+69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,
+166:Ya},{8:m,48:36,49:s,50:v,51:x,54:25,55:268,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,
+139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(g,[2,98]),c(g,[2,99]),c(g,[2,100]),c(g,[2,101]),c(g,[2,102]),{8:m,48:36,49:s,50:v,51:x,54:269,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,
+102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:270,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,
+83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:271,56:h,57:q,58:p,59:y,60:f,
+61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,
+159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,106]),c(g,[2,107]),c(g,[2,108]),c(g,[2,109]),c(g,[2,110]),c(g,[2,111]),c(g,[2,112]),c(g,[2,113]),{7:272,8:mb},{4:dc,8:m,11:273,48:36,49:s,50:v,51:x,54:274,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,
+115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{9:[1,276]},c(g,[2,126]),c(g,[2,127]),c(g,[2,128]),c(g,[2,129]),c(g,[2,130]),c(g,[2,131]),c(g,[2,132]),c(g,[2,133]),c(g,[2,134]),c(g,[2,135]),{8:m,48:36,49:s,50:v,51:x,54:277,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,
+73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,
+49:s,50:v,51:x,54:278,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,
+145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:279,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,
+127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:280,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:282,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,
+101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,138:[1,281],139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ec,[2,160]),{10:[1,284],140:283,169:Bb,170:Cb,177:242},{144:[1,285],192:wb},c(Nb,[2,204]),{8:[1,287]},c(Nb,[2,203],{189:fc}),c(Jb,
+[2,200]),{8:[1,289]},c(Jb,[2,199]),{146:[1,290],192:wb},{148:[1,291],192:wb},{150:[1,292],192:wb},{152:[1,293],192:wb},{154:[1,294],192:wb},{156:[1,295],192:wb},{158:[1,296],192:wb},{160:[1,297],192:wb},{162:[1,298],192:wb},{7:231,8:mb,17:299},c(gc,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:300,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,
+90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{8:[2,8]},
+c([8,49,50,51,56,57,58,59,60,61,62,63,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,145,147,149,151,153,155,157,159,161,163,164,165,166,173,174,179,180,181,182,183,184,185,187,189,191,192],[2,9]),c(Tb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:301,8:m,49:s,
+50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,
+149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c(Tb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:302,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,
+107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,167:[1,303],173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c(Db,[2,44]),c(Db,[2,45]),c(Ab,[2,214]),c(Ab,[2,215]),c(Ab,[2,216]),{10:[1,304]},c(ub,[2,162],{177:305,
+169:Bb,170:Cb}),{140:306,169:Bb,170:Cb,177:242},c(Eb,[2,186]),{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,170:[1,308],172:310,173:bb,174:cb,176:307},{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,
+111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,172:310,173:bb,174:cb,176:311},{8:bc},c(ub,[2,167],{170:[1,312],171:[1,313]}),c(ub,[2,168],{169:[1,314]}),{8:m,48:36,49:s,50:v,51:x,54:315,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,
+74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,173],
+{170:[1,316]}),c(ub,[2,174],{169:[1,317]}),c(g,[2,49]),{8:m,48:36,49:s,50:v,51:x,54:25,55:318,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:319,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,
+103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:320,56:h,57:q,58:p,59:y,60:f,61:u,
+62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,
+161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(g,[2,58]),{8:m,48:36,49:s,50:v,51:x,54:25,55:321,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,
+119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:322,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,
+88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,
+184:kb,185:lb},{8:m,48:36,49:s,50:v,51:x,54:25,55:323,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,
+136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},{50:[1,324],51:[1,325]},{7:155,8:mb,15:326},{6:[1,327]},c(g,[2,90]),c(g,[2,91]),{6:[1,328]},c(g,[2,94]),c(g,[2,95]),{8:m,48:36,49:s,50:v,51:x,54:329,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,
+90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{6:[1,330]},c(g,[2,103]),c(g,[2,104]),c(g,[2,105]),{7:331,8:mb},{4:dc,8:m,11:332,48:36,
+49:s,50:v,51:x,54:333,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,
+145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,125]),{5:[1,334]},{10:[1,335]},c(g,[2,136]),c(g,[2,137]),c(g,[2,138]),c(g,[2,139]),c(g,[2,140]),c(ec,[2,161]),{10:[1,336],169:Bb,170:Cb,177:305},{8:m,48:36,49:s,50:v,51:x,54:337,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,
+105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,145]),c(Jb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,188:215,186:216,55:218,190:338,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,
+71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,
+173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{20:341,21:Kb,22:342,23:Lb,44:340,47:339},c(Jb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,55:218,186:345,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb}),{20:348,21:Kb,22:349,23:Lb,24:350,25:hc,26:351,27:ic,42:347,43:346},c(g,[2,146]),c(g,[2,147]),c(g,[2,148]),c(g,[2,149]),c(g,[2,150]),c(g,[2,151]),c(g,[2,152]),c(g,
+[2,153]),c(g,[2,154]),c(gc,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:354,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,
+125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),{164:[1,355],192:wb},{10:[1,356],192:wb},{10:[1,357],192:wb},{8:[1,358]},c([6,8,10,19,21,23,25,27,29,31,33,35,37,39,41,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,85,86,87,88,89,90,
+91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,169,170,171,173,174,179,180,181,182,183,184,185,187,189,191,192,199,201,203,205],[2,2]),c(Eb,[2,187]),{10:[1,359],169:Bb,170:Cb,177:305},c([6,8,10,49,50,51,53,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,
+76,77,78,79,80,81,82,83,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,139,141,142,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,169,173,174,189,192,199,201,203,205],[2,183],{170:[1,360]}),{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,
+77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,172:310,173:bb,174:cb,176:361},
+c(Eb,[2,180]),c(Eb,[2,181]),c(Eb,[2,184]),{8:m,48:36,49:s,50:v,51:x,54:362,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,
+133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,164]),{8:m,48:36,49:s,50:v,51:x,54:363,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,
+118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(ub,[2,166]),{8:m,48:36,49:s,50:v,51:x,54:364,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,
+103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{8:m,48:36,49:s,50:v,51:x,54:365,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,
+85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},{10:[1,366]},{10:[1,367]},{10:[1,368]},{52:369,53:Mb},{52:370,
+53:Mb},{52:371,53:Mb},c(g,[2,46]),c(g,[2,47]),c(g,[2,82]),c(cc,[2,1]),{8:m,48:36,49:s,50:v,51:x,54:372,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,
+129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,96]),{8:m,48:36,49:s,50:v,51:x,54:373,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,
+114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,122]),{8:m,48:36,49:s,50:v,51:x,54:374,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,
+98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},c(g,[2,124]),{6:[1,375]},c(jc,[2,4]),{8:m,48:36,49:s,50:v,51:x,54:376,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,
+71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya},
+{8:[1,377]},c(Nb,[2,205]),{10:[1,378],20:341,21:Kb,22:342,23:Lb,44:379},c(Ob,[2,42]),c(Ob,[2,28]),c(Ob,[2,29]),{7:133,8:mb,14:380},{7:133,8:mb,14:381},c(Jb,[2,201]),{10:[1,382],20:348,21:Kb,22:349,23:Lb,24:350,25:hc,26:351,27:ic,42:383},c(Hb,[2,26]),c(Hb,[2,22]),c(Hb,[2,23]),c(Hb,[2,24]),c(Hb,[2,25]),{7:133,8:mb,14:384},{7:133,8:mb,14:385},{164:[1,386],192:wb},c(g,[2,156]),c(g,[2,157]),c(g,[2,158]),{18:389,19:kc,20:390,21:Kb,22:391,23:Lb,28:392,29:lc,30:393,31:mc,32:394,33:nc,34:395,35:oc,36:396,
+37:pc,38:397,39:qc,40:398,41:rc,45:388,46:387},c(g,[2,141]),{8:m,48:36,49:s,50:v,51:x,54:309,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,
+130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:Za,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,172:310,173:bb,174:cb,176:407},c(Eb,[2,185]),c(ub,[2,163]),c(ub,[2,165]),c(ub,[2,171]),c(ub,[2,172]),c(g,[2,59]),c(g,[2,61]),c(g,[2,63]),c(g,[2,60]),c(g,[2,62]),c(g,[2,64]),c(g,[2,93]),c(g,[2,97]),c(g,[2,123]),c(jc,[2,3]),{8:[1,408]},{140:409,169:Bb,170:Cb,177:242},c(Jb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,186:216,55:218,188:410,8:m,49:s,
+50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,
+149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb}),c(Ob,[2,43]),c(sc,[2,11]),c(sc,[2,12]),{8:m,48:36,49:s,50:v,51:x,54:25,55:411,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,
+112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,168:23,172:26,173:bb,174:cb,175:15,178:14,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb},c(Hb,[2,27]),c(Hb,[2,13]),c(Hb,[2,14]),c(g,[2,155]),{10:[1,412],18:389,19:kc,20:390,21:Kb,22:391,23:Lb,28:392,29:lc,30:393,31:mc,
+32:394,33:nc,34:395,35:oc,36:396,37:pc,38:397,39:qc,40:398,41:rc,45:413},c(ob,[2,40]),c(ob,[2,30]),c(ob,[2,31]),c(ob,[2,32]),c(ob,[2,33]),c(ob,[2,34]),c(ob,[2,35]),c(ob,[2,36]),c(ob,[2,37]),c(ob,[2,38]),c(ob,[2,39]),{7:133,8:mb,14:414},{7:133,8:mb,14:415},{7:133,8:mb,14:416},{7:133,8:mb,14:417},{7:133,8:mb,14:418},{7:133,8:mb,14:419},{7:133,8:mb,14:420},{7:133,8:mb,14:421},c(Eb,[2,182]),{10:[1,423],140:422,169:Bb,170:Cb,177:242},{10:[1,424],169:Bb,170:Cb,177:305},c(Nb,[2,202],{189:fc}),c(Jb,[2,198]),
+c(Tb,pb,{178:14,175:15,168:23,54:25,172:26,48:36,190:213,188:215,186:216,55:218,143:425,8:m,49:s,50:v,51:x,56:h,57:q,58:p,59:y,60:f,61:u,62:n,63:k,67:r,68:t,69:l,70:w,71:z,72:A,73:B,74:C,75:D,76:E,77:F,78:G,79:H,80:I,81:J,82:K,83:L,85:M,86:N,87:O,88:P,89:Q,90:R,91:S,92:T,93:U,94:V,95:W,96:X,97:Y,98:Z,99:$,100:aa,101:ba,102:ca,103:da,104:ea,105:fa,106:ga,107:ha,108:ia,109:ja,110:ka,111:la,112:ma,113:na,114:oa,115:pa,116:qa,117:ra,118:sa,119:ta,120:ua,121:va,122:wa,123:xa,124:ya,125:za,126:Aa,127:Ba,
+128:Ca,129:Da,130:Ea,131:Fa,132:Ga,133:Ha,134:Ia,135:Ja,136:Ka,139:eb,141:La,142:Ma,145:Na,147:Oa,149:Pa,151:Qa,153:Ra,155:Sa,157:Ta,159:Ua,161:Va,163:Wa,165:Xa,166:Ya,173:bb,174:cb,179:fb,180:gb,181:hb,182:ib,183:jb,184:kb,185:lb,187:qb,191:vb}),c(ob,[2,41]),c(ob,[2,10]),c(ob,[2,15]),c(ob,[2,16]),c(ob,[2,17]),c(ob,[2,18]),c(ob,[2,19]),c(ob,[2,20]),c(ob,[2,21]),{10:[1,426],169:Bb,170:Cb,177:305},c(g,[2,143]),c(g,[2,144]),{10:[1,427],192:wb},c(g,[2,142]),c(g,[2,159])],N:{10:[2,206],230:[2,8]},parseError:function(b,
+a){if(a.va)this.trace(b);else{var c=Error(b);c.hash=a;throw c;}},parse:function(b){var a=[0],c=[xb],e=[],g=this.ma,d="",m=0,s=0,v=0,x=e.slice.call(arguments,1),h=Object.create(this.S),q={},p;for(p in this.e)Object.prototype.hasOwnProperty.call(this.e,p)&&(q[p]=this.e[p]);h.ga(b,q);q.S=h;q.V=this;"undefined"==typeof h.c&&(h.c={});p=h.c;e.push(p);var y=h.options&&h.options.w;this.parseError="function"===typeof q.parseError?q.parseError:Object.getPrototypeOf(this).parseError;for(var f,u,n,k,r={},t,l;;){n=
+a[a.length-1];if(this.N[n])k=this.N[n];else{if(f===xb||"undefined"==typeof f)f=nb,f=h.R()||1,"number"!==typeof f&&(f=this.la[f]||f);k=g[n]&&g[n][f]}if("undefined"===typeof k||!k.length||!k[0]){var w="";l=[];for(t in g[n])this.z[t]&&2<t&&l.push("'"+this.z[t]+"'");w=h.D?"Parse error on line "+(m+1)+":\n"+h.D()+"\nExpecting "+l.join(", ")+", got '"+(this.z[f]||f)+"'":"Parse error on line "+(m+1)+": Unexpected "+(1==f?"end of input":"'"+(this.z[f]||f)+"'");this.parseError(w,{text:h.match,$:this.z[f]||
+f,T:h.f,ta:p,qa:l})}if(k[0]instanceof Array&&1<k.length)throw Error("Parse Error: multiple actions possible at state: "+n+", token: "+f);switch(k[0]){case 1:a.push(f);c.push(h.a);e.push(h.c);a.push(k[1]);f=xb;u?(f=u,u=xb):(s=h.q,d=h.a,m=h.f,p=h.c,0<v&&v--);break;case 2:l=this.W[k[1]][1];r.b=c[c.length-l];r.K={r:e[e.length-(l||1)].r,o:e[e.length-1].o,l:e[e.length-(l||1)].l,m:e[e.length-1].m};y&&(r.K.n=[e[e.length-(l||1)].n[0],e[e.length-1].n[1]]);n=this.H.apply(r,[d,s,m,q,k[1],c,e].concat(x));if("undefined"!==
+typeof n)return n;l&&(a=a.slice(0,-2*l),c=c.slice(0,-1*l),e=e.slice(0,-1*l));a.push(this.W[k[1]][0]);c.push(r.b);e.push(r.K);k=g[a[a.length-2]][a[a.length-1]];a.push(k);break;case 3:return tb}}return tb}},Qb="http://www.w3.org/1998/Math/MathML",wc="TeX LaTeX text/x-tex text/x-latex application/x-tex application/x-latex".split(" ");try{rb.C=new DOMParser}catch(yc){rb.C={parseFromString:function(){throw"DOMParser undefined. Did you call TeXZilla.setDOMParser?";}}}rb.fa=function(b){this.C=b};try{rb.G=
+new XMLSerializer}catch(zc){rb.G={serializeToString:function(){throw"XMLSerializer undefined. Did you call TeXZilla.setXMLSerializer?";}}}rb.ja=function(b){this.G=b};rb.U=function(b){return this.C.parseFromString(b,"application/xml").documentElement};rb.ia=function(b){this.e.v=b};rb.ha=function(b){this.e.da=b};rb.ca=function(b){"string"===typeof b&&(b=this.U(b));return Vb(b)};rb.Z=function(b,a,c,g){var f;try{f=this.parse("\\("+b+"\\)"),c&&(f=f.replace(/^<math/,'<math dir="rtl"')),a&&(f=f.replace(/^<math/,
+'<math display="block"'))}catch(d){if(g)throw d;f=Pb(Ib([e("merror",[e("mtext",Fb(d.message))])],a,c,b))}return f};rb.Y=function(b,a,c,e){return this.U(this.Z(b,a,c,e))};rb.na=function(b,a,c,e,f){var d,g;e===nb&&(e=64);f===nb&&(f=window.document);a=this.Y(b,tb,a);a.setAttribute("mathsize",e+"px");e=document.createElement("div");e.style.visibility="hidden";e.style.position="absolute";e.appendChild(a);f.body.appendChild(e);d=a.getBoundingClientRect();f.body.removeChild(e);e.removeChild(a);c?(c=Math.pow(2,
+Math.ceil(Math.log(d.width)/Math.LN2)),f=Math.pow(2,Math.ceil(Math.log(d.height)/Math.LN2))):(c=Math.ceil(d.width),f=Math.ceil(d.height));g=document.createElementNS("http://www.w3.org/2000/svg","svg");g.setAttribute("width",c+"px");g.setAttribute("height",f+"px");e=document.createElementNS("http://www.w3.org/2000/svg","g");e.setAttribute("transform","translate("+(c-d.width)/2+","+(f-d.height)/2+")");g.appendChild(e);e=document.createElementNS("http://www.w3.org/2000/svg","foreignObject");e.setAttribute("width",
+d.width);e.setAttribute("height",d.height);e.appendChild(a);g.firstChild.appendChild(e);a=new Image;a.src="data:image/svg+xml;base64,"+window.btoa(xc(this.G.serializeToString(g)));a.width=c;a.height=f;a.alt=Fb(b);return a};rb.Q=function(b,a){try{return this.parse(b)}catch(c){if(a)throw c;return b}};rb.P=function(b,a){var c,e,f;for(f=b.firstChild;f;f=f.nextSibling)switch(f.nodeType){case 1:this.P(f,a);break;case 3:this.e.O=tb;c=this.C.parseFromString("<root>"+zb.Q(f.data,a)+"</root>","application/xml").documentElement;
+for(this.e.O=yb;e=c.firstChild;)b.insertBefore(c.removeChild(e),f);e=f.previousSibling;b.removeChild(f);f=e}};rb.S=function(){return{J:1,parseError:function(b,a){if(this.e.V)this.e.V.parseError(b,a);else throw Error(b);},ga:function(b,a){this.e=a||this.e||{};this.g=b;this.u=this.B=this.s=yb;this.f=this.q=0;this.a=this.h=this.match="";this.d=["INITIAL"];this.c={r:1,l:0,o:1,m:0};this.options.w&&(this.c.n=[0,0]);this.offset=0;return this},input:function(){var b=this.g[0];this.a+=b;this.q++;this.offset++;
+this.match+=b;this.h+=b;b.match(/(?:\r\n?|\n).*/g)?(this.f++,this.c.o++):this.c.m++;this.options.w&&this.c.n[1]++;this.g=this.g.slice(1);return b},I:function(b){var a=b.length,c=b.split(/(?:\r\n?|\n)/g);this.g=b+this.g;this.a=this.a.substr(0,this.a.length-a);this.offset-=a;b=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1);this.h=this.h.substr(0,this.h.length-1);c.length-1&&(this.f-=c.length-1);var e=this.c.n;this.c={r:this.c.r,o:this.f+1,l:this.c.l,m:c?(c.length===
+b.length?this.c.l:0)+b[b.length-c.length].length-c[0].length:this.c.l-a};this.options.w&&(this.c.n=[e[0],e[0]+this.q-a]);this.q=this.a.length;return this},ua:function(){this.u=tb;return this},wa:function(){if(this.options.L)this.B=tb;else return this.parseError("Lexical error on line "+(this.f+1)+". You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n"+this.D(),{text:"",$:xb,T:this.f});return this},sa:function(b){this.I(this.match.slice(b))},
+ea:function(){var b=this.h.substr(0,this.h.length-this.match.length);return(20<b.length?"...":"")+b.substr(-20).replace(/\n/g,"")},oa:function(){var b=this.match;20>b.length&&(b+=this.g.substr(0,20-b.length));return(b.substr(0,20)+(20<b.length?"...":"")).replace(/\n/g,"")},D:function(){var b=this.ea(),a=Array(b.length+1).join("-");return b+this.oa()+"\n"+a+"^"},X:function(b,a){var c,e;this.options.L&&(e={f:this.f,c:{r:this.c.r,o:this.o,l:this.c.l,m:this.c.m},a:this.a,match:this.match,matches:this.matches,
+h:this.h,q:this.q,offset:this.offset,u:this.u,g:this.g,e:this.e,d:this.d.slice(0),s:this.s},this.options.w&&(e.c.n=this.c.n.slice(0)));if(c=b[0].match(/(?:\r\n?|\n).*/g))this.f+=c.length;this.c={r:this.c.o,o:this.f+1,l:this.c.m,m:c?c[c.length-1].length-c[c.length-1].match(/\r?\n?/)[0].length:this.c.m+b[0].length};this.a+=b[0];this.match+=b[0];this.matches=b;this.q=this.a.length;this.options.w&&(this.c.n=[this.offset,this.offset+=this.q]);this.B=this.u=yb;this.g=this.g.slice(b[0].length);this.h+=b[0];
+c=this.H.call(this,this.e,this,a,this.d[this.d.length-1]);this.s&&this.g&&(this.s=yb);if(c)return c;if(this.B)for(var f in e)this[f]=e[f];return yb},next:function(){if(this.s)return this.J;this.g||(this.s=tb);var b,a,c;this.u||(this.match=this.a="");for(var e=this.aa(),f=0;f<e.length;f++)if((a=this.g.match(this.rules[e[f]]))&&(!b||a[0].length>b[0].length))if(b=a,c=f,this.options.L){b=this.X(a,e[f]);if(b!==yb)return b;if(this.B)b=yb;else return yb}else if(!this.options.ra)break;return b?(b=this.X(b,
+e[c]),b!==yb?b:yb):""===this.g?this.J:this.parseError("Lexical error on line "+(this.f+1)+". Unrecognized text.\n"+this.D(),{text:"",$:xb,T:this.f})},R:function(){var b=this.next();return b?b:this.R()},j:function(b){this.d.push(b)},p:function(){return 0<this.d.length-1?this.d.pop():this.d[0]},aa:function(){return this.d.length&&this.d[this.d.length-1]?this.M[this.d[this.d.length-1]].rules:this.M.INITIAL.rules},ya:function(b){b=this.d.length-1-Math.abs(b||0);return 0<=b?this.d[b]:"INITIAL"},pushState:function(b){this.j(b)},
+xa:function(){return this.d.length},options:{},H:function(b,a,c){switch(c){case 0:this.I(a.a);this.pushState("DOCUMENT");break;case 1:return this.pushState("MATH"+(0+!!b.da)),b.ka=this.h.length,"STARTMATH"+(2*("$"==a.a[0])+("$"==a.a[1]||"["==a.a[1]));case 2:return this.p(),"EOF";case 3:return a.a=a.a[1],"TEXT";case 4:return b.O&&(a.a=Fb(a.a)),"TEXT";case 5:return"TEXT";case 6:return this.p(),"[";case 7:this.I(a.a);this.p();this.p();break;case 8:return"TEXTOPTARG";case 9:return this.p(),"]";case 10:return"{";
+case 11:return"TEXTARG";case 12:return this.p(),"}";case 13:return this.p(),"]";case 15:return this.p(),b.ba=this.h.length-this.match.length,b.t=this.h.substring(b.ka,b.ba),"ENDMATH"+(2*("$"==a.a[0])+("$"==a.a[1]||"]"==a.a[1]));case 16:return"{";case 17:return"}";case 18:return"^";case 19:return"_";case 20:return".";case 21:return"COLSEP";case 22:return"ROWSEP";case 23:return"NUM";case 24:return"A";case 25:return a.a="Ζ","AIUG";case 26:return a.a="ζ","AILG";case 27:return this.pushState("OPTARG"),
+this.pushState("TRYOPTARG"),a.a="⇌","XARROW";case 28:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇒","XARROW";case 29:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="→","XARROW";case 30:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="↦","XARROW";case 31:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇋","XARROW";case 32:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇔","XARROW";case 33:return this.pushState("OPTARG"),
+this.pushState("TRYOPTARG"),a.a="↔","XARROW";case 34:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="⇐","XARROW";case 35:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="←","XARROW";case 36:return a.a="Ξ","AIUG";case 37:return a.a="ξ","AILG";case 38:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="↪","XARROW";case 39:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),a.a="↩","XARROW";case 40:return a.a="≀","OP";case 41:return a.a="℘","A";case 42:return a.a=
+"⇀","ACCENT";case 43:return a.a="˜","ACCENT";case 44:return a.a="^","ACCENT";case 45:return a.a="ˇ","ACCENT";case 46:return a.a="¯","ACCENT";case 47:return a.a="≙","OP";case 48:return a.a="⋀","OPM";case 49:return a.a="∧","OP";case 50:return a.a="⦀","OPFS";case 51:return a.a="⊪","OP";case 52:return a.a="‖","OPFS";case 53:return a.a="|","OPFS";case 54:return a.a="⊻","OP";case 55:return a.a="⋁","OPM";case 56:return a.a="∨","OP";case 57:return a.a="⇀","ACCENTNS";case 58:return a.a="⋮","OP";case 59:return a.a=
+"⊫","OP";case 60:return a.a="⊩","OP";case 61:return a.a="⊨","OP";case 62:return a.a="⊢","OP";case 63:return a.a="⫫","OP";case 64:return a.a="⊳","OP";case 65:return a.a="⊲","OP";case 66:return a.a="▵","OP";case 67:return a.a="ϑ","AILG";case 68:return a.a="⫌︀","OP";case 69:return a.a="⊋︀","OP";case 70:return a.a="⫋︀","OP";case 71:return a.a="⊊︀","OP";case 72:return a.a="⊊︀","OP";case 73:return a.a="ς","A";case 74:return a.a="ϱ","AILG";case 75:return a.a="∝","OP";case 76:return a.a="ϖ","AILG";case 77:return a.a=
+"φ","AILG";case 78:return a.a="∅","A";case 79:return a.a="ϰ","AILG";case 80:return a.a="ε","AILG";case 81:return a.a="⤊","OPS";case 82:return a.a="⇈","OPS";case 83:return a.a="ϒ","A";case 84:return a.a="υ","AILG";case 85:return a.a="ϒ","A";case 86:return a.a="⊎","OP";case 87:return a.a="⨛","OP";case 88:return a.a="↿","OPS";case 89:return a.a="↾","OPS";case 90:return a.a="⇕","OPS";case 91:return a.a="↕","OPS";case 92:return a.a="↕","OPS";case 93:return a.a="⇑","OPS";case 94:return a.a="↑","OPS";case 95:return a.a=
+"↑","OPS";case 96:return a.a="⊵","OP";case 97:return a.a="⊴","OP";case 98:return a.a="⋃","OPM";case 99:return a.a="∪","OP";case 100:return"UNDERSET";case 101:return"UNDEROVERSET";case 102:return"UNDERLINE";case 103:return"UNDERBRACE";case 104:return a.a="⋰","OP";case 105:return"OP";case 106:return"OP";case 107:return"OP";case 108:return"OP";case 109:return"OP";case 110:return"OP";case 111:return"OP";case 112:return"OP";case 113:return"OP";case 114:return"OP";case 115:return"OP";case 116:return"OP";
+case 117:return"OP";case 118:return"OP";case 119:return"OP";case 120:return"OP";case 121:return"OP";case 122:return"OP";case 123:return"OP";case 124:return"OP";case 125:return"OP";case 126:return"OP";case 127:return"OP";case 128:return"OP";case 129:return"OP";case 130:return"OP";case 131:return"OP";case 132:return"OP";case 133:return"OP";case 134:return"OP";case 135:return"OP";case 136:return"OP";case 137:return"OP";case 138:return"OPFS";case 139:return"OPFS";case 140:return"OP";case 141:return"OP";
+case 142:return"OP";case 143:return"OP";case 144:return"OP";case 145:return"OP";case 146:return"OP";case 147:return"OP";case 148:return"OP";case 149:return"OP";case 150:return"OP";case 151:return"OP";case 152:return"OP";case 153:return"OP";case 154:return"OP";case 155:return"OP";case 156:return"OP";case 157:return"OP";case 158:return"OP";case 159:return"OP";case 160:return"OP";case 161:return a.a="⤖","OP";case 162:return a.a="↠","OPS";case 163:return a.a="↞","OPS";case 164:return a.a="∭","OP";case 165:return a.a=
+"⊵","OP";case 166:return a.a="▹","OP";case 167:return a.a="≜","OP";case 168:return a.a="⊴","OP";case 169:return a.a="◃","OP";case 170:return a.a="▿","OP";case 171:return a.a="▵","OP";case 172:return a.a="⤪","OP";case 173:return a.a="⤩","OP";case 174:return a.a="⊤","OP";case 175:return this.pushState("TEXTARG"),"TOOLTIP";case 176:return a.a="⤧","OP";case 177:return"TOGGLE";case 178:return a.a="⤨","OP";case 179:return a.a="→","OPS";case 180:return a.a="⊠","OP";case 181:return a.a="×","OP";case 182:return a.a=
+"˜","ACCENTNS";case 183:return"THINSPACE";case 184:return"THICKSPACE";case 185:return a.a="∼","OP";case 186:return a.a="≈","OP";case 187:return a.a="Θ","AIUG";case 188:return a.a="θ","AILG";case 189:return a.a="∴","OP";case 190:return"TFRAC";case 191:return"TEXTSTYLE";case 192:return"TEXTSIZE";case 193:return a.a="”","OPF";case 194:return a.a="“","OPF";case 195:return a.a="~","OPS";case 196:return a.a="`","OP";case 197:return a.a="^","OPS";case 198:return a.a="´","OP";case 199:return this.j("TEXTARG"),
+"MTEXT";case 200:return"TENSOR";case 201:return"TBINOM";case 202:return a.a="Τ","AIUG";case 203:return a.a="τ","AILG";case 204:return a.a="⇙","OPS";case 205:return a.a="↙","OPS";case 206:return a.a="⇙","OPS";case 207:return a.a="↙","OPS";case 208:return a.a="√","OPS";case 209:return a.a="⫌","OP";case 210:return a.a="⊋","OP";case 211:return a.a="⫆","OP";case 212:return a.a="⊇","OP";case 213:return a.a="⋑","OP";case 214:return a.a="⊃","OP";case 215:return a.a="∑","OPM";case 216:return a.a="≿","OP";
+case 217:return a.a="⋩","OP";case 218:return a.a="⪶","OP";case 219:return a.a="⪺","OP";case 220:return a.a="⪰","OP";case 221:return a.a="≽","OP";case 222:return a.a="⪸","OP";case 223:return a.a="≻","OP";case 224:return"SUBSTACK";case 225:return a.a="⫋","OP";case 226:return a.a="⊊","OP";case 227:return a.a="⫅","OP";case 228:return a.a="⊆","OP";case 229:return a.a="⋐","OP";case 230:return a.a="⊂","OP";case 231:return this.pushState("TEXTARG"),"STATUSLINE";case 232:return a.a="⋆","OP";case 233:return"OVERSET";
+case 234:return a.a="⫽","OP";case 235:return a.a="□","OP";case 236:return a.a="⊒","OP";case 237:return a.a="⊐","OP";case 238:return a.a="⊑","OP";case 239:return a.a="⊏","OP";case 240:return this.pushState("OPTARG"),this.pushState("TRYOPTARG"),"SQRT";case 241:return a.a="⊔","OP";case 242:return a.a="⊓","OP";case 243:return a.a="∢","OP";case 244:return a.a="♠","OP";case 245:return this.pushState("TEXTARG"),this.pushState("TEXTARG"),this.pushState("TEXTARG"),"SPACE";case 246:return a.a="⌣","OP";case 247:return a.a=
+"⌣","OP";case 248:return a.a="∖","OP";case 249:return a.a="⌢","OP";case 250:return"SLASH";case 251:return a.a="≃","OP";case 252:return a.a="∼","OP";case 253:return a.a="Σ","AIUG";case 254:return a.a="σ","AILG";case 255:return a.a="⧢","OP";case 256:return a.a="∥","OP";case 257:return a.a="∣","OP";case 258:return a.a="♯","OP";case 259:return a.a="∖","OP";case 260:return a.a="⤭","OP";case 261:return a.a="⇘","OPS";case 262:return a.a="↘","OPS";case 263:return a.a="⇘","OPS";case 264:return a.a="↘","OPS";
+case 265:return"SCRIPTSIZE";case 266:return"SCRIPTSCRIPTSIZE";case 267:return a.a="⋊","OP";case 268:return a.a="↱","OPS";case 269:return a.a="⇛","OPS";case 270:return a.a="⟫","OPFS";case 271:return a.a="’","OPF";case 272:return this.j("TEXTARG"),"ROWSPAN";case 273:return"ROWOPTS";case 274:return this.pushState("TEXTARG"),"ROWLINES";case 275:return this.j("TEXTARG"),"ROWALIGN";case 276:return"ROOT";case 277:return a.a="⎱","OP";case 278:return a.a="≓","OP";case 279:return a.a="⟲","OP";case 280:return a.a=
+"⋌","OP";case 281:return a.a="↝","OPS";case 282:return a.a="⇉","OPS";case 283:return a.a="⇌","OPS";case 284:return a.a="⇄","OPS";case 285:return a.a="⇀","OPS";case 286:return a.a="⇁","OPS";case 287:return a.a="⇾","OPS";case 288:return a.a="↣","OPS";case 289:return a.a="⇒","OPS";case 290:return a.a="→","OPS";case 291:return"RIGHT";case 292:return a.a="Ρ","AIUG";case 293:return a.a="ρ","AILG";case 294:return a.a="⊳","OP";case 295:return a.a="⌋","OPFS";case 296:return a.a="ℜ","A";case 297:return a.a=
+"⤰","OP";case 298:return a.a="⤫","OP";case 299:return a.a="⌉","OPFS";case 300:return a.a="]","OPFS";case 301:return a.a="}","OPFS";case 302:return a.a="⟩","OPFS";case 303:return a.a="⟩","OPFS";case 304:return a.a="≟","OP";case 305:return a.a="⨌","OP";case 306:return"QUAD";case 307:return"QQUAD";case 308:return a.a="▪","OP";case 309:return a.a="Ψ","AIUG";case 310:return a.a="ψ","AILG";case 311:return a.a="∝","OP";case 312:return a.a="∏","OPM";case 313:return a.a="∏","OPM";case 314:return a.a="′","OPP";
+case 315:return a.a="≾","OP";case 316:return a.a="⋨","OP";case 317:return a.a="⪵","OP";case 318:return a.a="⪹","OP";case 319:return a.a="⪯","OP";case 320:return a.a="≼","OP";case 321:return a.a="⪷","OP";case 322:return a.a="≺","OP";case 323:return"PMOD";case 324:return a.a="±","OP";case 325:return a.a="⨥","OP";case 326:return a.a="⊞","OP";case 327:return a.a="⋔","OP";case 328:return a.a="Π","AIUG";case 329:return a.a="π","AILG";case 330:return a.a="Φ","AIUG";case 331:return a.a="ϕ","AILG";case 332:return"PHANTOM";
+case 333:return a.a="⫫","OP";case 334:return a.a="⊥","OP";case 335:return a.a="⪣","OP";case 336:return a.a="∂","OP";case 337:return a.a="⅋","OP";case 338:return a.a="∥","OP";case 339:return this.pushState("TEXTARG"),"PADDING";case 340:return"OVERSET";case 341:return a.a="¯","ACCENT";case 342:return"OVERBRACE";case 343:return"TEXOVER";case 344:return a.a="⨴","OP";case 345:return a.a="⊗","OP";case 346:return a.a="⊘","OP";case 347:return"OPS";case 348:return"OPP";case 349:return"OPM";case 350:return a.a=
+"⨭","OP";case 351:return a.a="⊕","OP";case 352:return"OPFS";case 353:return"OPF";case 354:return this.j("TEXTARG"),"OPERATORNAME";case 355:return"OP";case 356:return a.a="⊖","OP";case 357:return a.a="ℴ","A";case 358:return a.a="Ω","AIUG";case 359:return a.a="ω","AILG";case 360:return a.a="∮","OP";case 361:return a.a="∯","OP";case 362:return a.a="∰","OP";case 363:return a.a="⊙","OP";case 364:return a.a="⊝","OP";case 365:return a.a="⦸","OP";case 366:return a.a="⤲","OP";case 367:return a.a="⇖","OPS";
+case 368:return a.a="↖","OPS";case 369:return a.a="⇖","OPS";case 370:return a.a="↖","OPS";case 371:return a.a="⊯","OP";case 372:return a.a="⊮","OP";case 373:return a.a="⊭","OP";case 374:return a.a="⊬","OP";case 375:return"NUM";case 376:return a.a="Ν","AIUG";case 377:return a.a="ν","AILG";case 378:return a.a="⋭","OP";case 379:return a.a="⋫","OP";case 380:return a.a="⋬","OP";case 381:return a.a="⋪","OP";case 382:return a.a="⊉","OP";case 383:return a.a="⊅","OP";case 384:return a.a="≿̸","OP";case 385:return a.a=
+"⪰̸","OP";case 386:return a.a="⊁","OP";case 387:return a.a="⊈","OP";case 388:return a.a="⊈","OP";case 389:return a.a="⊄","OP";case 390:return a.a="≄","OP";case 391:return a.a="≁","OP";case 392:return a.a="∦","OP";case 393:return a.a="∤","OP";case 394:return a.a="⇏","OP";case 395:return a.a="↛","OP";case 396:return a.a="⪯̸","OP";case 397:return a.a="⊀","OP";case 398:return a.a="∦","OP";case 399:return a.a="∌","OP";case 400:return a.a="∉","OP";case 401:return a.a="¬","OP";case 402:return a.a="∤","OP";
+case 403:return a.a="≮","OP";case 404:return a.a="⩽̸","OP";case 405:return a.a="⩽̸","OP";case 406:return a.a="≰","OP";case 407:return a.a="⇎","OP";case 408:return a.a="↮","OP";case 409:return a.a="⇍","OP";case 410:return a.a="↚","OP";case 411:return a.a="∋","OP";case 412:return a.a="≯","OP";case 413:return a.a="⩾̸","OP";case 414:return a.a="⩾̸","OP";case 415:return a.a="≱","OP";case 416:return a.a="∄","OP";case 417:return a.a="≢","OP";case 418:return a.a="≂̸","OP";case 419:return a.a="≠","OP";case 420:return a.a=
+"⤮","OP";case 421:return a.a="⤱","OP";case 422:return"NEGTHICKSPACE";case 423:return"NEGSPACE";case 424:return"NEGMEDSPACE";case 425:return a.a="¬","OP";case 426:return a.a="⇗","OPS";case 427:return a.a="↗","OPS";case 428:return a.a="⇗","OPS";case 429:return a.a="↗","OPS";case 430:return a.a="≠","OP";case 431:return a.a="≇","OP";case 432:return a.a="≎̸","OP";case 433:return a.a="≏̸","OP";case 434:return a.a="♮","OP";case 435:return a.a="≉","OP";case 436:return a.a="∇","OP";case 437:return"MULTI";
+case 438:return a.a="⊸","OP";case 439:return a.a="Μ","AIUG";case 440:return a.a="μ","AILG";case 441:return this.j("TEXTARG"),"MTEXT";case 442:return this.pushState("TEXTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),"MS";case 443:return a.a="∓","OP";case 444:return a.a="⊧","OP";case 445:return a.a="mod","MO";case 446:return this.pushState("TEXTARG"),"MO";case 447:return this.pushState("TEXTARG"),"MN";case 448:return a.a="⫛",
+"OP";case 449:return a.a="⨪","OP";case 450:return a.a="⊟","OP";case 451:return a.a="−","OP";case 452:return a.a=a.a.slice(1),"FM";case 453:return a.a="∣","OP";case 454:return this.pushState("TEXTARG"),"MI";case 455:return a.a="℧","A";case 456:return a.a="℧","A";case 457:return"MEDSPACE";case 458:return a.a="∡","OP";case 459:return"MATHTT";case 460:return"MATHSF";case 461:return"MATHSCR";case 462:return"MATHRM";case 463:return"MATHRLAP";case 464:return this.j("TEXTARG"),"MATHREL";case 465:return this.pushState("TEXTOPTARG"),
+this.pushState("TRYOPTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),this.pushState("TEXTARG"),"MATHRAISEBOX";case 466:return this.j("TEXTARG"),"MATHOP";case 467:return"MATHIT";case 468:return"MATHLLAP";case 469:return"MATHIT";case 470:return"MATHFRAK";case 471:return"MATHFRAK";case 472:return"MATHCLAP";case 473:return"MATHSCR";case 474:return"MATHBSCR";case 475:return"MATHBIT";case 476:return this.j("TEXTARG"),"MATHBIN";case 477:return"MATHBF";case 478:return"MATHBSCR";case 479:return"MATHBB";
+case 480:return a.a="⤇","OP";case 481:return a.a="↦","OPS";case 482:return a.a="⤆","OP";case 483:return a.a="↦","OPS";case 484:return a.a="≨︀","OP";case 485:return a.a="≨︀","OP";case 486:return a.a="⋉","OP";case 487:return a.a="<","OP";case 488:return a.a="↰","OPS";case 489:return a.a="‘","OPF";case 490:return a.a="◊","OP";case 491:return a.a="⨜","OP";case 492:return a.a="↬","OPS";case 493:return a.a="↫","OPS";case 494:return a.a="⟹","OPS";case 495:return a.a="⟶","OPS";case 496:return a.a="⟼","OPS";
+case 497:return a.a="⟺","OPS";case 498:return a.a="⟷","OPS";case 499:return a.a="⟸","OPS";case 500:return a.a="⟵","OPS";case 501:return a.a="⋦","OP";case 502:return a.a="≨","OP";case 503:return a.a="⪇","OP";case 504:return a.a="⪉","OP";case 505:return a.a="⎰","OP";case 506:return a.a="⋘","OP";case 507:return a.a="⇚","OPS";case 508:return a.a="⟪","OPFS";case 509:return a.a="≪","OP";case 510:return a.a="⊲","OP";case 511:return a.a="⌊","OPFS";case 512:return a.a="≲","OP";case 513:return a.a="≶","OP";
+case 514:return a.a="⪋","OP";case 515:return a.a="⋚","OP";case 516:return a.a="⋖","OP";case 517:return a.a="⪅","OP";case 518:return a.a="<","OP";case 519:return a.a="⩽","OP";case 520:return a.a="≦","OP";case 521:return a.a="≤","OP";case 522:return a.a="⟳","OP";case 523:return a.a="⋋","OP";case 524:return a.a="↜","OPS";case 525:return a.a="↭","OPS";case 526:return a.a="⇋","OPS";case 527:return a.a="⇿","OPS";case 528:return a.a="⇆","OPS";case 529:return a.a="⇔","OPS";case 530:return a.a="↔","OPS";case 531:return a.a=
+"⇇","OPS";case 532:return a.a="↼","OPS";case 533:return a.a="↽","OPS";case 534:return a.a="⇽","OPS";case 535:return a.a="↢","OPS";case 536:return a.a="⇐","OPS";case 537:return a.a="←","OPS";case 538:return"LEFT";case 539:return a.a="≤","OP";case 540:return a.a="…","OP";case 541:return a.a="⌈","OPFS";case 542:return a.a="[","OPFS";case 543:return a.a="{","OPFS";case 544:return a.a="⟨","OPFS";case 545:return a.a="⟨","OPFS";case 546:return a.a="Λ","AIUG";case 547:return a.a="λ","AILG";case 548:return a.a=
+"∻","OP";case 549:return a.a="Κ","AIUG";case 550:return a.a="κ","AILG";case 551:return a.a="ȷ","AILL";case 552:return this.pushState("TEXTARG"),"MN";case 553:return a.a="Ι","AIUG";case 554:return a.a="ι","AILG";case 555:return a.a="⅋","OP";case 556:return a.a="⨘","OP";case 557:return a.a="⨽","OP";case 558:return a.a="⨼","OP";case 559:return a.a="⋂","OPM";case 560:return a.a="∩","OP";case 561:return a.a="⫴","OP";case 562:return a.a="⊺","OP";case 563:return a.a="∫","OP";case 564:return a.a="⨚","OP";
+case 565:return a.a="⨙","OP";case 566:return a.a="⨎","OP";case 567:return a.a="⨍","OP";case 568:return a.a="∫","OP";case 569:return a.a="∞","NUM";case 570:return a.a="∞","NUM";case 571:return a.a=a.a.slice(1),"FM";case 572:return a.a="∊","OP";case 573:return a.a="⇒","OPS";case 574:return a.a="⇐","OPS";case 575:return a.a="ı","AILL";case 576:return a.a="ℑ","A";case 577:return a.a="∬","OP";case 578:return a.a="∭","OP";case 579:return a.a="⨌","OP";case 580:return a.a="⟺","OPS";case 581:return a.a="ℏ",
+"A";case 582:return this.pushState("TEXTARG"),"HREF";case 583:return a.a="↪","OPS";case 584:return a.a="↩","OPS";case 585:return a.a="⤦","OP";case 586:return a.a="⤥","OP";case 587:return a.a="♡","OP";case 588:return a.a="ℏ","A";case 589:return a.a="^","ACCENTNS";case 590:return a.a="≩︀","OP";case 591:return a.a="≩︀","OP";case 592:return a.a="≳","OP";case 593:return a.a="≷","OP";case 594:return a.a="⪌","OP";case 595:return a.a="⋛","OP";case 596:return a.a="⋗","OP";case 597:return a.a="⪆","OP";case 598:return a.a=
+">","OP";case 599:return a.a=">","OP";case 600:return a.a="⋧","OP";case 601:return a.a="≩","OP";case 602:return a.a="⪈","OP";case 603:return a.a="⪊","OP";case 604:return a.a="ℷ","A";case 605:return a.a="⋙","OP";case 606:return a.a="≫","OP";case 607:return a.a="⩾","OP";case 608:return a.a="≧","OP";case 609:return a.a="≥","OP";case 610:return a.a="≥","OP";case 611:return a.a="Γ","AIUG";case 612:return a.a="γ","AILG";case 613:return a.a="⌢","OP";case 614:return this.pushState("TEXTARG"),"FRAME";case 615:return"FRAC";
+case 616:return a.a="⫝","OP";case 617:return a.a="⫝̸","OP";case 618:return a.a="∀","OP";case 619:return a.a="♭","OP";case 620:return a.a="⤬","OP";case 621:return a.a="⤯","OP";case 622:return a.a="≒","OP";case 623:return a.a="∃","OP";case 624:return a.a="ð","A";case 625:return a.a="ð","A";case 626:return a.a="Η","AIUG";case 627:return a.a="η","AILG";case 628:return a.a="≡","OP";case 629:return this.pushState("TEXTARG"),"EQROWS";case 630:return this.pushState("TEXTARG"),"EQCOLS";case 631:return a.a=
+"⪕","OP";case 632:return a.a="⪖","OP";case 633:return a.a="≂","OP";case 634:return a.a="=∷","OP";case 635:return a.a="≕","OP";case 636:return a.a="−∷","OP";case 637:return a.a="=∷","OP";case 638:return a.a="=∷","OP";case 639:return a.a="=∷","OP";case 640:return a.a="≕","OP";case 641:return a.a="≖","OP";case 642:return a.a="ϵ","AILG";case 643:return"EVVMATRIX";case 644:return"EVMATRIX";case 645:return"ETOGGLE";case 646:return"EALIGNED";case 647:return"ESMALLMATRIX";case 648:return"EPMATRIX";case 649:return"EMATRIX";
+case 650:return"EGATHERED";case 651:return"ECASES";case 652:return"EBBMATRIX";case 653:return"EBMATRIX";case 654:return"EARRAY";case 655:return"EALIGNED";case 656:return a.a="∅","A";case 657:return a.a="∅","A";case 658:return a.a="↪","OPS";case 659:return a.a="ℓ","A";case 660:return a.a="↕","OPS";case 661:return a.a="⧟","OP";case 662:return a.a="⤐","OPS";case 663:return a.a="↕","OPS";case 664:return a.a="⇂","OPS";case 665:return a.a="⇃","OPS";case 666:return a.a="⇊","OPS";case 667:return a.a="⇓",
+"OPS";case 668:return a.a="↓","OPS";case 669:return a.a="∬","OP";case 670:return a.a="⩞","OP";case 671:return a.a="⌆","OP";case 672:return a.a="…","OP";case 673:return a.a="∔","OP";case 674:return a.a="∸","OP";case 675:return a.a="≑","OP";case 676:return a.a="≑","OP";case 677:return a.a="≐","OP";case 678:return a.a="˙","ACCENT";case 679:return a.a="⋇","OP";case 680:return a.a="÷","OP";case 681:return"DISPLAYSTYLE";case 682:return a.a="⨈","OPM";case 683:return a.a="ϝ","A";case 684:return a.a="♢","OP";
+case 685:return a.a="⋄","OP";case 686:return a.a="⋄","OP";case 687:return a.a=a.a.slice(1),"FM";case 688:return a.a="Δ","AIUG";case 689:return a.a="δ","AILG";case 690:return a.a="∇","OP";case 691:return a.a="°","OP";case 692:return a.a="⤋","OPS";case 693:return a.a="⩷","OP";case 694:return a.a="⋱","OP";case 695:return a.a="̈","ACCENT";case 696:return a.a="⃛","OP";case 697:return a.a="⃛","ACCENT";case 698:return a.a="⃜","OP";case 699:return a.a="⃜","ACCENT";case 700:return a.a="‡","OP";case 701:return a.a=
+"∷","OP";case 702:return a.a="⤏","OPS";case 703:return a.a="⫤","OP";case 704:return a.a="⫣","OP";case 705:return a.a="⊣","OP";case 706:return a.a="⤏","OPS";case 707:return a.a="⤎","OPS";case 708:return a.a="↓","OPS";case 709:return a.a="ℸ","A";case 710:return a.a="†","OP";case 711:return a.a="↷","OP";case 712:return a.a="↶","OP";case 713:return a.a="⤻","OP";case 714:return a.a="⋏","OP";case 715:return a.a="⋎","OP";case 716:return a.a="⋟","OP";case 717:return a.a="⋞","OP";case 718:return a.a="⊍","OP";
+case 719:return a.a="⋓","OP";case 720:return a.a="∪","OP";case 721:return a.a="∐","OPM";case 722:return a.a="∐","OPM";case 723:return a.a="∮","OP";case 724:return a.a="⨇","OPM";case 725:return a.a="∮","OP";case 726:return a.a="≅","OP";case 727:return a.a="∁","OP";case 728:return this.j("TEXTARG"),"COLSPAN";case 729:return this.pushState("TEXTARG"),"COLOR";case 730:return a.a="∷∼","OP";case 731:return a.a="∶∼","OP";case 732:return a.a="⩴","OP";case 733:return a.a="≔","OP";case 734:return a.a="∷−",
+"OP";case 735:return a.a="≔","OP";case 736:return a.a="∷≈","OP";case 737:return a.a="∶≈","OP";case 738:return a.a="∷","OP";case 739:return a.a=":","OP";case 740:return this.pushState("TEXTARG"),"COLLINES";case 741:return this.pushState("TEXTARG"),"COLLAYOUT";case 742:return this.j("TEXTARG"),"COLALIGN";case 743:return a.a="♣","OP";case 744:return a.a="¯","ACCENT";case 745:return a.a="⊝","OP";case 746:return a.a="⊚","OP";case 747:return a.a="⊛","OP";case 748:return a.a="⥁","OP";case 749:return a.a=
+"⥀","OP";case 750:return a.a="≗","OP";case 751:return a.a="∘","OP";case 752:return"TEXCHOOSE";case 753:return a.a="χ","AILG";case 754:return a.a="ˇ","ACCENTNS";case 755:return"CELLOPTS";case 756:return a.a="⋯","OP";case 757:return a.a="·","OP";case 758:return a.a="⋅","OP";case 759:return a.a="⋒","OP";case 760:return a.a="∩","OP";case 761:return a.a="⪮","OP";case 762:return a.a="≎","OP";case 763:return a.a="≏","OP";case 764:return a.a="•","OP";case 765:return a.a="⨲","OP";case 766:return a.a="⊠","OP";
+case 767:return a.a="⊞","OP";case 768:return a.a="⊟","OP";case 769:return"BOXED";case 770:return a.a="⊡","OP";case 771:return a.a="⧄","OP";case 772:return a.a="⧇","OP";case 773:return a.a="⧅","OP";case 774:return a.a="⧆","OP";case 775:return a.a="□","OP";case 776:return a.a="⋈","OP";case 777:return a.a="⊥","OP";case 778:return a.a="⊥","OP";case 779:return"MATHBF";case 780:return a.a="▸","OP";case 781:return a.a="◂","OP";case 782:return a.a="▾","OP";case 783:return a.a="▴","OP";case 784:return a.a=
+"■","OP";case 785:return a.a="⧫","OP";case 786:return a.a="⤍","OPS";case 787:return"BINOM";case 788:return a.a="⋀","OPM";case 789:return a.a="⋁","OPM";case 790:return a.a="⨄","OPM";case 791:return a.a="△","OP";case 792:return a.a="▽","OP";case 793:return a.a="⨉","OPM";case 794:return a.a="★","OP";case 795:return a.a="⨆","OPM";case 796:return a.a="⨅","OPM";case 797:return"BBIG";case 798:return"BIG";case 799:return a.a="⨂","OPM";case 800:return a.a="⨁","OPM";case 801:return a.a="⨀","OPM";case 802:return"BBIGL";
+case 803:return"BIGL";case 804:return a.a="⫼","OPM";case 805:return"BBIGG";case 806:return"BIGG";case 807:return"BBIGGL";case 808:return"BIGGL";case 809:return"BBIGG";case 810:return"BIGG";case 811:return a.a="⨃","OPM";case 812:return a.a="⋃","OPM";case 813:return a.a="○","OP";case 814:return a.a="⋂","OPM";case 815:return"BBIG";case 816:return"BIG";case 817:return this.pushState("TEXTARG"),"BGCOLOR";case 818:return a.a="≬","OP";case 819:return a.a="ℶ","A";case 820:return a.a="Β","AIUG";case 821:return a.a=
+"β","AILG";case 822:return"BVVMATRIX";case 823:return"BVMATRIX";case 824:return"BTOGGLE";case 825:return"BALIGNED";case 826:return"BSMALLMATRIX";case 827:return"BPMATRIX";case 828:return"BMATRIX";case 829:return"BGATHERED";case 830:return"BCASES";case 831:return"BBBMATRIX";case 832:return"BBMATRIX";case 833:return this.pushState("TEXTARG"),this.pushState("TEXTOPTARG"),this.pushState("TRYOPTARG"),"BARRAY";case 834:return"BALIGNED";case 835:return a.a="∵","OP";case 836:return a.a="ℿ","A";case 837:return a.a=
+"⌅","OP";case 838:return a.a="¯","ACCENTNS";case 839:return a.a="\\","OP";case 840:return a.a="⋍","OP";case 841:return a.a="∽","OP";case 842:return a.a="‵","OPP";case 843:return a.a="϶","OP";case 844:return"TEXATOP";case 845:return a.a="≍","OP";case 846:return a.a="∗","OP";case 847:return"ARRAYOPTS";case 848:return"ARRAY";case 849:return a.a=a.a.slice(1),"F";case 850:return a.a="≊","OP";case 851:return a.a="≈","OP";case 852:return a.a="∠","OP";case 853:return a.a="⨿","OP";case 854:return a.a="Α",
+"AIUG";case 855:return a.a="α","AILG";case 856:return this.pushState("TEXTARG"),"ALIGN";case 857:return a.a="ℵ","A";case 858:return"AIUL";case 859:return"AIUG";case 860:return"AILL";case 861:return"AILG";case 862:return a.a="⋰","OP";case 863:return a.a="Å","A";case 864:return"A";case 865:return a.a="$","A";case 866:return a.a="}","OPFS";case 867:return a.a="‖","OPFS";case 868:return a.a="{","OPFS";case 869:return"THICKSPACE";case 870:return"MEDSPACE";case 871:return"THINSPACE";case 872:return a.a=
+"&","A";case 873:return a.a="%","A";case 874:return a.a="#","OP";case 875:return"NEGSPACE";case 876:return a.a="−","OP";case 877:return a.a="⁗","OPP";case 878:return a.a="‴","OPP";case 879:return a.a="″","OPP";case 880:return a.a="′","OPP";case 881:return"HIGH_SURROGATE";case 882:return"LOW_SURROGATE";case 883:return"BMP_CHARACTER"}},rules:[/^(?:.)/,/^(?:\$\$|\\\[|\$|\\\()/,/^(?:$)/,/^(?:\\[$\\])/,/^(?:[<&>])/,/^(?:[^])/,/^(?:\s*\[)/,/^(?:.)/,/^(?:([^\\\]]|(\\[\\\]]))+)/,/^(?:\])/,/^(?:\s*\{)/,/^(?:([^\\\}]|(\\[\\\}]))+)/,
+/^(?:\})/,/^(?:\])/,/^(?:\s+)/,/^(?:\$\$|\\\]|\$|\\\))/,/^(?:\{)/,/^(?:\})/,/^(?:\^)/,/^(?:_)/,/^(?:\.)/,/^(?:&)/,/^(?:\\\\)/,/^(?:[0-9]+(?:\.[0-9]+)?|[\u0660-\u0669]+(?:\u066B[\u0660-\u0669]+)?|(?:\uD835[\uDFCE-\uDFD7])+|(?:\uD835[\uDFD8-\uDFE1])+|(?:\uD835[\uDFE2-\uDFEB])+|(?:\uD835[\uDFEC-\uDFF5])+|(?:\uD835[\uDFF6-\uDFFF])+)/,/^(?:[a-zA-Z]+)/,/^(?:\\Zeta)/,/^(?:\\zeta)/,/^(?:\\xrightleftharpoons)/,/^(?:\\xRightarrow)/,/^(?:\\xrightarrow)/,/^(?:\\xmapsto)/,/^(?:\\xleftrightharpoons)/,/^(?:\\xLeftrightarrow)/,
+/^(?:\\xleftrightarrow)/,/^(?:\\xLeftarrow)/,/^(?:\\xleftarrow)/,/^(?:\\Xi)/,/^(?:\\xi)/,/^(?:\\xhookrightarrow)/,/^(?:\\xhookleftarrow)/,/^(?:\\wr)/,/^(?:\\wp)/,/^(?:\\widevec)/,/^(?:\\widetilde)/,/^(?:\\widehat)/,/^(?:\\widecheck)/,/^(?:\\widebar)/,/^(?:\\wedgeq)/,/^(?:\\Wedge)/,/^(?:\\wedge)/,/^(?:\\Vvert)/,/^(?:\\Vvdash)/,/^(?:\\Vert)/,/^(?:\\vert)/,/^(?:\\veebar)/,/^(?:\\Vee)/,/^(?:\\vee)/,/^(?:\\vec)/,/^(?:\\vdots)/,/^(?:\\VDash)/,/^(?:\\Vdash)/,/^(?:\\vDash)/,/^(?:\\vdash)/,/^(?:\\Vbar)/,/^(?:\\vartriangleright)/,
+/^(?:\\vartriangleleft)/,/^(?:\\vartriangle)/,/^(?:\\vartheta)/,/^(?:\\varsupsetneqq)/,/^(?:\\varsupsetneq)/,/^(?:\\varsubsetneqq)/,/^(?:\\varsubsetneqq)/,/^(?:\\varsubsetneq)/,/^(?:\\varsigma)/,/^(?:\\varrho)/,/^(?:\\varpropto)/,/^(?:\\varpi)/,/^(?:\\varphi)/,/^(?:\\varnothing)/,/^(?:\\varkappa)/,/^(?:\\varepsilon)/,/^(?:\\Uuparrow)/,/^(?:\\upuparrows)/,/^(?:\\Upsilon)/,/^(?:\\upsilon)/,/^(?:\\Upsi)/,/^(?:\\uplus)/,/^(?:\\upint)/,/^(?:\\upharpoonright)/,/^(?:\\upharpoonleft)/,/^(?:\\Updownarrow)/,
+/^(?:\\updownarrow)/,/^(?:\\updarr)/,/^(?:\\Uparrow)/,/^(?:\\uparrow)/,/^(?:\\uparr)/,/^(?:\\unrhd)/,/^(?:\\unlhd)/,/^(?:\\Union)/,/^(?:\\union)/,/^(?:\\underset)/,/^(?:\\underoverset)/,/^(?:\\underline)/,/^(?:\\underbrace)/,/^(?:\\udots)/,/^(?:\u2ADD\u0338)/,/^(?:\u2ACC\uFE00)/,/^(?:\u2ACB\uFE00)/,/^(?:\u2AB0\u0338)/,/^(?:\u2AAF\u0338)/,/^(?:\u2AA2\u0338)/,/^(?:\u2AA1\u0338)/,/^(?:\u2A7E\u0338)/,/^(?:\u2A7D\u0338)/,/^(?:\u29D0\u0338)/,/^(?:\u29CF\u0338)/,/^(?:\u2290\u0338)/,/^(?:\u228F\u0338)/,/^(?:\u228B\uFE00)/,
+/^(?:\u228A\uFE00)/,/^(?:\u2283\u20D2)/,/^(?:\u2282\u20D2)/,/^(?:\u227F\u0338)/,/^(?:\u226B\u0338)/,/^(?:\u226A\u0338)/,/^(?:\u2269\uFE00)/,/^(?:\u2268\uFE00)/,/^(?:\u2266\u0338)/,/^(?:\u224F\u0338)/,/^(?:\u224E\u0338)/,/^(?:\u2242\u0338)/,/^(?:\u223D\u0331)/,/^(?:\u2237\u2248)/,/^(?:\u2237\u223C)/,/^(?:\u2237\u2212)/,/^(?:\u2236\u2248)/,/^(?:\u2236\u223C)/,/^(?:\u2212\u2237)/,/^(?:\u007C\u007C\u007C)/,/^(?:\u007C\u007C)/,/^(?:\u003E\u003D)/,/^(?:\u003D\u2237)/,/^(?:\u003D\u2237)/,/^(?:\u003D\u003D)/,
+/^(?:\u003C\u003E)/,/^(?:\u003C\u003D)/,/^(?:\u003A\u003D)/,/^(?:\u002F\u003D)/,/^(?:\u002F\u002F)/,/^(?:\u002E\u002E\u002E)/,/^(?:\u002E\u002E)/,/^(?:\u002D\u003E)/,/^(?:\u002D\u003D)/,/^(?:\u002D\u002D)/,/^(?:\u002B\u003D)/,/^(?:\u002B\u002B)/,/^(?:\u002A\u003D)/,/^(?:\u002A\u002A)/,/^(?:\u0026\u0026)/,/^(?:\u0021\u003D)/,/^(?:\u0021\u0021)/,/^(?:\\twoheadrightarrowtail)/,/^(?:\\twoheadrightarrow)/,/^(?:\\twoheadleftarrow)/,/^(?:\\tripleintegral)/,/^(?:\\trianglerighteq)/,/^(?:\\triangleright)/,
+/^(?:\\triangleq)/,/^(?:\\trianglelefteq)/,/^(?:\\triangleleft)/,/^(?:\\triangledown)/,/^(?:\\triangle)/,/^(?:\\towa)/,/^(?:\\tosa)/,/^(?:\\top)/,/^(?:\\tooltip)/,/^(?:\\tona)/,/^(?:\\toggle)/,/^(?:\\toea)/,/^(?:\\to)/,/^(?:\\timesb)/,/^(?:\\times)/,/^(?:\\tilde)/,/^(?:\\thinspace)/,/^(?:\\thickspace)/,/^(?:\\thicksim)/,/^(?:\\thickapprox)/,/^(?:\\Theta)/,/^(?:\\theta)/,/^(?:\\therefore)/,/^(?:\\tfrac)/,/^(?:\\textstyle)/,/^(?:\\textsize)/,/^(?:\\textquotedblright)/,/^(?:\\textquotedblleft)/,/^(?:\\textasciitilde)/,
+/^(?:\\textasciigrave)/,/^(?:\\textasciicircumflex)/,/^(?:\\textasciiacute)/,/^(?:\\text)/,/^(?:\\tensor)/,/^(?:\\tbinom)/,/^(?:\\Tau)/,/^(?:\\tau)/,/^(?:\\swArrow)/,/^(?:\\swarrow)/,/^(?:\\swArr)/,/^(?:\\swarr)/,/^(?:\\surd)/,/^(?:\\supsetneqq)/,/^(?:\\supsetneq)/,/^(?:\\supseteqq)/,/^(?:\\supseteq)/,/^(?:\\Supset)/,/^(?:\\supset)/,/^(?:\\sum)/,/^(?:\\succsim)/,/^(?:\\succnsim)/,/^(?:\\succneqq)/,/^(?:\\succnapprox)/,/^(?:\\succeq)/,/^(?:\\succcurlyeq)/,/^(?:\\succapprox)/,/^(?:\\succ)/,/^(?:\\substack)/,
+/^(?:\\subsetneqq)/,/^(?:\\subsetneq)/,/^(?:\\subseteqq)/,/^(?:\\subseteq)/,/^(?:\\Subset)/,/^(?:\\subset)/,/^(?:\\statusline)/,/^(?:\\star)/,/^(?:\\stackrel)/,/^(?:\\sslash)/,/^(?:\\square)/,/^(?:\\sqsupseteq)/,/^(?:\\sqsupset)/,/^(?:\\sqsubseteq)/,/^(?:\\sqsubset)/,/^(?:\\sqrt)/,/^(?:\\sqcup)/,/^(?:\\sqcap)/,/^(?:\\sphericalangle)/,/^(?:\\spadesuit)/,/^(?:\\space)/,/^(?:\\smile)/,/^(?:\\smallsmile)/,/^(?:\\smallsetminus)/,/^(?:\\smallfrown)/,/^(?:\\slash)/,/^(?:\\simeq)/,/^(?:\\sim)/,/^(?:\\Sigma)/,
+/^(?:\\sigma)/,/^(?:\\shuffle)/,/^(?:\\shortparallel)/,/^(?:\\shortmid)/,/^(?:\\sharp)/,/^(?:\\setminus)/,/^(?:\\seovnearrow)/,/^(?:\\seArrow)/,/^(?:\\searrow)/,/^(?:\\seArr)/,/^(?:\\searr)/,/^(?:\\scriptsize)/,/^(?:\\scriptscriptsize)/,/^(?:\\rtimes)/,/^(?:\\Rsh)/,/^(?:\\Rrightarrow)/,/^(?:\\rrangle)/,/^(?:\\rq)/,/^(?:\\rowspan)/,/^(?:\\rowopts)/,/^(?:\\rowlines)/,/^(?:\\rowalign)/,/^(?:\\root)/,/^(?:\\rmoustache)/,/^(?:\\risingdotseq)/,/^(?:\\righttoleftarrow)/,/^(?:\\rightthreetimes)/,/^(?:\\rightsquigarrow)/,
+/^(?:\\rightrightarrows)/,/^(?:\\rightleftharpoons)/,/^(?:\\rightleftarrows)/,/^(?:\\rightharpoonup)/,/^(?:\\rightharpoondown)/,/^(?:\\rightarrowtriangle)/,/^(?:\\rightarrowtail)/,/^(?:\\Rightarrow)/,/^(?:\\rightarrow)/,/^(?:\\right)/,/^(?:\\Rho)/,/^(?:\\rho)/,/^(?:\\rhd)/,/^(?:\\rfloor)/,/^(?:\\Re)/,/^(?:\\rdiagovsearrow)/,/^(?:\\rdiagovfdiag)/,/^(?:\\rceil)/,/^(?:\\rbrack)/,/^(?:\\rbrace)/,/^(?:\\rangle)/,/^(?:\\rang)/,/^(?:\\questeq)/,/^(?:\\quadrupleintegral)/,/^(?:\\quad)/,/^(?:\\qquad)/,/^(?:\\qed)/,
+/^(?:\\Psi)/,/^(?:\\psi)/,/^(?:\\propto)/,/^(?:\\product)/,/^(?:\\prod)/,/^(?:\\prime)/,/^(?:\\precsim)/,/^(?:\\precnsim)/,/^(?:\\precneqq)/,/^(?:\\precnapprox)/,/^(?:\\preceq)/,/^(?:\\preccurlyeq)/,/^(?:\\precapprox)/,/^(?:\\prec)/,/^(?:\\pmod)/,/^(?:\\pm)/,/^(?:\\plusdot)/,/^(?:\\plusb)/,/^(?:\\pitchfork)/,/^(?:\\Pi)/,/^(?:\\pi)/,/^(?:\\Phi)/,/^(?:\\phi)/,/^(?:\\phantom)/,/^(?:\\Perp)/,/^(?:\\perp)/,/^(?:\\partialmeetcontraction)/,/^(?:\\partial)/,/^(?:\\parr)/,/^(?:\\parallel)/,/^(?:\\padding)/,
+/^(?:\\overset)/,/^(?:\\overline)/,/^(?:\\overbrace)/,/^(?:\\over)/,/^(?:\\Otimes)/,/^(?:\\otimes)/,/^(?:\\oslash)/,/^(?:[\u007E\u00AF\u02C6\u02C7\u02C9\u02CD\u02DC\u02F7\u0302\u203E\u2044\u2190-\u2199\u219C-\u21AD\u21AF-\u21B5\u21B9\u21BC-\u21CC\u21D0-\u21DD\u21E0-\u21F0\u21F3\u21F5\u21F6\u21FD-\u21FF\u2215\u221A\u23B4\u23B5\u23DC-\u23E1\u27F0\u27F1\u27F5-\u27FF\u290A-\u2910\u2912\u2913\u2921\u2922\u294E-\u2961\u296E\u296F\u2B45\u2B46])/,/^(?:[\u2032-\u2035\u2057])/,/^(?:[\u220F-\u2211\u22C0-\u22C3\u2A00-\u2A0A\u2A10-\u2A14\u2AFC\u2AFF])/,
+/^(?:\\Oplus)/,/^(?:\\oplus)/,/^(?:[\u0028\u0029\u005B\u005D\u007C\u2016\u2308-\u230B\u2329\u232A\u2772\u2773\u27E6-\u27EF\u2980\u2983-\u2998\u29FC\u29FD])/,/^(?:[\u2018\u2019\u201C\u201D])/,/^(?:\\operatorname)/,/^(?:[\u0021-\u0023\u002A-\u002C\u002F\u003A-\u0040\u0060\u00A8\u00AA\u00AC\u00B0-\u00B4\u00B7-\u00BA\u00D7\u00F7\u02CA\u02CB\u02D8-\u02DA\u02DD\u0311\u03F6\u201A\u201B\u201E-\u2022\u2026\u2036\u2037\u2043\u2061-\u2064\u20DB\u20DC\u2145\u2146\u214B\u219A\u219B\u21AE\u21B6-\u21B8\u21BA\u21BB\u21CD-\u21CF\u21DE\u21DF\u21F1\u21F2\u21F4\u21F7-\u21FC\u2200-\u2204\u2206-\u220E\u2212-\u2214\u2216-\u2219\u221B-\u221D\u221F-\u22BF\u22C4-\u22FF\u2305\u2306\u2322\u2323\u23B0\u23B1\u25A0\u25A1\u25AA\u25AB\u25AD-\u25B9\u25BC-\u25CF\u25D6\u25D7\u25E6\u2605\u2660-\u2663\u266D-\u266F\u2758\u27F2\u27F3\u2900-\u2909\u2911\u2914-\u2920\u2923-\u294D\u2962-\u296D\u2970-\u297F\u2981\u2982\u2999-\u29D9\u29DB-\u29FB\u29FE\u29FF\u2A0B-\u2A0F\u2A15-\u2ADB\u2ADD-\u2AFB\u2AFD\u2AFE])/,
+/^(?:\\ominus)/,/^(?:\\omicron)/,/^(?:\\Omega)/,/^(?:\\omega)/,/^(?:\\oint)/,/^(?:\\oiint)/,/^(?:\\oiiint)/,/^(?:\\odot)/,/^(?:\\odash)/,/^(?:\\obslash)/,/^(?:\\nwovnearrow)/,/^(?:\\nwArrow)/,/^(?:\\nwarrow)/,/^(?:\\nwArr)/,/^(?:\\nwarr)/,/^(?:\\nVDash)/,/^(?:\\nVdash)/,/^(?:\\nvDash)/,/^(?:\\nvdash)/,/^(?:\u221E)/,/^(?:\\Nu)/,/^(?:\\nu)/,/^(?:\\ntrianglerighteq)/,/^(?:\\ntriangleright)/,/^(?:\\ntrianglelefteq)/,/^(?:\\ntriangleleft)/,/^(?:\\nsupseteq)/,/^(?:\\nsupset)/,/^(?:\\nsuccsim)/,/^(?:\\nsucceq)/,
+/^(?:\\nsucc)/,/^(?:\\nsubseteqq)/,/^(?:\\nsubseteq)/,/^(?:\\nsubset)/,/^(?:\\nsime)/,/^(?:\\nsim)/,/^(?:\\nshortparallel)/,/^(?:\\nshortmid)/,/^(?:\\nRightarrow)/,/^(?:\\nrightarrow)/,/^(?:\\npreceq)/,/^(?:\\nprec)/,/^(?:\\nparallel)/,/^(?:\\notni)/,/^(?:\\notin)/,/^(?:\\not)/,/^(?:\\nmid)/,/^(?:\\nless)/,/^(?:\\nleqslant)/,/^(?:\\nleqq)/,/^(?:\\nleq)/,/^(?:\\nLeftrightarrow)/,/^(?:\\nleftrightarrow)/,/^(?:\\nLeftarrow)/,/^(?:\\nleftarrow)/,/^(?:\\ni)/,/^(?:\\ngtr)/,/^(?:\\ngeqslant)/,/^(?:\\ngeqq)/,
+/^(?:\\ngeq)/,/^(?:\\nexists)/,/^(?:\\nequiv)/,/^(?:\\neqsim)/,/^(?:\\neq)/,/^(?:\\neovsearrow)/,/^(?:\\neovnwarrow)/,/^(?:\\negthickspace)/,/^(?:\\negspace)/,/^(?:\\negmedspace)/,/^(?:\\neg)/,/^(?:\\neArrow)/,/^(?:\\nearrow)/,/^(?:\\neArr)/,/^(?:\\nearr)/,/^(?:\\ne)/,/^(?:\\ncong)/,/^(?:\\nBumpeq)/,/^(?:\\nbumpeq)/,/^(?:\\natural)/,/^(?:\\napprox)/,/^(?:\\nabla)/,/^(?:\\multiscripts)/,/^(?:\\multimap)/,/^(?:\\Mu)/,/^(?:\\mu)/,/^(?:\\mtext)/,/^(?:\\ms)/,/^(?:\\mp)/,/^(?:\\models)/,/^(?:\\mod)/,/^(?:\\mo)/,
+/^(?:\\mn)/,/^(?:\\mlcp)/,/^(?:\\minusdot)/,/^(?:\\minusb)/,/^(?:\\minus)/,/^(?:\\min)/,/^(?:\\mid)/,/^(?:\\mi)/,/^(?:\\mho)/,/^(?:\\mho)/,/^(?:\\medspace)/,/^(?:\\measuredangle)/,/^(?:\\mathtt)/,/^(?:\\mathsf)/,/^(?:\\mathscr)/,/^(?:\\mathrm)/,/^(?:\\mathrlap)/,/^(?:\\mathrel)/,/^(?:\\mathraisebox)/,/^(?:\\mathop)/,/^(?:\\mathmit)/,/^(?:\\mathllap)/,/^(?:\\mathit)/,/^(?:\\mathfrak)/,/^(?:\\mathfr)/,/^(?:\\mathclap)/,/^(?:\\mathcal)/,/^(?:\\mathbscr)/,/^(?:\\mathbit)/,/^(?:\\mathbin)/,/^(?:\\mathbf)/,
+/^(?:\\mathbcal)/,/^(?:\\mathbb)/,/^(?:\\Mapsto)/,/^(?:\\mapsto)/,/^(?:\\Mapsfrom)/,/^(?:\\map)/,/^(?:\\lvertneqq)/,/^(?:\\lvertneqq)/,/^(?:\\ltimes)/,/^(?:\\lt)/,/^(?:\\Lsh)/,/^(?:\\lq)/,/^(?:\\lozenge)/,/^(?:\\lowint)/,/^(?:\\looparrowright)/,/^(?:\\looparrowleft)/,/^(?:\\Longrightarrow)/,/^(?:\\longrightarrow)/,/^(?:\\longmapsto)/,/^(?:\\Longleftrightarrow)/,/^(?:\\longleftrightarrow)/,/^(?:\\Longleftarrow)/,/^(?:\\longleftarrow)/,/^(?:\\lnsim)/,/^(?:\\lneqq)/,/^(?:\\lneq)/,/^(?:\\lnapprox)/,/^(?:\\lmoustache)/,
+/^(?:\\lll)/,/^(?:\\Lleftarrow)/,/^(?:\\llangle)/,/^(?:\\ll)/,/^(?:\\lhd)/,/^(?:\\lfloor)/,/^(?:\\lesssim)/,/^(?:\\lessgtr)/,/^(?:\\lesseqqgtr)/,/^(?:\\lesseqgtr)/,/^(?:\\lessdot)/,/^(?:\\lessapprox)/,/^(?:\\less)/,/^(?:\\leqslant)/,/^(?:\\leqq)/,/^(?:\\leq)/,/^(?:\\lefttorightarrow)/,/^(?:\\leftthreetimes)/,/^(?:\\leftsquigarrow)/,/^(?:\\leftrightsquigarrow)/,/^(?:\\leftrightharpoons)/,/^(?:\\leftrightarrowtria\*)/,/^(?:\\leftrightarrows)/,/^(?:\\Leftrightarrow)/,/^(?:\\leftrightarrow)/,/^(?:\\leftleftarrows)/,
+/^(?:\\leftharpoonup)/,/^(?:\\leftharpoondown)/,/^(?:\\leftarrowtriangle)/,/^(?:\\leftarrowtail)/,/^(?:\\Leftarrow)/,/^(?:\\leftarrow)/,/^(?:\\left)/,/^(?:\\le)/,/^(?:\\ldots)/,/^(?:\\lceil)/,/^(?:\\lbrack)/,/^(?:\\lbrace)/,/^(?:\\langle)/,/^(?:\\lang)/,/^(?:\\Lambda)/,/^(?:\\lambda)/,/^(?:\\kernelcontraction)/,/^(?:\\Kappa)/,/^(?:\\kappa)/,/^(?:\\jmath)/,/^(?:\\itexnum)/,/^(?:\\Iota)/,/^(?:\\iota)/,/^(?:\\invamp)/,/^(?:\\intx)/,/^(?:\\intprodr)/,/^(?:\\intprod)/,/^(?:\\Intersection)/,/^(?:\\intersection)/,
+/^(?:\\interleave)/,/^(?:\\intercal)/,/^(?:\\integral)/,/^(?:\\intcup)/,/^(?:\\intcap)/,/^(?:\\intBar)/,/^(?:\\intbar)/,/^(?:\\int)/,/^(?:\\infty)/,/^(?:\\infinity)/,/^(?:\\inf)/,/^(?:\\in)/,/^(?:\\implies)/,/^(?:\\impliedby)/,/^(?:\\imath)/,/^(?:\\Im)/,/^(?:\\iint)/,/^(?:\\iiint)/,/^(?:\\iiiint)/,/^(?:\\iff)/,/^(?:\\hslash)/,/^(?:\\href)/,/^(?:\\hookrightarrow)/,/^(?:\\hookleftarrow)/,/^(?:\\hkswarow)/,/^(?:\\hksearow)/,/^(?:\\heartsuit)/,/^(?:\\hbar)/,/^(?:\\hat)/,/^(?:\\gvertneqq)/,/^(?:\\gvertneqq)/,
+/^(?:\\gtrsim)/,/^(?:\\gtrless)/,/^(?:\\gtreqqless)/,/^(?:\\gtreqless)/,/^(?:\\gtrdot)/,/^(?:\\gtrapprox)/,/^(?:\\gt)/,/^(?:\\greater)/,/^(?:\\gnsim)/,/^(?:\\gneqq)/,/^(?:\\gneq)/,/^(?:\\gnapprox)/,/^(?:\\gimel)/,/^(?:\\ggg)/,/^(?:\\gg)/,/^(?:\\geqslant)/,/^(?:\\geqq)/,/^(?:\\geq)/,/^(?:\\ge)/,/^(?:\\Gamma)/,/^(?:\\gamma)/,/^(?:\\frown)/,/^(?:\\frame)/,/^(?:\\frac)/,/^(?:\\forksnot)/,/^(?:\\forks)/,/^(?:\\forall)/,/^(?:\\flat)/,/^(?:\\fdiagovrdiag)/,/^(?:\\fdiagovnearrow)/,/^(?:\\fallingdotseq)/,
+/^(?:\\exists)/,/^(?:\\eth)/,/^(?:\\eth)/,/^(?:\\Eta)/,/^(?:\\eta)/,/^(?:\\equiv)/,/^(?:\\equalrows)/,/^(?:\\equalcols)/,/^(?:\\eqslantless)/,/^(?:\\eqslantgtr)/,/^(?:\\eqsim)/,/^(?:\\Eqqcolon)/,/^(?:\\eqqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\Eqcolon)/,/^(?:\\eqcolon)/,/^(?:\\eqcirc)/,/^(?:\\epsilon)/,/^(?:\\end\{Vmatrix\})/,/^(?:\\end\{vmatrix\})/,/^(?:\\endtoggle)/,/^(?:\\end\{split\})/,/^(?:\\end\{smallmatrix\})/,/^(?:\\end\{pmatrix\})/,/^(?:\\end\{matrix\})/,/^(?:\\end\{gathered\})/,
+/^(?:\\end\{cases\})/,/^(?:\\end\{Bmatrix\})/,/^(?:\\end\{bmatrix\})/,/^(?:\\end\{array\})/,/^(?:\\end\{aligned\})/,/^(?:\\emptyset)/,/^(?:\\empty)/,/^(?:\\embedsin)/,/^(?:\\ell)/,/^(?:\\duparr)/,/^(?:\\dualmap)/,/^(?:\\drbkarrow)/,/^(?:\\downuparrow)/,/^(?:\\downharpoonright)/,/^(?:\\downharpoonleft)/,/^(?:\\downdownarrows)/,/^(?:\\Downarrow)/,/^(?:\\downarrow)/,/^(?:\\doubleintegral)/,/^(?:\\doublebarwedge)/,/^(?:\\doublebarwedge)/,/^(?:\\dots)/,/^(?:\\dotplus)/,/^(?:\\dotminus)/,/^(?:\\doteqdot)/,
+/^(?:\\Doteq)/,/^(?:\\doteq)/,/^(?:\\dot)/,/^(?:\\divideontimes)/,/^(?:\\div)/,/^(?:\\displaystyle)/,/^(?:\\disjquant)/,/^(?:\\digamma)/,/^(?:\\diamondsuit)/,/^(?:\\Diamond)/,/^(?:\\diamond)/,/^(?:\\det|\\gcd|\\liminf|\\limsup|\\lim|\\max|\\Pr|\\sup)/,/^(?:\\Delta)/,/^(?:\\delta)/,/^(?:\\Del)/,/^(?:\\degree)/,/^(?:\\Ddownarrow)/,/^(?:\\ddotseq)/,/^(?:\\ddots)/,/^(?:\\ddot)/,/^(?:\\dddot)/,/^(?:\\dddot)/,/^(?:\\ddddot)/,/^(?:\\ddddot)/,/^(?:\\ddagger)/,/^(?:\\dblcolon)/,/^(?:\\dbkarow)/,/^(?:\\Dashv)/,
+/^(?:\\dashV)/,/^(?:\\dashv)/,/^(?:\\dashrightarrow)/,/^(?:\\dashleftarrow)/,/^(?:\\darr)/,/^(?:\\daleth)/,/^(?:\\dagger)/,/^(?:\\curvearrowright)/,/^(?:\\curvearrowleft)/,/^(?:\\curvearrowbotright)/,/^(?:\\curlywedge)/,/^(?:\\curlyvee)/,/^(?:\\curlyeqsucc)/,/^(?:\\curlyeqprec)/,/^(?:\\cupdot)/,/^(?:\\Cup)/,/^(?:\\cup)/,/^(?:\\coproduct)/,/^(?:\\coprod)/,/^(?:\\contourintegral)/,/^(?:\\conjquant)/,/^(?:\\conint)/,/^(?:\\cong)/,/^(?:\\complement)/,/^(?:\\colspan)/,/^(?:\\color)/,/^(?:\\Colonsim)/,
+/^(?:\\colonsim)/,/^(?:\\Coloneqq)/,/^(?:\\coloneqq)/,/^(?:\\Coloneq)/,/^(?:\\coloneq)/,/^(?:\\Colonapprox)/,/^(?:\\colonapprox)/,/^(?:\\Colon)/,/^(?:\\colon)/,/^(?:\\collines)/,/^(?:\\collayout)/,/^(?:\\colalign)/,/^(?:\\clubsuit)/,/^(?:\\closure)/,/^(?:\\circleddash)/,/^(?:\\circledcirc)/,/^(?:\\circledast)/,/^(?:\\circlearrowright)/,/^(?:\\circlearrowleft)/,/^(?:\\circeq)/,/^(?:\\circ)/,/^(?:\\choose)/,/^(?:\\chi)/,/^(?:\\check)/,/^(?:\\cellopts)/,/^(?:\\cdots)/,/^(?:\\cdotp)/,/^(?:\\cdot)/,/^(?:\\Cap)/,
+/^(?:\\cap)/,/^(?:\\bumpeqq)/,/^(?:\\Bumpeq)/,/^(?:\\bumpeq)/,/^(?:\\bullet)/,/^(?:\\btimes)/,/^(?:\\boxtimes)/,/^(?:\\boxplus)/,/^(?:\\boxminus)/,/^(?:\\boxed)/,/^(?:\\boxdot)/,/^(?:\\boxdiag)/,/^(?:\\boxcircle)/,/^(?:\\boxbslash)/,/^(?:\\boxast)/,/^(?:\\Box)/,/^(?:\\bowtie)/,/^(?:\\bottom)/,/^(?:\\bot)/,/^(?:\\boldsymbol)/,/^(?:\\blacktriangleright)/,/^(?:\\blacktriangleleft)/,/^(?:\\blacktriangledown)/,/^(?:\\blacktriangle)/,/^(?:\\blacksquare)/,/^(?:\\blacklozenge)/,/^(?:\\bkarow)/,/^(?:\\binom)/,
+/^(?:\\bigwedge)/,/^(?:\\bigvee)/,/^(?:\\biguplus)/,/^(?:\\bigtriangleup)/,/^(?:\\bigtriangledown)/,/^(?:\\bigtimes)/,/^(?:\\bigstar)/,/^(?:\\bigsqcup)/,/^(?:\\bigsqcap)/,/^(?:\\Bigr)/,/^(?:\\bigr)/,/^(?:\\bigotimes)/,/^(?:\\bigoplus)/,/^(?:\\bigodot)/,/^(?:\\Bigl)/,/^(?:\\bigl)/,/^(?:\\biginterleave)/,/^(?:\\Biggr)/,/^(?:\\biggr)/,/^(?:\\Biggl)/,/^(?:\\biggl)/,/^(?:\\Bigg)/,/^(?:\\bigg)/,/^(?:\\bigcupdot)/,/^(?:\\bigcup)/,/^(?:\\bigcirc)/,/^(?:\\bigcap)/,/^(?:\\Big)/,/^(?:\\big)/,/^(?:\\bgcolor)/,
+/^(?:\\between)/,/^(?:\\beth)/,/^(?:\\Beta)/,/^(?:\\beta)/,/^(?:\\begin\{Vmatrix\})/,/^(?:\\begin\{vmatrix\})/,/^(?:\\begintoggle)/,/^(?:\\begin\{split\})/,/^(?:\\begin\{smallmatrix\})/,/^(?:\\begin\{pmatrix\})/,/^(?:\\begin\{matrix\})/,/^(?:\\begin\{gathered\})/,/^(?:\\begin\{cases\})/,/^(?:\\begin\{Bmatrix\})/,/^(?:\\begin\{bmatrix\})/,/^(?:\\begin\{array\})/,/^(?:\\begin\{aligned\})/,/^(?:\\because)/,/^(?:\\BbbPi)/,/^(?:\\barwedge)/,/^(?:\\bar)/,/^(?:\\backslash)/,/^(?:\\backsimeq)/,/^(?:\\backsim)/,
+/^(?:\\backprime)/,/^(?:\\backepsilon)/,/^(?:\\atop)/,/^(?:\\asymp)/,/^(?:\\ast)/,/^(?:\\arrayopts)/,/^(?:\\array)/,/^(?:\\arccos|\\arcsin|\\arctan|\\arg|\\cosh|\\cos|\\coth|\\cot|\\csc|\\deg|\\dim|\\exp|\\hom|\\ker|\\lg|\\ln|\\log|\\sec|\\sinh|\\sin|\\tanh|\\tan)/,/^(?:\\approxeq)/,/^(?:\\approx)/,/^(?:\\angle)/,/^(?:\\amalg)/,/^(?:\\Alpha)/,/^(?:\\alpha)/,/^(?:\\align)/,/^(?:\\aleph)/,/^(?:[\u0041-\u005A])/,/^(?:[\u0391-\u03A1\u03A3\u03A4\u03A6-\u03A9])/,/^(?:[\u0061-\u007A\u0131\u0237])/,/^(?:[\u03B1-\u03C1\u03C3-\u03C9\u03D1\u03D5\u03D6\u03F0\u03F1\u03F4\u03F5])/,
+/^(?:\\adots)/,/^(?:\\AA)/,/^(?:[\u00F0\u03C2\u03D0\u03D2\u03DA-\u03DD\u03E0\u03E1\u0428\u0608\u0627-\u063A\u2102\u210A-\u210D\u210F-\u2113\u2115\u2118-\u211D\u2124\u2127\u2128\u212B-\u212D\u212F-\u2131\u2133-\u2138\u213C\u213D\u213F\u2205]|\uD83B[\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB\uDEF0\uDEF1]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDFCB])/,
+/^(?:\\\$)/,/^(?:\\\})/,/^(?:\\\|)/,/^(?:\\\{)/,/^(?:\\;)/,/^(?:\\:)/,/^(?:\\,)/,/^(?:\\&)/,/^(?:\\%)/,/^(?:\\#)/,/^(?:\\!)/,/^(?:-)/,/^(?:'''')/,/^(?:''')/,/^(?:'')/,/^(?:')/,/^(?:[\uD800-\uDBFF])/,/^(?:[\uDC00-\uDFFF])/,/^(?:.)/],M:{MATH0:{rules:[14,15,16,17,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,
+99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,
+225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,
+351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,
+477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,
+603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,
+729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,
+855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883],inclusive:tb},MATH1:{rules:[14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,
+125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,
+251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,
+377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,
+503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,
+629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,
+755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,
+881,882,883],inclusive:tb},OPTARG:{rules:[13,14,15,16,17,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,
+151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,
+277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,
+403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,
+529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,
+655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,
+781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883],inclusive:tb},DOCUMENT:{rules:[1,2,3,4,5],inclusive:yb},TRYOPTARG:{rules:[6,7],inclusive:yb},
+TEXTOPTARG:{rules:[8,9],inclusive:yb},TEXTARG:{rules:[10,11,12],inclusive:yb},INITIAL:{rules:[0,14,15,16,17,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,
+138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,
+264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,
+390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,
+516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,
+642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,
+768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883],inclusive:tb}}}}();Rb.prototype=rb;rb.pa=
+Rb;return new Rb}();window.TeXZilla=zb;window.TeXZilla.setDOMParser=zb.fa;window.TeXZilla.setXMLSerializer=zb.ja;window.TeXZilla.setSafeMode=zb.ia;window.TeXZilla.setItexIdentifierMode=zb.ha;window.TeXZilla.getTeXSource=zb.ca;window.TeXZilla.toMathMLString=zb.Z;window.TeXZilla.toMathML=zb.Y;window.TeXZilla.toImage=zb.na;window.TeXZilla.filterString=zb.Q;window.TeXZilla.filterElement=zb.P;
+})();
diff --git a/comm/mail/components/customizableui/CustomizableUI.sys.mjs b/comm/mail/components/customizableui/CustomizableUI.sys.mjs
new file mode 100644
index 0000000000..2628bd6109
--- /dev/null
+++ b/comm/mail/components/customizableui/CustomizableUI.sys.mjs
@@ -0,0 +1,360 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file is a copy of a file with the same name in Firefox. Only the
+// pieces we're using, and a few pieces the devtools rely on such as the
+// constants, remain.
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+
+/**
+ * gPanelsForWindow is a list of known panels in a window which we may need to close
+ * should command events fire which target them.
+ */
+var gPanelsForWindow = new WeakMap();
+
+var CustomizableUIInternal = {
+ addPanelCloseListeners(aPanel) {
+ Services.els.addSystemEventListener(aPanel, "click", this, false);
+ Services.els.addSystemEventListener(aPanel, "keypress", this, false);
+ let win = aPanel.ownerGlobal;
+ if (!gPanelsForWindow.has(win)) {
+ gPanelsForWindow.set(win, new Set());
+ }
+ gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel));
+ },
+
+ removePanelCloseListeners(aPanel) {
+ Services.els.removeSystemEventListener(aPanel, "click", this, false);
+ Services.els.removeSystemEventListener(aPanel, "keypress", this, false);
+ let win = aPanel.ownerGlobal;
+ let panels = gPanelsForWindow.get(win);
+ if (panels) {
+ panels.delete(this._getPanelForNode(aPanel));
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "click":
+ case "keypress":
+ this.maybeAutoHidePanel(aEvent);
+ break;
+ }
+ },
+
+ _getPanelForNode(aNode) {
+ return aNode.closest("panel");
+ },
+
+ /*
+ * If people put things in the panel which need more than single-click interaction,
+ * we don't want to close it. Right now we check for text inputs and menu buttons.
+ * We also check for being outside of any toolbaritem/toolbarbutton, ie on a blank
+ * part of the menu.
+ */
+ _isOnInteractiveElement(aEvent) {
+ function getMenuPopupForDescendant(aNode) {
+ let lastPopup = null;
+ while (
+ aNode &&
+ aNode.parentNode &&
+ aNode.parentNode.localName.startsWith("menu")
+ ) {
+ lastPopup = aNode.localName == "menupopup" ? aNode : lastPopup;
+ aNode = aNode.parentNode;
+ }
+ return lastPopup;
+ }
+
+ let target = aEvent.target;
+ let panel = this._getPanelForNode(aEvent.currentTarget);
+ // This can happen in e.g. customize mode. If there's no panel,
+ // there's clearly nothing for us to close; pretend we're interactive.
+ if (!panel) {
+ return true;
+ }
+ // We keep track of:
+ // whether we're in an input container (text field)
+ let inInput = false;
+ // whether we're in a popup/context menu
+ let inMenu = false;
+ // whether we're in a toolbarbutton/toolbaritem
+ let inItem = false;
+ // whether the current menuitem has a valid closemenu attribute
+ let menuitemCloseMenu = "auto";
+
+ // While keeping track of that, we go from the original target back up,
+ // to the panel if we have to. We bail as soon as we find an input,
+ // a toolbarbutton/item, or the panel:
+ while (target) {
+ // Skip out of iframes etc:
+ if (target.nodeType == target.DOCUMENT_NODE) {
+ if (!target.defaultView) {
+ // Err, we're done.
+ break;
+ }
+ // Find containing browser or iframe element in the parent doc.
+ target = target.defaultView.docShell.chromeEventHandler;
+ if (!target) {
+ break;
+ }
+ }
+ let tagName = target.localName;
+ inInput = tagName == "input";
+ inItem = tagName == "toolbaritem" || tagName == "toolbarbutton";
+ let isMenuItem = tagName == "menuitem";
+ inMenu = inMenu || isMenuItem;
+
+ if (isMenuItem && target.hasAttribute("closemenu")) {
+ let closemenuVal = target.getAttribute("closemenu");
+ menuitemCloseMenu =
+ closemenuVal == "single" || closemenuVal == "none"
+ ? closemenuVal
+ : "auto";
+ }
+
+ // Keep the menu open and break out of the loop if the click happened on
+ // the ShadowRoot or a disabled menu item.
+ if (
+ target.nodeType == target.DOCUMENT_FRAGMENT_NODE ||
+ target.getAttribute("disabled") == "true"
+ ) {
+ return true;
+ }
+
+ // This isn't in the loop condition because we want to break before
+ // changing |target| if any of these conditions are true
+ if (inInput || inItem || target == panel) {
+ break;
+ }
+ // We need specific code for popups: the item on which they were invoked
+ // isn't necessarily in their parentNode chain:
+ if (isMenuItem) {
+ let topmostMenuPopup = getMenuPopupForDescendant(target);
+ target =
+ (topmostMenuPopup && topmostMenuPopup.triggerNode) ||
+ target.parentNode;
+ } else {
+ target = target.parentNode;
+ }
+ }
+
+ // If the user clicked a menu item...
+ if (inMenu) {
+ // We care if we're in an input also,
+ // or if the user specified closemenu!="auto":
+ if (inInput || menuitemCloseMenu != "auto") {
+ return true;
+ }
+ // Otherwise, we're probably fine to close the panel
+ return false;
+ }
+ // If we're not in a menu, and we *are* in a type="menu" toolbarbutton,
+ // we'll now interact with the menu
+ if (inItem && target.getAttribute("type") == "menu") {
+ return true;
+ }
+ return inInput || !inItem;
+ },
+
+ hidePanelForNode(aNode) {
+ let panel = this._getPanelForNode(aNode);
+ if (panel) {
+ lazy.PanelMultiView.hidePopup(panel);
+ }
+ },
+
+ maybeAutoHidePanel(aEvent) {
+ let eventType = aEvent.type;
+ if (eventType == "keypress" && aEvent.keyCode != aEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ if (eventType == "click" && aEvent.button != 0) {
+ return;
+ }
+
+ // We don't check preventDefault - it makes sense that this was prevented,
+ // but we probably still want to close the panel. If consumers don't want
+ // this to happen, they should specify the closemenu attribute.
+ if (eventType != "command" && this._isOnInteractiveElement(aEvent)) {
+ return;
+ }
+
+ // We can't use event.target because we might have passed an anonymous
+ // content boundary as well, and so target points to the outer element in
+ // that case. Unfortunately, this means we get anonymous child nodes instead
+ // of the real ones, so looking for the 'stoooop, don't close me' attributes
+ // is more involved.
+ let target = aEvent.originalTarget;
+ while (target.parentNode && target.localName != "panel") {
+ if (
+ target.getAttribute("closemenu") == "none" ||
+ target.getAttribute("widget-type") == "view" ||
+ target.getAttribute("widget-type") == "button-and-view"
+ ) {
+ return;
+ }
+ target = target.parentNode;
+ }
+
+ // If we get here, we can actually hide the popup:
+ this.hidePanelForNode(aEvent.target);
+ },
+};
+Object.freeze(CustomizableUIInternal);
+
+export var CustomizableUI = {
+ /**
+ * Constant reference to the ID of the navigation toolbar.
+ */
+ AREA_NAVBAR: "nav-bar",
+ /**
+ * Constant reference to the ID of the menubar's toolbar.
+ */
+ AREA_MENUBAR: "toolbar-menubar",
+ /**
+ * Constant reference to the ID of the tabstrip toolbar.
+ */
+ AREA_TABSTRIP: "TabsToolbar",
+ /**
+ * Constant reference to the ID of the bookmarks toolbar.
+ */
+ AREA_BOOKMARKS: "PersonalToolbar",
+ /**
+ * Constant reference to the ID of the non-dymanic (fixed) list in the overflow panel.
+ */
+ AREA_FIXED_OVERFLOW_PANEL: "widget-overflow-fixed-list",
+
+ /**
+ * Constant indicating the area is a menu panel.
+ */
+ TYPE_MENU_PANEL: "menu-panel",
+ /**
+ * Constant indicating the area is a toolbar.
+ */
+ TYPE_TOOLBAR: "toolbar",
+
+ /**
+ * Constant indicating a XUL-type provider.
+ */
+ PROVIDER_XUL: "xul",
+ /**
+ * Constant indicating an API-type provider.
+ */
+ PROVIDER_API: "api",
+ /**
+ * Constant indicating dynamic (special) widgets: spring, spacer, and separator.
+ */
+ PROVIDER_SPECIAL: "special",
+
+ /**
+ * Constant indicating the widget is built-in
+ */
+ SOURCE_BUILTIN: "builtin",
+ /**
+ * Constant indicating the widget is externally provided
+ * (e.g. by add-ons or other items not part of the builtin widget set).
+ */
+ SOURCE_EXTERNAL: "external",
+
+ /**
+ * Constant indicating the reason the event was fired was a window closing
+ */
+ REASON_WINDOW_CLOSED: "window-closed",
+ /**
+ * Constant indicating the reason the event was fired was an area being
+ * unregistered separately from window closing mechanics.
+ */
+ REASON_AREA_UNREGISTERED: "area-unregistered",
+
+ /**
+ * Add a widget to an area.
+ * If the area to which you try to add is not known to CustomizableUI,
+ * this will throw.
+ * If the area to which you try to add is the same as the area in which
+ * the widget is currently placed, this will do the same as
+ * moveWidgetWithinArea.
+ * If the widget cannot be removed from its original location, this will
+ * no-op.
+ *
+ * This will fire an onWidgetAdded notification,
+ * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification
+ * for each window CustomizableUI knows about.
+ *
+ * @param aWidgetId the ID of the widget to add
+ * @param aArea the ID of the area to add the widget to
+ * @param aPosition the position at which to add the widget. If you do not
+ * pass a position, the widget will be added to the end
+ * of the area.
+ */
+ addWidgetToArea(aWidgetId, aArea, aPosition) {},
+ /**
+ * Remove a widget from its area. If the widget cannot be removed from its
+ * area, or is not in any area, this will no-op. Otherwise, this will fire an
+ * onWidgetRemoved notification, and an onWidgetBeforeDOMChange and
+ * onWidgetAfterDOMChange notification for each window CustomizableUI knows
+ * about.
+ *
+ * @param aWidgetId the ID of the widget to remove
+ */
+ removeWidgetFromArea(aWidgetId) {},
+ /**
+ * Get the placement of a widget. This is by far the best way to obtain
+ * information about what the state of your widget is. The internals of
+ * this call are cheap (no DOM necessary) and you will know where the user
+ * has put your widget.
+ *
+ * @param aWidgetId the ID of the widget whose placement you want to know
+ * @returns
+ * {
+ * area: "somearea", // The ID of the area where the widget is placed
+ * position: 42 // the index in the placements array corresponding to
+ * // your widget.
+ * }
+ *
+ * OR
+ *
+ * null // if the widget is not placed anywhere (ie in the palette)
+ */
+ getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) {
+ return null;
+ },
+ /**
+ * Add listeners to a panel that will close it. For use from the menu panel
+ * and overflowable toolbar implementations, unlikely to be useful for
+ * consumers.
+ *
+ * @param aPanel the panel to which listeners should be attached.
+ */
+ addPanelCloseListeners(aPanel) {
+ CustomizableUIInternal.addPanelCloseListeners(aPanel);
+ },
+ /**
+ * Remove close listeners that have been added to a panel with
+ * addPanelCloseListeners. For use from the menu panel and overflowable
+ * toolbar implementations, unlikely to be useful for consumers.
+ *
+ * @param aPanel the panel from which listeners should be removed.
+ */
+ removePanelCloseListeners(aPanel) {
+ CustomizableUIInternal.removePanelCloseListeners(aPanel);
+ },
+ /**
+ * Notify toolbox(es) of a particular event. If you don't pass aWindow,
+ * all toolboxes will be notified. For use from Customize Mode only,
+ * do not use otherwise.
+ *
+ * @param aEvent the name of the event to send.
+ * @param aDetails optional, the details of the event.
+ * @param aWindow optional, the window in which to send the event.
+ */
+ dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) {},
+};
+Object.freeze(CustomizableUI);
diff --git a/comm/mail/components/customizableui/PanelMultiView.sys.mjs b/comm/mail/components/customizableui/PanelMultiView.sys.mjs
new file mode 100644
index 0000000000..c68f88c586
--- /dev/null
+++ b/comm/mail/components/customizableui/PanelMultiView.sys.mjs
@@ -0,0 +1,1699 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Allows a popup panel to host multiple subviews. The main view shown when the
+ * panel is opened may slide out to display a subview, which in turn may lead to
+ * other subviews in a cascade menu pattern.
+ *
+ * The <panel> element should contain a <panelmultiview> element. Views are
+ * declared using <panelview> elements that are usually children of the main
+ * <panelmultiview> element, although they don't need to be, as views can also
+ * be imported into the panel from other panels or popup sets.
+ *
+ * The panel should be opened asynchronously using the openPopup static method
+ * on the PanelMultiView object. This will display the view specified using the
+ * mainViewId attribute on the contained <panelmultiview> element.
+ *
+ * Specific subviews can slide in using the showSubView method, and backwards
+ * navigation can be done using the goBack method or through a button in the
+ * subview headers.
+ *
+ * The process of displaying the main view or a new subview requires multiple
+ * steps to be completed, hence at any given time the <panelview> element may
+ * be in different states:
+ *
+ * -- Open or closed
+ *
+ * All the <panelview> elements start "closed", meaning that they are not
+ * associated to a <panelmultiview> element and can be located anywhere in
+ * the document. When the openPopup or showSubView methods are called, the
+ * relevant view becomes "open" and the <panelview> element may be moved to
+ * ensure it is a descendant of the <panelmultiview> element.
+ *
+ * The "ViewShowing" event is fired at this point, when the view is not
+ * visible yet. The event is allowed to cancel the operation, in which case
+ * the view is closed immediately.
+ *
+ * Closing the view does not move the node back to its original position.
+ *
+ * -- Visible or invisible
+ *
+ * This indicates whether the view is visible in the document from a layout
+ * perspective, regardless of whether it is currently scrolled into view. In
+ * fact, all subviews are already visible before they start sliding in.
+ *
+ * Before scrolling into view, a view may become visible but be placed in a
+ * special off-screen area of the document where layout and measurements can
+ * take place asynchronously.
+ *
+ * When navigating forward, an open view may become invisible but stay open
+ * after sliding out of view. The last known size of these views is still
+ * taken into account for determining the overall panel size.
+ *
+ * When navigating backwards, an open subview will first become invisible and
+ * then will be closed.
+ *
+ * -- Active or inactive
+ *
+ * This indicates whether the view is fully scrolled into the visible area
+ * and ready to receive mouse and keyboard events. An active view is always
+ * visible, but a visible view may be inactive. For example, during a scroll
+ * transition, both views will be inactive.
+ *
+ * When a view becomes active, the ViewShown event is fired synchronously,
+ * and the showSubView and goBack methods can be called for navigation.
+ *
+ * For the main view of the panel, the ViewShown event is dispatched during
+ * the "popupshown" event, which means that other "popupshown" handlers may
+ * be called before the view is active. Thus, code that needs to perform
+ * further navigation automatically should either use the ViewShown event or
+ * wait for an event loop tick, like BrowserTestUtils.waitForEvent does.
+ *
+ * -- Navigating with the keyboard
+ *
+ * An open view may keep state related to keyboard navigation, even if it is
+ * invisible. When a view is closed, keyboard navigation state is cleared.
+ *
+ * This diagram shows how <panelview> nodes move during navigation:
+ *
+ * In this <panelmultiview> In other panels Action
+ * ┌───┬───┬───┐ ┌───┬───┐
+ * │(A)│ B │ C │ │ D │ E │ Open panel
+ * └───┴───┴───┘ └───┴───┘
+ * ┌───┬───┬───┐ ┌───┬───┐
+ * │{A}│(C)│ B │ │ D │ E │ Show subview C
+ * └───┴───┴───┘ └───┴───┘
+ * ┌───┬───┬───┬───┐ ┌───┐
+ * │{A}│{C}│(D)│ B │ │ E │ Show subview D
+ * └───┴───┴───┴───┘ └───┘
+ * │ ┌───┬───┬───┬───┐ ┌───┐
+ * │ │{A}│(C)│ D │ B │ │ E │ Go back
+ * │ └───┴───┴───┴───┘ └───┘
+ * │ │ │
+ * │ │ └── Currently visible view
+ * │ │ │
+ * └───┴───┴── Open views
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+});
+
+/**
+ * Safety timeout after which asynchronous events will be canceled if any of the
+ * registered blockers does not return.
+ */
+const BLOCKERS_TIMEOUT_MS = 10000;
+
+const TRANSITION_PHASES = Object.freeze({
+ START: 1,
+ PREPARE: 2,
+ TRANSITION: 3,
+});
+
+let gNodeToObjectMap = new WeakMap();
+let gWindowsWithUnloadHandler = new WeakSet();
+
+/**
+ * Allows associating an object to a node lazily using a weak map.
+ *
+ * Classes deriving from this one may be easily converted to Custom Elements,
+ * although they would lose the ability of being associated lazily.
+ */
+var AssociatedToNode = class {
+ constructor(node) {
+ /**
+ * Node associated to this object.
+ */
+ this.node = node;
+
+ /**
+ * This promise is resolved when the current set of blockers set by event
+ * handlers have all been processed.
+ */
+ this._blockersPromise = Promise.resolve();
+ }
+
+ /**
+ * Retrieves the instance associated with the given node, constructing a new
+ * one if necessary. When the last reference to the node is released, the
+ * object instance will be garbage collected as well.
+ */
+ static forNode(node) {
+ let associatedToNode = gNodeToObjectMap.get(node);
+ if (!associatedToNode) {
+ associatedToNode = new this(node);
+ gNodeToObjectMap.set(node, associatedToNode);
+ }
+ return associatedToNode;
+ }
+
+ get document() {
+ return this.node.ownerDocument;
+ }
+
+ get window() {
+ return this.node.ownerGlobal;
+ }
+
+ _getBoundsWithoutFlushing(element) {
+ return this.window.windowUtils.getBoundsWithoutFlushing(element);
+ }
+
+ /**
+ * Dispatches a custom event on this element.
+ *
+ * @param {string} eventName Name of the event to dispatch.
+ * @param {object} [detail] Event detail object. Optional.
+ * @param {boolean} cancelable If the event can be canceled.
+ * @returns {boolean} `true` if the event was canceled by an event handler, `false`
+ * otherwise.
+ */
+ dispatchCustomEvent(eventName, detail, cancelable = false) {
+ let event = new this.window.CustomEvent(eventName, {
+ detail,
+ bubbles: true,
+ cancelable,
+ });
+ this.node.dispatchEvent(event);
+ return event.defaultPrevented;
+ }
+
+ /**
+ * Dispatches a custom event on this element and waits for any blocking
+ * promises registered using the "addBlocker" function on the details object.
+ * If this function is called again, the event is only dispatched after all
+ * the previously registered blockers have returned.
+ *
+ * The event can be canceled either by resolving any blocking promise to the
+ * boolean value "false" or by calling preventDefault on the event. Rejections
+ * and exceptions will be reported and will cancel the event.
+ *
+ * Blocking should be used sporadically because it slows down the interface.
+ * Also, non-reentrancy is not strictly guaranteed because a safety timeout of
+ * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled.
+ * This helps to prevent deadlocks if any of the event handlers does not
+ * resolve a blocker promise.
+ *
+ * @note Since there is no use case for dispatching different asynchronous
+ * events in parallel for the same element, this function will also wait
+ * for previous blockers when the event name is different.
+ *
+ * @param eventName
+ * Name of the custom event to dispatch.
+ *
+ * @resolves True if the event was canceled by a handler, false otherwise.
+ */
+ async dispatchAsyncEvent(eventName) {
+ // Wait for all the previous blockers before dispatching the event.
+ let blockersPromise = this._blockersPromise.catch(() => {});
+ return (this._blockersPromise = blockersPromise.then(async () => {
+ let blockers = new Set();
+ let cancel = this.dispatchCustomEvent(
+ eventName,
+ {
+ addBlocker(promise) {
+ // Any exception in the blocker will cancel the operation.
+ blockers.add(
+ promise.catch(ex => {
+ console.error(ex);
+ return true;
+ })
+ );
+ },
+ },
+ true
+ );
+ if (blockers.size) {
+ let timeoutPromise = new Promise((resolve, reject) => {
+ this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS);
+ });
+ try {
+ let results = await Promise.race([
+ Promise.all(blockers),
+ timeoutPromise,
+ ]);
+ cancel = cancel || results.some(result => result === false);
+ } catch (ex) {
+ console.error(
+ new Error(`One of the blockers for ${eventName} timed out.`)
+ );
+ return true;
+ }
+ }
+ return cancel;
+ }));
+ }
+};
+
+/**
+ * This is associated to <panelmultiview> elements.
+ */
+export class PanelMultiView extends AssociatedToNode {
+ /**
+ * Tries to open the specified <panel> and displays the main view specified
+ * with the "mainViewId" attribute on the <panelmultiview> node it contains.
+ *
+ * If the panel does not contain a <panelmultiview>, it is opened directly.
+ * This allows consumers like page actions to accept different panel types.
+ *
+ * @see The non-static openPopup method for details.
+ */
+ static async openPopup(panelNode, ...args) {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ return this.forNode(panelMultiViewNode).openPopup(...args);
+ }
+ panelNode.openPopup(...args);
+ return true;
+ }
+
+ /**
+ * Closes the specified <panel> which contains a <panelmultiview> node.
+ *
+ * If the panel does not contain a <panelmultiview>, it is closed directly.
+ * This allows consumers like page actions to accept different panel types.
+ *
+ * @see The non-static hidePopup method for details.
+ */
+ static hidePopup(panelNode) {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ this.forNode(panelMultiViewNode).hidePopup();
+ } else {
+ panelNode.hidePopup();
+ }
+ }
+
+ /**
+ * Removes the specified <panel> from the document, ensuring that any
+ * <panelmultiview> node it contains is destroyed properly.
+ *
+ * If the viewCacheId attribute is present on the <panelmultiview> element,
+ * imported subviews will be moved out again to the element it specifies, so
+ * that the panel element can be removed safely.
+ *
+ * If the panel does not contain a <panelmultiview>, it is removed directly.
+ * This allows consumers like page actions to accept different panel types.
+ */
+ static removePopup(panelNode) {
+ try {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ let panelMultiView = this.forNode(panelMultiViewNode);
+ panelMultiView._moveOutKids();
+ panelMultiView.disconnect();
+ }
+ } finally {
+ // Make sure to remove the panel element even if disconnecting fails.
+ panelNode.remove();
+ }
+ }
+
+ /**
+ * Ensures that when the specified window is closed all the <panelmultiview>
+ * node it contains are destroyed properly.
+ */
+ static ensureUnloadHandlerRegistered(window) {
+ if (gWindowsWithUnloadHandler.has(window)) {
+ return;
+ }
+
+ window.addEventListener(
+ "unload",
+ () => {
+ for (let panelMultiViewNode of window.document.querySelectorAll(
+ "panelmultiview"
+ )) {
+ this.forNode(panelMultiViewNode).disconnect();
+ }
+ },
+ { once: true }
+ );
+
+ gWindowsWithUnloadHandler.add(window);
+ }
+
+ get _panel() {
+ return this.node.parentNode;
+ }
+
+ set _transitioning(val) {
+ if (val) {
+ this.node.setAttribute("transitioning", "true");
+ } else {
+ this.node.removeAttribute("transitioning");
+ }
+ }
+
+ get _screenManager() {
+ if (this.__screenManager) {
+ return this.__screenManager;
+ }
+ return (this.__screenManager = Cc[
+ "@mozilla.org/gfx/screenmanager;1"
+ ].getService(Ci.nsIScreenManager));
+ }
+
+ constructor(node) {
+ super(node);
+ this._openPopupPromise = Promise.resolve(false);
+ this._openPopupCancelCallback = () => {};
+ }
+
+ connect() {
+ this.connected = true;
+
+ PanelMultiView.ensureUnloadHandlerRegistered(this.window);
+
+ let viewContainer = (this._viewContainer =
+ this.document.createXULElement("box"));
+ viewContainer.classList.add("panel-viewcontainer");
+
+ let viewStack = (this._viewStack = this.document.createXULElement("box"));
+ viewStack.classList.add("panel-viewstack");
+ viewContainer.append(viewStack);
+
+ let offscreenViewContainer = this.document.createXULElement("box");
+ offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen");
+
+ let offscreenViewStack = (this._offscreenViewStack =
+ this.document.createXULElement("box"));
+ offscreenViewStack.classList.add("panel-viewstack");
+ offscreenViewContainer.append(offscreenViewStack);
+
+ this.node.prepend(offscreenViewContainer);
+ this.node.prepend(viewContainer);
+
+ this.openViews = [];
+
+ this._panel.addEventListener("popupshowing", this);
+ this._panel.addEventListener("popuppositioned", this);
+ this._panel.addEventListener("popuphidden", this);
+ this._panel.addEventListener("popupshown", this);
+
+ // Proxy these public properties and methods, as used elsewhere by various
+ // parts of the browser, to this instance.
+ ["goBack", "showSubView"].forEach(method => {
+ Object.defineProperty(this.node, method, {
+ enumerable: true,
+ value: (...args) => this[method](...args),
+ });
+ });
+ }
+
+ disconnect() {
+ // Guard against re-entrancy.
+ if (!this.node || !this.connected) {
+ return;
+ }
+
+ this._panel.removeEventListener("mousemove", this);
+ this._panel.removeEventListener("popupshowing", this);
+ this._panel.removeEventListener("popuppositioned", this);
+ this._panel.removeEventListener("popupshown", this);
+ this._panel.removeEventListener("popuphidden", this);
+ this.window.removeEventListener("keydown", this, true);
+ this.node =
+ this._openPopupPromise =
+ this._openPopupCancelCallback =
+ this._viewContainer =
+ this._viewStack =
+ this._transitionDetails =
+ null;
+ }
+
+ /**
+ * Tries to open the panel associated with this PanelMultiView, and displays
+ * the main view specified with the "mainViewId" attribute.
+ *
+ * The hidePopup method can be called while the operation is in progress to
+ * prevent the panel from being displayed. View events may also cancel the
+ * operation, so there is no guarantee that the panel will become visible.
+ *
+ * The "popuphidden" event will be fired either when the operation is canceled
+ * or when the popup is closed later. This event can be used for example to
+ * reset the "open" state of the anchor or tear down temporary panels.
+ *
+ * If this method is called again before the panel is shown, the result
+ * depends on the operation currently in progress. If the operation was not
+ * canceled, the panel is opened using the arguments from the previous call,
+ * and this call is ignored. If the operation was canceled, it will be
+ * retried again using the arguments from this call.
+ *
+ * It's not necessary for the <panelmultiview> binding to be connected when
+ * this method is called, but the containing panel must have its display
+ * turned on, for example it shouldn't have the "hidden" attribute.
+ *
+ * @param anchor
+ * The node to anchor the popup to.
+ * @param options
+ * Either options to use or a string position. This is forwarded to
+ * the openPopup method of the panel.
+ * @param args
+ * Additional arguments to be forwarded to the openPopup method of the
+ * panel.
+ *
+ * @resolves With true as soon as the request to display the panel has been
+ * sent, or with false if the operation was canceled. The state of
+ * the panel at this point is not guaranteed. It may be still
+ * showing, completely shown, or completely hidden.
+ * @rejects If an exception is thrown at any point in the process before the
+ * request to display the panel is sent.
+ */
+ async openPopup(anchor, options, ...args) {
+ // Set up the function that allows hidePopup or a second call to showPopup
+ // to cancel the specific panel opening operation that we're starting below.
+ // This function must be synchronous, meaning we can't use Promise.race,
+ // because hidePopup wants to dispatch the "popuphidden" event synchronously
+ // even if the panel has not been opened yet.
+ let canCancel = true;
+ let cancelCallback = (this._openPopupCancelCallback = () => {
+ // If the cancel callback is called and the panel hasn't been prepared
+ // yet, cancel showing it. Setting canCancel to false will prevent the
+ // popup from opening. If the panel has opened by the time the cancel
+ // callback is called, canCancel will be false already, and we will not
+ // fire the "popuphidden" event.
+ if (canCancel && this.node) {
+ canCancel = false;
+ this.dispatchCustomEvent("popuphidden");
+ }
+ });
+
+ // Create a promise that is resolved with the result of the last call to
+ // this method, where errors indicate that the panel was not opened.
+ let openPopupPromise = this._openPopupPromise.catch(() => {
+ return false;
+ });
+
+ // Make the preparation done before showing the panel non-reentrant. The
+ // promise created here will be resolved only after the panel preparation is
+ // completed, even if a cancellation request is received in the meantime.
+ return (this._openPopupPromise = openPopupPromise.then(async wasShown => {
+ // The panel may have been destroyed in the meantime.
+ if (!this.node) {
+ return false;
+ }
+ // If the panel has been already opened there is nothing more to do. We
+ // check the actual state of the panel rather than setting some state in
+ // our handler of the "popuphidden" event because this has a lower chance
+ // of locking indefinitely if events aren't raised in the expected order.
+ if (wasShown && ["open", "showing"].includes(this._panel.state)) {
+ return true;
+ }
+ try {
+ if (!this.connected) {
+ this.connect();
+ }
+ // Allow any of the ViewShowing handlers to prevent showing the main view.
+ if (!(await this._showMainView())) {
+ cancelCallback();
+ }
+ } catch (ex) {
+ cancelCallback();
+ throw ex;
+ }
+ // If a cancellation request was received there is nothing more to do.
+ if (!canCancel || !this.node) {
+ return false;
+ }
+ // We have to set canCancel to false before opening the popup because the
+ // hidePopup method of PanelMultiView can be re-entered by event handlers.
+ // If the openPopup call fails, however, we still have to dispatch the
+ // "popuphidden" event even if canCancel was set to false.
+ try {
+ canCancel = false;
+ this._panel.openPopup(anchor, options, ...args);
+
+ // On Windows, if another popup is hiding while we call openPopup, the
+ // call won't fail but the popup won't open. In this case, we have to
+ // dispatch an artificial "popuphidden" event to reset our state.
+ if (this._panel.state == "closed" && this.openViews.length) {
+ this.dispatchCustomEvent("popuphidden");
+ return false;
+ }
+
+ if (
+ options &&
+ typeof options == "object" &&
+ options.triggerEvent &&
+ options.triggerEvent.type == "keypress" &&
+ this.openViews.length
+ ) {
+ // This was opened via the keyboard, so focus the first item.
+ this.openViews[0].focusWhenActive = true;
+ }
+
+ return true;
+ } catch (ex) {
+ this.dispatchCustomEvent("popuphidden");
+ throw ex;
+ }
+ }));
+ }
+
+ /**
+ * Closes the panel associated with this PanelMultiView.
+ *
+ * If the openPopup method was called but the panel has not been displayed
+ * yet, the operation is canceled and the panel will not be displayed, but the
+ * "popuphidden" event is fired synchronously anyways.
+ *
+ * This means that by the time this method returns all the operations handled
+ * by the "popuphidden" event are completed, for example resetting the "open"
+ * state of the anchor, and the panel is already invisible.
+ */
+ hidePopup() {
+ if (!this.node || !this.connected) {
+ return;
+ }
+
+ // If we have already reached the _panel.openPopup call in the openPopup
+ // method, we can call hidePopup. Otherwise, we have to cancel the latest
+ // request to open the panel, which will have no effect if the request has
+ // been canceled already.
+ if (["open", "showing"].includes(this._panel.state)) {
+ this._panel.hidePopup();
+ } else {
+ this._openPopupCancelCallback();
+ }
+
+ // We close all the views synchronously, so that they are ready to be opened
+ // in other PanelMultiView instances. The "popuphidden" handler may also
+ // call this function, but the second time openViews will be empty.
+ this.closeAllViews();
+ }
+
+ /**
+ * Move any child subviews into the element defined by "viewCacheId" to make
+ * sure they will not be removed together with the <panelmultiview> element.
+ */
+ _moveOutKids() {
+ let viewCacheId = this.node.getAttribute("viewCacheId");
+ if (!viewCacheId) {
+ return;
+ }
+
+ // Node.children and Node.children is live to DOM changes like the
+ // ones we're about to do, so iterate over a static copy:
+ let subviews = Array.from(this._viewStack.children);
+ let viewCache = this.document.getElementById(viewCacheId);
+ for (let subview of subviews) {
+ viewCache.appendChild(subview);
+ }
+ }
+
+ /**
+ * Slides in the specified view as a subview.
+ *
+ * @param viewIdOrNode
+ * DOM element or string ID of the <panelview> to display.
+ * @param anchor
+ * DOM element that triggered the subview, which will be highlighted
+ * and whose "label" attribute will be used for the title of the
+ * subview when a "title" attribute is not specified.
+ */
+ showSubView(viewIdOrNode, anchor) {
+ this._showSubView(viewIdOrNode, anchor).catch(console.error);
+ }
+ async _showSubView(viewIdOrNode, anchor) {
+ let viewNode =
+ typeof viewIdOrNode == "string"
+ ? this.document.getElementById(viewIdOrNode)
+ : viewIdOrNode;
+ if (!viewNode) {
+ console.error(new Error(`Subview ${viewIdOrNode} doesn't exist.`));
+ return;
+ }
+
+ if (!this.openViews.length) {
+ console.error(new Error(`Cannot show a subview in a closed panel.`));
+ return;
+ }
+
+ let prevPanelView = this.openViews[this.openViews.length - 1];
+ let nextPanelView = PanelView.forNode(viewNode);
+ if (this.openViews.includes(nextPanelView)) {
+ console.error(new Error(`Subview ${viewNode.id} is already open.`));
+ return;
+ }
+
+ // Do not re-enter the process if navigation is already in progress. Since
+ // there is only one active view at any given time, we can do this check
+ // safely, even considering that during the navigation process the actual
+ // view to which prevPanelView refers will change.
+ if (!prevPanelView.active) {
+ return;
+ }
+ // If prevPanelView._doingKeyboardActivation is true, it will be reset to
+ // false synchronously. Therefore, we must capture it before we use any
+ // "await" statements.
+ let doingKeyboardActivation = prevPanelView._doingKeyboardActivation;
+ // Marking the view that is about to scrolled out of the visible area as
+ // inactive will prevent re-entrancy and also disable keyboard navigation.
+ // From this point onwards, "await" statements can be used safely.
+ prevPanelView.active = false;
+
+ // Provide visual feedback while navigation is in progress, starting before
+ // the transition starts and ending when the previous view is invisible.
+ if (anchor) {
+ anchor.setAttribute("open", "true");
+ }
+ try {
+ // If the ViewShowing event cancels the operation we have to re-enable
+ // keyboard navigation, but this must be avoided if the panel was closed.
+ if (!(await this._openView(nextPanelView))) {
+ if (prevPanelView.isOpenIn(this)) {
+ // We don't raise a ViewShown event because nothing actually changed.
+ // Technically we should use a different state flag just because there
+ // is code that could check the "active" property to determine whether
+ // to wait for a ViewShown event later, but this only happens in
+ // regression tests and is less likely to be a technique used in
+ // production code, where use of ViewShown is less common.
+ prevPanelView.active = true;
+ }
+ return;
+ }
+
+ prevPanelView.captureKnownSize();
+
+ // The main view of a panel can be a subview in another one. Make sure to
+ // reset all the properties that may be set on a subview.
+ nextPanelView.mainview = false;
+ // The header may change based on how the subview was opened.
+ nextPanelView.headerText =
+ viewNode.getAttribute("title") ||
+ (anchor && anchor.getAttribute("label"));
+ // The constrained width of subviews may also vary between panels.
+ nextPanelView.minMaxWidth = prevPanelView.knownWidth;
+
+ if (anchor) {
+ viewNode.classList.add("PanelUI-subView");
+ }
+
+ await this._transitionViews(prevPanelView.node, viewNode, false, anchor);
+ } finally {
+ if (anchor) {
+ anchor.removeAttribute("open");
+ }
+ }
+
+ nextPanelView.focusWhenActive = doingKeyboardActivation;
+ this._activateView(nextPanelView);
+ }
+
+ /**
+ * Navigates backwards by sliding out the most recent subview.
+ */
+ goBack() {
+ this._goBack().catch(console.error);
+ }
+ async _goBack() {
+ if (this.openViews.length < 2) {
+ // This may be called by keyboard navigation or external code when only
+ // the main view is open.
+ return;
+ }
+
+ let prevPanelView = this.openViews[this.openViews.length - 1];
+ let nextPanelView = this.openViews[this.openViews.length - 2];
+
+ // Like in the showSubView method, do not re-enter navigation while it is
+ // in progress, and make the view inactive immediately. From this point
+ // onwards, "await" statements can be used safely.
+ if (!prevPanelView.active) {
+ return;
+ }
+
+ prevPanelView.active = false;
+
+ prevPanelView.captureKnownSize();
+
+ await this._transitionViews(prevPanelView.node, nextPanelView.node, true);
+
+ this._closeLatestView();
+
+ this._activateView(nextPanelView);
+ }
+
+ /**
+ * Prepares the main view before showing the panel.
+ */
+ async _showMainView() {
+ let nextPanelView = PanelView.forNode(
+ this.document.getElementById(this.node.getAttribute("mainViewId"))
+ );
+
+ // If the view is already open in another panel, close the panel first.
+ let oldPanelMultiViewNode = nextPanelView.node.panelMultiView;
+ if (oldPanelMultiViewNode) {
+ PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup();
+ // Wait for a layout flush after hiding the popup, otherwise the view may
+ // not be displayed correctly for some time after the new panel is opened.
+ // This is filed as bug 1441015.
+ await this.window.promiseDocumentFlushed(() => {});
+ }
+
+ if (!(await this._openView(nextPanelView))) {
+ return false;
+ }
+
+ // The main view of a panel can be a subview in another one. Make sure to
+ // reset all the properties that may be set on a subview.
+ nextPanelView.mainview = true;
+ nextPanelView.headerText = "";
+ nextPanelView.minMaxWidth = 0;
+
+ // Ensure the view will be visible once the panel is opened.
+ nextPanelView.visible = true;
+
+ return true;
+ }
+
+ /**
+ * Opens the specified PanelView and dispatches the ViewShowing event, which
+ * can be used to populate the subview or cancel the operation.
+ *
+ * This also clears all the attributes and styles that may be left by a
+ * transition that was interrupted.
+ *
+ * @resolves With true if the view was opened, false otherwise.
+ */
+ async _openView(panelView) {
+ if (panelView.node.parentNode != this._viewStack) {
+ this._viewStack.appendChild(panelView.node);
+ }
+
+ panelView.node.panelMultiView = this.node;
+ this.openViews.push(panelView);
+
+ let canceled = await panelView.dispatchAsyncEvent("ViewShowing");
+
+ // The panel can be hidden while we are processing the ViewShowing event.
+ // This results in all the views being closed synchronously, and at this
+ // point the ViewHiding event has already been dispatched for all of them.
+ if (!this.openViews.length) {
+ return false;
+ }
+
+ // Check if the event requested cancellation but the panel is still open.
+ if (canceled) {
+ // Handlers for ViewShowing can't know if a different handler requested
+ // cancellation, so this will dispatch a ViewHiding event to give a chance
+ // to clean up.
+ this._closeLatestView();
+ return false;
+ }
+
+ // Clean up all the attributes and styles related to transitions. We do this
+ // here rather than when the view is closed because we are likely to make
+ // other DOM modifications soon, which isn't the case when closing.
+ let { style } = panelView.node;
+ style.removeProperty("outline");
+ style.removeProperty("width");
+
+ return true;
+ }
+
+ /**
+ * Activates the specified view and raises the ViewShown event, unless the
+ * view was closed in the meantime.
+ */
+ _activateView(panelView) {
+ if (panelView.isOpenIn(this)) {
+ panelView.active = true;
+ if (panelView.focusWhenActive) {
+ panelView.focusFirstNavigableElement(false, true);
+ panelView.focusWhenActive = false;
+ }
+ panelView.dispatchCustomEvent("ViewShown");
+ }
+ }
+
+ /**
+ * Closes the most recent PanelView and raises the ViewHiding event.
+ *
+ * @note The ViewHiding event is not cancelable and should probably be renamed
+ * to ViewHidden or ViewClosed instead, see bug 1438507.
+ */
+ _closeLatestView() {
+ let panelView = this.openViews.pop();
+ panelView.clearNavigation();
+ panelView.dispatchCustomEvent("ViewHiding");
+ panelView.node.panelMultiView = null;
+ // Views become invisible synchronously when they are closed, and they won't
+ // become visible again until they are opened. When this is called at the
+ // end of backwards navigation, the view is already invisible.
+ panelView.visible = false;
+ }
+
+ /**
+ * Closes all the views that are currently open.
+ */
+ closeAllViews() {
+ // Raise ViewHiding events for open views in reverse order.
+ while (this.openViews.length) {
+ this._closeLatestView();
+ }
+ }
+
+ /**
+ * Apply a transition to 'slide' from the currently active view to the next
+ * one.
+ * Sliding the next subview in means that the previous panelview stays where it
+ * is and the active panelview slides in from the left in LTR mode, right in
+ * RTL mode.
+ *
+ * @param {panelview} previousViewNode Node that is currently displayed, but
+ * is about to be transitioned away. This
+ * must be already inactive at this point.
+ * @param {panelview} viewNode - Node that will becode the active view,
+ * after the transition has finished.
+ * @param {boolean} reverse Whether we're navigation back to a
+ * previous view or forward to a next view.
+ */
+ async _transitionViews(previousViewNode, viewNode, reverse) {
+ const { window } = this;
+
+ let nextPanelView = PanelView.forNode(viewNode);
+ let prevPanelView = PanelView.forNode(previousViewNode);
+
+ let details = (this._transitionDetails = {
+ phase: TRANSITION_PHASES.START,
+ });
+
+ // Set the viewContainer dimensions to make sure only the current view is
+ // visible.
+ let olderView = reverse ? nextPanelView : prevPanelView;
+ this._viewContainer.style.minHeight = olderView.knownHeight + "px";
+ this._viewContainer.style.height = prevPanelView.knownHeight + "px";
+ this._viewContainer.style.width = prevPanelView.knownWidth + "px";
+ // Lock the dimensions of the window that hosts the popup panel.
+ let rect = this._getBoundsWithoutFlushing(this._panel);
+ this._panel.style.width = rect.width + "px";
+ this._panel.style.height = rect.height + "px";
+
+ let viewRect;
+ if (reverse) {
+ // Use the cached size when going back to a previous view, but not when
+ // reopening a subview, because its contents may have changed.
+ viewRect = {
+ width: nextPanelView.knownWidth,
+ height: nextPanelView.knownHeight,
+ };
+ nextPanelView.visible = true;
+ } else if (viewNode.customRectGetter) {
+ // We use a customRectGetter for WebExtensions panels, because they need
+ // to query the size from an embedded browser. The presence of this
+ // getter also provides an indication that the view node shouldn't be
+ // moved around, otherwise the state of the browser would get disrupted.
+ let width = prevPanelView.knownWidth;
+ let height = prevPanelView.knownHeight;
+ viewRect = Object.assign({ height, width }, viewNode.customRectGetter());
+ nextPanelView.visible = true;
+ // Until the header is visible, it has 0 height.
+ // Wait for layout before measuring it
+ let header = viewNode.firstElementChild;
+ if (header && header.classList.contains("panel-header")) {
+ viewRect.height += await window.promiseDocumentFlushed(() => {
+ return this._getBoundsWithoutFlushing(header).height;
+ });
+ }
+ } else {
+ this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px";
+ this._offscreenViewStack.appendChild(viewNode);
+ nextPanelView.visible = true;
+
+ viewRect = await window.promiseDocumentFlushed(() => {
+ return this._getBoundsWithoutFlushing(viewNode);
+ });
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+
+ // Place back the view after all the other views that are already open in
+ // order for the transition to work as expected.
+ this._viewStack.appendChild(viewNode);
+
+ this._offscreenViewStack.style.removeProperty("min-height");
+ }
+
+ this._transitioning = true;
+ details.phase = TRANSITION_PHASES.PREPARE;
+
+ // The 'magic' part: build up the amount of pixels to move right or left.
+ let moveToLeft =
+ (this.window.RTL_UI && !reverse) || (!this.window.RTL_UI && reverse);
+ let deltaX = prevPanelView.knownWidth;
+ let deepestNode = reverse ? previousViewNode : viewNode;
+
+ // With a transition when navigating backwards - user hits the 'back'
+ // button - we need to make sure that the views are positioned in a way
+ // that a translateX() unveils the previous view from the right direction.
+ if (reverse) {
+ this._viewStack.style.marginInlineStart = "-" + deltaX + "px";
+ }
+
+ // Set the transition style and listen for its end to clean up and make sure
+ // the box sizing becomes dynamic again.
+ // Somehow, putting these properties in PanelUI.css doesn't work for newly
+ // shown nodes in a XUL parent node.
+ this._viewStack.style.transition =
+ "transform var(--animation-easing-function)" +
+ " var(--panelui-subview-transition-duration)";
+ this._viewStack.style.willChange = "transform";
+ // Use an outline instead of a border so that the size is not affected.
+ deepestNode.style.outline = "1px solid var(--panel-separator-color)";
+
+ // Now that all the elements are in place for the start of the transition,
+ // give the layout code a chance to set the initial values.
+ await window.promiseDocumentFlushed(() => {});
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+
+ // Now set the viewContainer dimensions to that of the new view, which
+ // kicks of the height animation.
+ this._viewContainer.style.height = viewRect.height + "px";
+ this._viewContainer.style.width = viewRect.width + "px";
+ this._panel.style.removeProperty("width");
+ this._panel.style.removeProperty("height");
+
+ // We're setting the width property to prevent flickering during the
+ // sliding animation with smaller views.
+ viewNode.style.width = viewRect.width + "px";
+
+ // Kick off the transition!
+ details.phase = TRANSITION_PHASES.TRANSITION;
+
+ // If we're going to show the main view, we can remove the
+ // min-height property on the view container.
+ if (viewNode.getAttribute("mainview")) {
+ this._viewContainer.style.removeProperty("min-height");
+ }
+
+ this._viewStack.style.transform =
+ "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)";
+
+ await new Promise(resolve => {
+ details.resolve = resolve;
+ this._viewContainer.addEventListener(
+ "transitionend",
+ (details.listener = ev => {
+ // It's quite common that `height` on the view container doesn't need
+ // to transition, so we make sure to do all the work on the transform
+ // transition-end, because that is guaranteed to happen.
+ if (ev.target != this._viewStack || ev.propertyName != "transform") {
+ return;
+ }
+ this._viewContainer.removeEventListener(
+ "transitionend",
+ details.listener
+ );
+ delete details.listener;
+ resolve();
+ })
+ );
+ this._viewContainer.addEventListener(
+ "transitioncancel",
+ (details.cancelListener = ev => {
+ if (ev.target != this._viewStack) {
+ return;
+ }
+ this._viewContainer.removeEventListener(
+ "transitioncancel",
+ details.cancelListener
+ );
+ delete details.cancelListener;
+ resolve();
+ })
+ );
+ });
+
+ // Bail out if the panel was closed during the transition.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+ prevPanelView.visible = false;
+
+ // This will complete the operation by removing any transition properties.
+ nextPanelView.node.style.removeProperty("width");
+ deepestNode.style.removeProperty("outline");
+ this._cleanupTransitionPhase();
+
+ nextPanelView.focusSelectedElement();
+ }
+
+ /**
+ * Attempt to clean up the attributes and properties set by `_transitionViews`
+ * above. Which attributes and properties depends on the phase the transition
+ * was left from.
+ */
+ _cleanupTransitionPhase() {
+ if (!this._transitionDetails) {
+ return;
+ }
+
+ let { phase, resolve, listener, cancelListener } = this._transitionDetails;
+ this._transitionDetails = null;
+
+ if (phase >= TRANSITION_PHASES.START) {
+ this._panel.style.removeProperty("width");
+ this._panel.style.removeProperty("height");
+ this._viewContainer.style.removeProperty("height");
+ this._viewContainer.style.removeProperty("width");
+ }
+ if (phase >= TRANSITION_PHASES.PREPARE) {
+ this._transitioning = false;
+ this._viewStack.style.removeProperty("margin-inline-start");
+ this._viewStack.style.removeProperty("transition");
+ }
+ if (phase >= TRANSITION_PHASES.TRANSITION) {
+ this._viewStack.style.removeProperty("transform");
+ if (listener) {
+ this._viewContainer.removeEventListener("transitionend", listener);
+ }
+ if (cancelListener) {
+ this._viewContainer.removeEventListener(
+ "transitioncancel",
+ cancelListener
+ );
+ }
+ if (resolve) {
+ resolve();
+ }
+ }
+ }
+
+ _calculateMaxHeight(aEvent) {
+ // While opening the panel, we have to limit the maximum height of any
+ // view based on the space that will be available. We cannot just use
+ // window.screen.availTop and availHeight because these may return an
+ // incorrect value when the window spans multiple screens.
+ let anchor = this._panel.anchorNode;
+ let anchorRect = anchor.getBoundingClientRect();
+
+ let screen = this._screenManager.screenForRect(
+ anchor.screenX,
+ anchor.screenY,
+ anchorRect.width,
+ anchorRect.height
+ );
+ let availTop = {},
+ availHeight = {};
+ screen.GetAvailRect({}, availTop, {}, availHeight);
+ let cssAvailTop = availTop.value / screen.defaultCSSScaleFactor;
+
+ // The distance from the anchor to the available margin of the screen is
+ // based on whether the panel will open towards the top or the bottom.
+ let maxHeight;
+ if (aEvent.alignmentPosition.startsWith("before_")) {
+ maxHeight = anchor.screenY - cssAvailTop;
+ } else {
+ let anchorScreenBottom = anchor.screenY + anchorRect.height;
+ let cssAvailHeight = availHeight.value / screen.defaultCSSScaleFactor;
+ maxHeight = cssAvailTop + cssAvailHeight - anchorScreenBottom;
+ }
+
+ // To go from the maximum height of the panel to the maximum height of
+ // the view stack, we need to subtract the height of the arrow and the
+ // height of the opposite margin, but we cannot get their actual values
+ // because the panel is not visible yet. However, we know that this is
+ // currently 11px on Mac, 13px on Windows, and 13px on Linux. We also
+ // want an extra margin, both for visual reasons and to prevent glitches
+ // due to small rounding errors. So, we just use a value that makes
+ // sense for all platforms. If the arrow visuals change significantly,
+ // this value will be easy to adjust.
+ const EXTRA_MARGIN_PX = 20;
+ maxHeight -= EXTRA_MARGIN_PX;
+ return maxHeight;
+ }
+
+ handleEvent(aEvent) {
+ // Only process actual popup events from the panel or events we generate
+ // ourselves, but not from menus being shown from within the panel.
+ if (
+ aEvent.type.startsWith("popup") &&
+ aEvent.target != this._panel &&
+ aEvent.target != this.node
+ ) {
+ return;
+ }
+ switch (aEvent.type) {
+ case "keydown":
+ // Since we start listening for the "keydown" event when the popup is
+ // already showing and stop listening when the panel is hidden, we
+ // always have at least one view open.
+ let currentView = this.openViews[this.openViews.length - 1];
+ currentView.keyNavigation(aEvent);
+ break;
+ case "mousemove":
+ this.openViews.forEach(panelView => panelView.clearNavigation());
+ break;
+ case "popupshowing": {
+ this._viewContainer.setAttribute("panelopen", "true");
+ if (!this.node.hasAttribute("disablekeynav")) {
+ // We add the keydown handler on the window so that it handles key
+ // presses when a panel appears but doesn't get focus, as happens
+ // when a button to open a panel is clicked with the mouse.
+ // However, this means the listener is on an ancestor of the panel,
+ // which means that handlers such as ToolbarKeyboardNavigator are
+ // deeper in the tree. Therefore, this must be a capturing listener
+ // so we get the event first.
+ this.window.addEventListener("keydown", this, true);
+ this._panel.addEventListener("mousemove", this);
+ }
+ break;
+ }
+ case "popuppositioned": {
+ if (this._panel.state == "showing") {
+ let maxHeight = this._calculateMaxHeight(aEvent);
+ this._viewStack.style.maxHeight = maxHeight + "px";
+ this._offscreenViewStack.style.maxHeight = maxHeight + "px";
+ }
+ break;
+ }
+ case "popupshown":
+ // The main view is always open and visible when the panel is first
+ // shown, so we can check the height of the description elements it
+ // contains and notify consumers using the ViewShown event. In order to
+ // minimize flicker we need to allow synchronous reflows, and we still
+ // make sure the ViewShown event is dispatched synchronously.
+ let mainPanelView = this.openViews[0];
+ this._activateView(mainPanelView);
+ break;
+ case "popuphidden": {
+ // WebExtensions consumers can hide the popup from viewshowing, or
+ // mid-transition, which disrupts our state:
+ this._transitioning = false;
+ this._viewContainer.removeAttribute("panelopen");
+ this._cleanupTransitionPhase();
+ this.window.removeEventListener("keydown", this, true);
+ this._panel.removeEventListener("mousemove", this);
+ this.closeAllViews();
+
+ // Clear the main view size caches. The dimensions could be different
+ // when the popup is opened again, e.g. through touch mode sizing.
+ this._viewContainer.style.removeProperty("min-height");
+ this._viewStack.style.removeProperty("max-height");
+ this._viewContainer.style.removeProperty("width");
+ this._viewContainer.style.removeProperty("height");
+
+ this.dispatchCustomEvent("PanelMultiViewHidden");
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * This is associated to <panelview> elements.
+ */
+export class PanelView extends AssociatedToNode {
+ constructor(node) {
+ super(node);
+
+ /**
+ * Indicates whether the view is active. When this is false, consumers can
+ * wait for the ViewShown event to know when the view becomes active.
+ */
+ this.active = false;
+
+ /**
+ * Specifies whether the view should be focused when active. When this
+ * is true, the first navigable element in the view will be focused
+ * when the view becomes active. This should be set to true when the view
+ * is activated from the keyboard. It will be set to false once the view
+ * is active.
+ */
+ this.focusWhenActive = false;
+ }
+
+ /**
+ * Indicates whether the view is open in the specified PanelMultiView object.
+ */
+ isOpenIn(panelMultiView) {
+ return this.node.panelMultiView == panelMultiView.node;
+ }
+
+ /**
+ * The "mainview" attribute is set before the panel is opened when this view
+ * is displayed as the main view, and is removed before the <panelview> is
+ * displayed as a subview. The same view element can be displayed as a main
+ * view and as a subview at different times.
+ */
+ set mainview(value) {
+ if (value) {
+ this.node.setAttribute("mainview", true);
+ } else {
+ this.node.removeAttribute("mainview");
+ }
+ }
+
+ /**
+ * Determines whether the view is visible. Setting this to false also resets
+ * the "active" property.
+ */
+ set visible(value) {
+ if (value) {
+ this.node.setAttribute("visible", true);
+ } else {
+ this.node.removeAttribute("visible");
+ this.active = false;
+ this.focusWhenActive = false;
+ }
+ }
+
+ /**
+ * Constrains the width of this view using the "min-width" and "max-width"
+ * styles. Setting this to zero removes the constraints.
+ */
+ set minMaxWidth(value) {
+ let style = this.node.style;
+ if (value) {
+ style.minWidth = style.maxWidth = value + "px";
+ } else {
+ style.removeProperty("min-width");
+ style.removeProperty("max-width");
+ }
+ }
+
+ /**
+ * Adds a header with the given title, or removes it if the title is empty.
+ */
+ set headerText(value) {
+ // If the header already exists, update or remove it as requested.
+ let header = this.node.firstElementChild;
+ if (header && header.classList.contains("panel-header")) {
+ if (value) {
+ header.querySelector(".panel-header > h1 > span").textContent = value;
+ } else {
+ header.remove();
+ }
+ return;
+ }
+
+ // The header doesn't exist, only create it if needed.
+ if (!value) {
+ return;
+ }
+
+ header = this.document.createXULElement("box");
+ header.classList.add("panel-header");
+
+ let backButton = this.document.createXULElement("toolbarbutton");
+ backButton.className =
+ "subviewbutton subviewbutton-iconic subviewbutton-back";
+ backButton.setAttribute("closemenu", "none");
+ backButton.setAttribute("tabindex", "0");
+
+ backButton.setAttribute(
+ "aria-label",
+ lazy.gBundle.GetStringFromName("panel.back")
+ );
+
+ backButton.addEventListener("command", () => {
+ // The panelmultiview element may change if the view is reused.
+ this.node.panelMultiView.goBack();
+ backButton.blur();
+ });
+
+ let h1 = this.document.createElement("h1");
+ let span = this.document.createElement("span");
+ span.textContent = value;
+ h1.appendChild(span);
+
+ header.append(backButton, h1);
+ this.node.prepend(header);
+ }
+
+ /**
+ * Populates the "knownWidth" and "knownHeight" properties with the current
+ * dimensions of the view. These may be zero if the view is invisible.
+ *
+ * These values are relevant during transitions and are retained for backwards
+ * navigation if the view is still open but is invisible.
+ */
+ captureKnownSize() {
+ let rect = this._getBoundsWithoutFlushing(this.node);
+ this.knownWidth = rect.width;
+ this.knownHeight = rect.height;
+ }
+
+ /**
+ * Determine whether an element can only be navigated to with tab/shift+tab,
+ * not the arrow keys.
+ */
+ _isNavigableWithTabOnly(element) {
+ let tag = element.localName;
+ return (
+ tag == "menulist" ||
+ tag == "input" ||
+ tag == "textarea" ||
+ // Allow tab to reach embedded documents in extension panels.
+ tag == "browser"
+ );
+ }
+
+ /**
+ * Make a TreeWalker for keyboard navigation.
+ *
+ * @param {boolean} arrowKey If `true`, elements only navigable with tab are
+ * excluded.
+ */
+ _makeNavigableTreeWalker(arrowKey) {
+ let filter = node => {
+ if (node.disabled) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ let bounds = this._getBoundsWithoutFlushing(node);
+ if (bounds.width == 0 || bounds.height == 0) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ if (
+ node.tagName == "button" ||
+ node.tagName == "toolbarbutton" ||
+ node.classList.contains("text-link") ||
+ (!arrowKey && this._isNavigableWithTabOnly(node))
+ ) {
+ // Set the tabindex attribute to make sure the node is focusable.
+ if (!node.hasAttribute("tabindex")) {
+ node.setAttribute("tabindex", "-1");
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ };
+ return this.document.createTreeWalker(
+ this.node,
+ NodeFilter.SHOW_ELEMENT,
+ filter
+ );
+ }
+
+ /**
+ * Get a TreeWalker which finds elements navigable with tab/shift+tab.
+ */
+ get _tabNavigableWalker() {
+ if (!this.__tabNavigableWalker) {
+ this.__tabNavigableWalker = this._makeNavigableTreeWalker(false);
+ }
+ return this.__tabNavigableWalker;
+ }
+
+ /**
+ * Get a TreeWalker which finds elements navigable with up/down arrow keys.
+ */
+ get _arrowNavigableWalker() {
+ if (!this.__arrowNavigableWalker) {
+ this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true);
+ }
+ return this.__arrowNavigableWalker;
+ }
+
+ /**
+ * Element that is currently selected with the keyboard, or null if no element
+ * is selected. Since the reference is held weakly, it can become null or
+ * undefined at any time.
+ */
+ get selectedElement() {
+ return this._selectedElement && this._selectedElement.get();
+ }
+ set selectedElement(value) {
+ if (!value) {
+ delete this._selectedElement;
+ } else {
+ this._selectedElement = Cu.getWeakReference(value);
+ }
+ }
+
+ /**
+ * Focuses and moves keyboard selection to the first navigable element.
+ * This is a no-op if there are no navigable elements.
+ *
+ * @param {boolean} homeKey - `true` if this is for the home key.
+ * @param {boolean} skipBack - `true` if the Back button should be skipped.
+ */
+ focusFirstNavigableElement(homeKey = false, skipBack = false) {
+ // The home key is conceptually similar to the up/down arrow keys.
+ let walker = homeKey
+ ? this._arrowNavigableWalker
+ : this._tabNavigableWalker;
+ walker.currentNode = walker.root;
+ this.selectedElement = walker.firstChild();
+ if (
+ skipBack &&
+ walker.currentNode &&
+ walker.currentNode.classList.contains("subviewbutton-back") &&
+ walker.nextNode()
+ ) {
+ this.selectedElement = walker.currentNode;
+ }
+ this.focusSelectedElement(/* byKey */ true);
+ }
+
+ /**
+ * Focuses and moves keyboard selection to the last navigable element.
+ * This is a no-op if there are no navigable elements.
+ *
+ * @param {boolean} endKey - `true` if this is for the end key.
+ */
+ focusLastNavigableElement(endKey = false) {
+ // The end key is conceptually similar to the up/down arrow keys.
+ let walker = endKey ? this._arrowNavigableWalker : this._tabNavigableWalker;
+ walker.currentNode = walker.root;
+ this.selectedElement = walker.lastChild();
+ this.focusSelectedElement(/* byKey */ true);
+ }
+
+ /**
+ * Based on going up or down, select the previous or next focusable element.
+ *
+ * @param {boolean} isDown - whether we're going down (true) or up (false).
+ * @param {boolean} arrowKey - `true` if this is for the up/down arrow keys.
+ *
+ * @returns {DOMNode} the element we selected.
+ */
+ moveSelection(isDown, arrowKey = false) {
+ let walker = arrowKey
+ ? this._arrowNavigableWalker
+ : this._tabNavigableWalker;
+ let oldSel = this.selectedElement;
+ let newSel;
+ if (oldSel) {
+ walker.currentNode = oldSel;
+ newSel = isDown ? walker.nextNode() : walker.previousNode();
+ }
+ // If we couldn't find something, select the first or last item:
+ if (!newSel) {
+ walker.currentNode = walker.root;
+ newSel = isDown ? walker.firstChild() : walker.lastChild();
+ }
+ this.selectedElement = newSel;
+ return newSel;
+ }
+
+ /**
+ * Allow for navigating subview buttons using the arrow keys and the Enter key.
+ * The Up and Down keys can be used to navigate the list up and down and the
+ * Enter, Right or Left - depending on the text direction - key can be used to
+ * simulate a click on the currently selected button.
+ * The Right or Left key - depending on the text direction - can be used to
+ * navigate to the previous view, functioning as a shortcut for the view's
+ * back button.
+ * Thus, in LTR mode:
+ * - The Right key functions the same as the Enter key, simulating a click
+ * - The Left key triggers a navigation back to the previous view.
+ *
+ * Key navigation is only enabled while the view is active, meaning that this
+ * method will return early if it is invoked during a sliding transition.
+ *
+ * @param {KeyEvent} event
+ */
+ /* eslint-disable-next-line complexity */
+ keyNavigation(event) {
+ if (!this.active) {
+ return;
+ }
+
+ let focus = this.document.activeElement;
+ // Make sure the focus is actually inside the panel. (It might not be if
+ // the panel was opened with the mouse.) If it isn't, we don't care
+ // about it for our purposes.
+ // We use Node.compareDocumentPosition because Node.contains doesn't
+ // behave as expected for anonymous content; e.g. the input inside a
+ // textbox.
+ if (
+ focus &&
+ !(
+ this.node.compareDocumentPosition(focus) &
+ Node.DOCUMENT_POSITION_CONTAINED_BY
+ )
+ ) {
+ focus = null;
+ }
+
+ // Extension panels contain embedded documents. We can't manage
+ // keyboard navigation within those.
+ if (focus && focus.tagName == "browser") {
+ return;
+ }
+
+ let stop = () => {
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ // If the focused element is only navigable with tab, it wants the arrow
+ // keys, etc. We shouldn't handle any keys except tab and shift+tab.
+ // We make a function for this for performance reasons: we only want to
+ // check this for keys we potentially care about, not *all* keys.
+ let tabOnly = () => {
+ // We use the real focus rather than this.selectedElement because focus
+ // might have been moved without keyboard navigation (e.g. mouse click)
+ // and this.selectedElement is only updated for keyboard navigation.
+ return focus && this._isNavigableWithTabOnly(focus);
+ };
+
+ // If a context menu is open, we must let it handle all keys.
+ // Normally, this just happens, but because we have a capturing window
+ // keydown listener, our listener takes precedence.
+ // Again, we only want to do this check on demand for performance.
+ let isContextMenuOpen = () => {
+ if (!focus) {
+ return false;
+ }
+ let contextNode = focus.closest("[context]");
+ if (!contextNode) {
+ return false;
+ }
+ let context = contextNode.getAttribute("context");
+ let popup = this.document.getElementById(context);
+ return popup && popup.state == "open";
+ };
+
+ let keyCode = event.code;
+ switch (keyCode) {
+ case "ArrowDown":
+ case "ArrowUp":
+ if (tabOnly()) {
+ break;
+ }
+ // Fall-through...
+ case "Tab": {
+ if (isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ let isDown =
+ keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey);
+ let button = this.moveSelection(isDown, keyCode != "Tab");
+ Services.focus.setFocus(button, Services.focus.FLAG_BYKEY);
+ break;
+ }
+ case "Home":
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ this.focusFirstNavigableElement(true);
+ break;
+ case "End":
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ this.focusLastNavigableElement(true);
+ break;
+ case "ArrowLeft":
+ case "ArrowRight": {
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ if (
+ (!this.window.RTL_UI && keyCode == "ArrowLeft") ||
+ (this.window.RTL_UI && keyCode == "ArrowRight")
+ ) {
+ this.node.panelMultiView.goBack();
+ break;
+ }
+ // If the current button is _not_ one that points to a subview, pressing
+ // the arrow key shouldn't do anything.
+ let button = this.selectedElement;
+ if (!button || !button.classList.contains("subviewbutton-nav")) {
+ break;
+ }
+ }
+ // Fall-through...
+ case "Space":
+ case "NumpadEnter":
+ case "Enter": {
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ let button = this.selectedElement;
+ if (!button) {
+ break;
+ }
+ stop();
+
+ this._doingKeyboardActivation = true;
+ // Unfortunately, 'tabindex' doesn't execute the default action, so
+ // we explicitly do this here.
+ // We are sending a command event, a mousedown event and then a click
+ // event. This is done in order to mimic a "real" mouse click event.
+ // Normally, the command event executes the action, then the click event
+ // closes the menu. However, in some cases (e.g. the Library button),
+ // there is no command event handler and the mousedown event executes the
+ // action instead.
+ button.doCommand();
+ let dispEvent = new event.target.ownerGlobal.MouseEvent("mousedown", {
+ bubbles: true,
+ });
+ button.dispatchEvent(dispEvent);
+ dispEvent = new event.target.ownerGlobal.MouseEvent("click", {
+ bubbles: true,
+ });
+ button.dispatchEvent(dispEvent);
+ this._doingKeyboardActivation = false;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Focus the last selected element in the view, if any.
+ *
+ * @param byKey {Boolean} whether focus was moved by the user pressing a key.
+ * Needed to ensure we show focus styles in the right cases.
+ */
+ focusSelectedElement(byKey = false) {
+ let selected = this.selectedElement;
+ if (selected) {
+ let flag = byKey ? "FLAG_BYKEY" : "FLAG_BYELEMENTFOCUS";
+ Services.focus.setFocus(selected, Services.focus[flag]);
+ }
+ }
+
+ /**
+ * Clear all traces of keyboard navigation happening right now.
+ */
+ clearNavigation() {
+ let selected = this.selectedElement;
+ if (selected) {
+ selected.blur();
+ this.selectedElement = null;
+ }
+ }
+}
diff --git a/comm/mail/components/customizableui/content/customizeMode.inc.xhtml b/comm/mail/components/customizableui/content/customizeMode.inc.xhtml
new file mode 100644
index 0000000000..fc7eb0595b
--- /dev/null
+++ b/comm/mail/components/customizableui/content/customizeMode.inc.xhtml
@@ -0,0 +1,128 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<box id="customization-content-container">
+<box flex="1" id="customization-palette-container">
+ <label id="customization-header" data-l10n-id="customize-mode-menu-and-toolbars-header"></label>
+ <vbox id="customization-palette" class="customization-palette" hidden="true"/>
+ <vbox id="customization-pong-arena" hidden="true"/>
+ <spacer id="customization-spacer"/>
+</box>
+<vbox id="customization-panel-container">
+ <vbox id="customization-panelWrapper">
+ <box class="panel-arrowbox">
+ <image class="panel-arrow" side="top"/>
+ </box>
+ <box class="panel-arrowcontent" side="top" flex="1">
+ <vbox id="customization-panelHolder">
+ <description id="customization-panelHeader" data-l10n-id="customize-mode-overflow-list-title"></description>
+ <description id="customization-panelDescription" data-l10n-id="customize-mode-overflow-list-description"></description>
+ </vbox>
+ <box class="panel-inner-arrowcontentfooter" hidden="true"/>
+ </box>
+ </vbox>
+</vbox>
+</box>
+<hbox id="customization-footer">
+<checkbox id="customization-titlebar-visibility-checkbox" class="customizationmode-checkbox"
+# NB: because oncommand fires after click, by the time we've fired, the checkbox binding
+# will already have switched the button's state, so this is correct:
+ oncommand="gCustomizeMode.toggleTitlebar(this.checked)" data-l10n-id="customize-mode-titlebar"/>
+<checkbox id="customization-extra-drag-space-checkbox" class="customizationmode-checkbox"
+ data-l10n-id="customize-mode-extra-drag-space"
+ oncommand="gCustomizeMode.toggleDragSpace(this.checked)"/>
+<button id="customization-toolbar-visibility-button" class="customizationmode-button" type="menu" data-l10n-id="customize-mode-toolbars">
+ <menupopup id="customization-toolbar-menu" onpopupshowing="onViewToolbarsPopupShowing(event)"/>
+</button>
+<button id="customization-lwtheme-button" data-l10n-id="customize-mode-lwthemes" class="customizationmode-button" type="menu">
+ <panel type="arrow" id="customization-lwtheme-menu"
+ orient="vertical"
+ onpopupshowing="gCustomizeMode.onThemesMenuShowing(event);"
+ position="topcenter bottomleft"
+ flip="none"
+ role="menu">
+ <label id="customization-lwtheme-menu-header" data-l10n-id="customize-mode-lwthemes-my-themes"/>
+ <hbox id="customization-lwtheme-menu-footer">
+ <toolbarbutton class="customization-lwtheme-menu-footeritem"
+ data-l10n-id="customize-mode-lwthemes-menu-manage"
+ tabindex="0"
+ oncommand="gCustomizeMode.openAddonsManagerThemes(event);"/>
+ <toolbarbutton class="customization-lwtheme-menu-footeritem"
+ data-l10n-id="customize-mode-lwthemes-menu-get-more"
+ tabindex="0"
+ oncommand="gCustomizeMode.getMoreThemes(event);"/>
+ </hbox>
+ </panel>
+</button>
+<button id="customization-uidensity-button"
+ data-l10n-id="customize-mode-uidensity"
+ class="customizationmode-button"
+ type="menu">
+ <panel type="arrow" id="customization-uidensity-menu"
+ onpopupshowing="gCustomizeMode.onUIDensityMenuShowing();"
+ position="topcenter bottomleft"
+ flip="none"
+ role="menu">
+ <menuitem id="customization-uidensity-menuitem-compact"
+ class="menuitem-iconic customization-uidensity-menuitem"
+ role="menuitemradio"
+ data-l10n-id="customize-mode-uidensity-menu-compact"
+ tabindex="0"
+ onfocus="gCustomizeMode.updateUIDensity(this.mode);"
+ onmouseover="gCustomizeMode.updateUIDensity(this.mode);"
+ onblur="gCustomizeMode.resetUIDensity();"
+ onmouseout="gCustomizeMode.resetUIDensity();"
+ oncommand="gCustomizeMode.setUIDensity(this.mode);"/>
+ <menuitem id="customization-uidensity-menuitem-normal"
+ class="menuitem-iconic customization-uidensity-menuitem"
+ role="menuitemradio"
+ data-l10n-id="customize-mode-uidensity-menu-normal"
+ tabindex="0"
+ onfocus="gCustomizeMode.updateUIDensity(this.mode);"
+ onmouseover="gCustomizeMode.updateUIDensity(this.mode);"
+ onblur="gCustomizeMode.resetUIDensity();"
+ onmouseout="gCustomizeMode.resetUIDensity();"
+ oncommand="gCustomizeMode.setUIDensity(this.mode);"/>
+#ifndef XP_MACOSX
+ <menuitem id="customization-uidensity-menuitem-touch"
+ class="menuitem-iconic customization-uidensity-menuitem"
+ role="menuitemradio"
+ data-l10n-id="customize-mode-uidensity-menu-touch"
+ tabindex="0"
+ onfocus="gCustomizeMode.updateUIDensity(this.mode);"
+ onmouseover="gCustomizeMode.updateUIDensity(this.mode);"
+ onblur="gCustomizeMode.resetUIDensity();"
+ onmouseout="gCustomizeMode.resetUIDensity();"
+ oncommand="gCustomizeMode.setUIDensity(this.mode);">
+ </menuitem>
+ <spacer hidden="true" id="customization-uidensity-touch-spacer"/>
+ <checkbox id="customization-uidensity-autotouchmode-checkbox"
+ hidden="true"
+ data-l10n-id="customize-mode-uidensity-auto-touch-mode-checkbox"
+ oncommand="gCustomizeMode.updateAutoTouchMode(this.checked)"/>
+#endif
+ </panel>
+</button>
+
+<button id="whimsy-button"
+ type="checkbox"
+ class="customizationmode-button"
+ oncommand="gCustomizeMode.togglePong(this.checked);"
+ hidden="true"/>
+
+<spacer id="customization-footer-spacer"/>
+<button id="customization-undo-reset-button"
+ class="customizationmode-button"
+ hidden="true"
+ oncommand="gCustomizeMode.undoReset();"
+ data-l10n-id="customize-mode-undo-cmd"/>
+<button id="customization-reset-button"
+ oncommand="gCustomizeMode.reset();"
+ data-l10n-id="customize-mode-restore-defaults"
+ class="customizationmode-button"/>
+<button id="customization-done-button"
+ oncommand="gCustomizeMode.exit();"
+ data-l10n-id="customize-mode-done"
+ class="customizationmode-button"/>
+</hbox>
diff --git a/comm/mail/components/customizableui/content/jar.mn b/comm/mail/components/customizableui/content/jar.mn
new file mode 100644
index 0000000000..db1978fdb0
--- /dev/null
+++ b/comm/mail/components/customizableui/content/jar.mn
@@ -0,0 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/panelUI.js
diff --git a/comm/mail/components/customizableui/content/moz.build b/comm/mail/components/customizableui/content/moz.build
new file mode 100644
index 0000000000..d988c0ff9b
--- /dev/null
+++ b/comm/mail/components/customizableui/content/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/customizableui/content/panelUI.inc.xhtml b/comm/mail/components/customizableui/content/panelUI.inc.xhtml
new file mode 100644
index 0000000000..3b965da756
--- /dev/null
+++ b/comm/mail/components/customizableui/content/panelUI.inc.xhtml
@@ -0,0 +1,606 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<panel id="appMenu-popup"
+ class="cui-widget-panel panel-no-padding"
+ role="group"
+ type="arrow"
+ hidden="true"
+ flip="slide"
+ position="bottomright topright"
+ noautofocus="true">
+ <panelmultiview id="appMenu-multiView"
+ mainViewId="appMenu-mainView"
+ viewCacheId="appMenu-viewCache">
+
+ <!-- Main Appmenu View -->
+ <panelview id="appMenu-mainView" class="PanelUI-subView">
+ <vbox id="appMenu-mainViewItems"
+ class="panel-subview-body">
+ <vbox id="appMenu-addon-banners"/>
+ <toolbarbutton class="panel-banner-item"
+ oncommand="PanelUI._onBannerItemSelected(event)"
+ hidden="true"/>
+#ifdef NIGHTLY_BUILD
+ <toolbarbutton id="appmenu_signin"
+ data-l10n-id="appmenu-signin-panel"
+ class="subviewbutton subviewbutton-iconic"
+ hidden="true"
+ oncommand="gSync.initFxA();"/>
+ <toolbarbutton id="appmenu_sync"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ hidden="true"
+ align="center"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-syncView', this)">
+ <hbox flex="1">
+ <html:img id="appmenu-sync-icon"
+ class="toolbarbutton-icon"
+ alt=""/>
+ <vbox flex="1">
+ <label id="appmenu-sync-sync"
+ crop="end"
+ data-l10n-id="appmenu-sync-sync"/>
+ <label id="appmenu-sync-account"
+ class="appmenu-sync-account-email"
+ crop="end"
+ data-l10n-id="appmenu-sync-account"/>
+ </vbox>
+ </hbox>
+ </toolbarbutton>
+ <toolbarseparator id="syncSeparator" hidden="true"/>
+#endif
+ <toolbarbutton id="appmenu_new"
+ data-l10n-id="appmenu-new-account-panel"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-newView', this)"/>
+ <toolbarbutton id="appmenu_create"
+ data-l10n-id="appmenu-create-panel"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-createView', this)"/>
+ <toolbarseparator id="appmenu_createPopupMenuSeparator"/>
+ <toolbarbutton id="appmenu_open"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-open-file-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-openView', this)"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_View"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-view-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-viewView', this)"/>
+ <toolbaritem id="appMenu-uiDensity-controls"
+ class="subviewbutton subviewbutton-iconic toolbaritem-combined-buttons"
+ closemenu="none">
+ <html:img class="toolbarbutton-icon" src="" alt=""/>
+ <label class="toolbarbutton-text" data-l10n-id="appmenu-mail-uidensity-value"/>
+ <toolbarbutton id="appmenu_uiDensityCompact"
+ class="subviewbutton subviewbutton-iconic subviewbutton"
+ data-l10n-id="appmenu-uidensity-compact"
+ type="radio"
+ oncommand="PanelUI.setUIDensity(event);"/>
+ <toolbarbutton id="appmenu_uiDensityNormal"
+ class="subviewbutton subviewbutton-iconic subviewbutton"
+ data-l10n-id="appmenu-uidensity-default"
+ type="radio"
+ oncommand="PanelUI.setUIDensity(event);"/>
+ <toolbarbutton id="appmenu_uiDensityTouch"
+ class="subviewbutton subviewbutton-iconic subviewbutton"
+ data-l10n-id="appmenu-uidensity-relaxed"
+ type="radio"
+ oncommand="PanelUI.setUIDensity(event);"/>
+ </toolbaritem>
+ <toolbaritem id="appMenu-fontSize-controls"
+ class="subviewbutton subviewbutton-iconic toolbaritem-combined-buttons"
+ closemenu="none">
+ <html:img class="toolbarbutton-icon" src="" alt=""/>
+ <label class="toolbarbutton-text" data-l10n-id="appmenu-font-size-value"/>
+ <toolbarbutton id="appMenu-fontSizeReduce-button"
+ class="subviewbutton subviewbutton-iconic"
+ oncommand="UIFontSize.reduceSize();"
+ data-l10n-id="appmenuitem-font-size-reduce"/>
+ <toolbarbutton id="appMenu-fontSizeReset-button"
+ class="subviewbutton"
+ oncommand="UIFontSize.resetSize();"
+ tooltip="fontSizeReset"/>
+ <toolbarbutton id="appMenu-fontSizeEnlarge-button"
+ class="subviewbutton subviewbutton-iconic"
+ oncommand="UIFontSize.increaseSize();"
+ data-l10n-id="appmenuitem-font-size-enlarge"/>
+ </toolbaritem>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_preferences"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-settings"
+ oncommand="openOptionsDialog();"/>
+ <toolbarbutton id="appmenu_accountmgr"
+ class="subviewbutton subviewbutton-iconic"
+ label="&accountManagerCmd2.label;"
+ oncommand="MsgAccountManager(null);"/>
+ <toolbarbutton id="appmenu_addons"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-addons-and-themes"
+ oncommand="openAddonsMgr();"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_toolsMenu"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-tools-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-toolsView', this)"/>
+ <toolbarbutton id="appmenu_help"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="menu-help-help-title"
+ closemenu="none"
+ oncommand="buildHelpMenu(); PanelUI.showSubView('appMenu-helpView', this)"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu-quit"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="menu-quit"
+ key="key_quitApplication"
+ command="cmd_quitApplication"/>
+ </vbox>
+ </panelview>
+#ifdef NIGHTLY_BUILD
+ <!-- Sync -->
+ <panelview id="appMenu-syncView"
+ data-l10n-id="appmenu-sync-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-syncViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_manageSyncAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ align="center"
+ oncommand="gSync.openFxAManagePage();">
+ <hbox flex="1">
+ <html:img id="appmenu-manage-sync-icon"
+ class="toolbarbutton-icon"
+ alt=""/>
+ <vbox flex="1">
+ <label id="appmenu-sync-menu-manage"
+ crop="end"
+ data-l10n-id="appmenu-sync-manage"/>
+ <label id="appmenu-sync-menu-account"
+ class="appmenu-sync-account-email"
+ crop="end"
+ data-l10n-id="appmenu-sync-account"/>
+ </vbox>
+ </hbox>
+ </toolbarbutton>
+
+ <toolbarbutton id="appmenu-submenu-sync-now"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-sync-now"
+ closemenu="none"
+ oncommand="Weave.Service.sync({});"/>
+ <toolbarbutton id="appmenu-submenu-sync-settings"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-sync-settings"
+ oncommand="openPreferencesTab('sync');"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu-submenu-sync-sign-out"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-sync-sign-out"
+ oncommand="gSync.disconnect({ confirm: true });"/>
+ </vbox>
+ </panelview>
+#endif
+ <!-- New -->
+ <panelview id="appMenu-newView"
+ data-l10n-id="appmenu-new-account-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-newViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_newCreateEmailAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-create-new-mail-account"
+ oncommand="openAccountProvisionerTab();"/>
+ <toolbarbutton id="appmenu_newMailAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-mail-account"
+ oncommand="openAccountSetupTab();"/>
+#ifdef MAIN_WINDOW
+ <toolbarbutton id="appmenu_calendar-new-calendar-menu-item"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-calendar"
+ command="calendar_new_calendar_command"/>
+#endif
+ <toolbarbutton id="appmenu_newAB"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ data-l10n-id="appmenu-newab-panel"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-newabView', this)"/>
+ <toolbarbutton id="appmenu_newIMAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-chat-account"
+ oncommand="openIMAccountWizard();"/>
+ <toolbarbutton id="appmenu_newFeedAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-feed"
+ oncommand="AddFeedAccount();"/>
+ <toolbarbutton id="appmenu_newNewsgroupAccountMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-newsgroup"
+ oncommand="openNewsgroupAccountWizard();"/>
+ </vbox>
+ </panelview>
+
+ <!-- New AB -->
+ <panelview id="appMenu-newabView"
+ data-l10n-id="appmenu-newab-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-newABItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_newABMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-addressbook"
+ oncommand="openNewABDialog();"/>
+ <toolbarbutton id="appmenu_newCardDAVMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-carddav"
+ oncommand="openNewABDialog('CARDDAV');"/>
+ <toolbarbutton id="appmenu_newLdapMenuItem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-new-ldap"
+ oncommand="openNewABDialog('LDAP');"/>
+ </vbox>
+ </panelview>
+
+ <!-- Create -->
+ <panelview id="appMenu-createView"
+ data-l10n-id="appmenu-create-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-createViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_newNewMsgCmd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-create-message"
+ key = "key_newMessage2"
+ command="cmd_newMessage"/>
+#ifdef MAIN_WINDOW
+ <toolbarbutton id="appmenu_calendar-new-event-menu-item"
+ class="subviewbutton subviewbutton-iconic hide-when-calendar-deactivated"
+ data-l10n-id="appmenu-create-event"
+ command="calendar_new_event_command"/>
+ <toolbarbutton id="appmenu_calendar-new-task-menu-item"
+ class="subviewbutton subviewbutton-iconic hide-when-calendar-deactivated"
+ data-l10n-id="appmenu-create-task"
+ command="calendar_new_todo_command"/>
+#endif
+ <toolbarbutton id="appmenu_newCard"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-create-contact"
+ command="cmd_newCard"/>
+ </vbox>
+ </panelview>
+
+ <!-- Open -->
+ <panelview id="appMenu-openView"
+ data-l10n-id="appmenu-open-file-panel-title"
+ class="PanelUI-subView">
+ <vbox id="appMenu-openViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_OpenMessageFileMenuitem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-open-message"
+ oncommand="MsgOpenFromFile();"/>
+ <toolbarbutton id="appmenu_OpenCalendarFileMenuitem"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-open-calendar"
+ oncommand="openLocalCalendar();"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Toolbars -->
+ <panelview id="appMenu-toolbarsView"
+ title="&viewToolbarsMenu.label;"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+#ifdef MAIN_WINDOW
+ <toolbarbutton id="appmenu_quickFilterBar"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-toggle"
+ command="cmd_toggleQuickFilterBar"/>
+ <toolbarbutton id="appmenu_spacesToolbar"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ data-l10n-id="menu-spaces-toolbar-button"
+ closemenu="none"
+ oncommand="gSpacesToolbar.toggleToolbarFromMenu();"/>
+#endif
+ <toolbarbutton id="appmenu_showStatusbar"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ label="&showTaskbarCmd.label;"
+ oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')"
+ closemenu="none"
+ checked="true"
+ observes="menu_showTaskbar"/>
+ <toolbarseparator id="appmenu_toggleToolbarsSeparator"/>
+ <toolbarbutton id="appmenu_toolbarLayout"
+ class="subviewbutton subviewbutton-iconic"
+ label="&appmenuToolbarLayout.label;"
+ command="cmd_CustomizeMailToolbar"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Layout -->
+ <panelview id="appMenu-preferencesLayoutView"
+ title="&messagePaneLayoutStyle.label;"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_messagePaneClassic"
+ class="subviewbutton subviewbutton-iconic"
+ type="radio"
+ label="&messagePaneClassic.label;"
+ name="viewlayoutgroup"
+ command="cmd_viewClassicMailLayout"/>
+ <toolbarbutton id="appmenu_messagePaneWide"
+ class="subviewbutton subviewbutton-iconic"
+ type="radio"
+ label="&messagePaneWide.label;"
+ name="viewlayoutgroup"
+ command="cmd_viewWideMailLayout"/>
+ <toolbarbutton id="appmenu_messagePaneVertical"
+ class="subviewbutton subviewbutton-iconic"
+ type="radio"
+ label="&messagePaneVertical.label;"
+ name="viewlayoutgroup"
+ command="cmd_viewVerticalMailLayout"/>
+ <toolbarseparator id="appmenu_viewMenuAfterPaneVerticalSeparator"/>
+ <toolbarbutton id="appmenu_showFolderPane"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ closemenu="none"
+ label="&showFolderPaneCmd.label;"
+ command="cmd_toggleFolderPane"/>
+ <toolbarbutton id="appmenu_toggleThreadPaneHeader"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ name="threadheader"
+ closemenu="none"
+ data-l10n-id="appmenuitem-toggle-thread-pane-header"
+ command="cmd_toggleThreadPaneHeader"/>
+ <toolbarbutton id="appmenu_showMessage"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ closemenu="none"
+ label="&showMessageCmd.label;"
+ key="key_toggleMessagePane"
+ command="cmd_toggleMessagePane"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_calShowTodayPane-2"
+ class="subviewbutton subviewbutton-iconic"
+ label="&todaypane.showTodayPane.label;"
+ type="checkbox"
+ command="calendar_toggle_todaypane_command"/>
+ </vbox>
+ </panelview>
+
+ <!-- View -->
+ <panelview id="appMenu-viewView"
+ class="PanelUI-subView"
+ data-l10n-id="appmenu-view-panel-title">
+ <vbox id="appMenu-viewViewItems"
+ class="panel-subview-body">
+ <toolbarbutton id="appmenu_Toolbars"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ label="&viewToolbarsMenu.label;"
+ accesskey="&viewToolbarsMenu.accesskey;"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-toolbarsView', this)"/>
+ <toolbarbutton id="appmenu_MessagePaneLayout"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ label="&messagePaneLayoutStyle.label;"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-preferencesLayoutView', this)"/>
+ <toolbarbutton id="appmenu_FolderViews"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ label="&folderView.label;"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-foldersView', this)"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Folders -->
+ <panelview id="appMenu-foldersView"
+ title="&folderView.label;"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_toggleFolderHeader"
+ class="subviewbutton subviewbutton-iconic"
+ name="paneheader"
+ value="toggle-header"
+ data-l10n-id="menu-view-folders-toggle-header"
+ type="checkbox"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarseparator id="appmenu_folderModesSeparator"/>
+ <toolbarbutton id="appmenu_allFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="all"
+ data-l10n-id="show-all-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_smartFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="smart"
+ data-l10n-id="show-smart-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_unreadFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="unread"
+ data-l10n-id="show-unread-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_favoriteFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="favorite"
+ data-l10n-id="show-favorite-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarbutton id="appmenu_recentFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="recent"
+ data-l10n-id="show-recent-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_tagsFolders"
+ class="subviewbutton subviewbutton-iconic"
+ value="tags"
+ data-l10n-id="show-tags-folders-label"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <toolbarseparator id="appmenu_compactPropertiesSeparator"/>
+ <toolbarbutton id="appmenu_compactMode"
+ class="subviewbutton subviewbutton-iconic"
+ value="compact"
+ data-l10n-id="folder-toolbar-toggle-folder-compact-view"
+ type="checkbox"
+ name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderCompactMenuOnCommand(event)"/>
+ <toolbarseparator id="appmenu_favoritePropertiesSeparator"/>
+ <toolbarbutton id="appmenu_favoriteFolder"
+ class="subviewbutton subviewbutton-iconic"
+ type="checkbox"
+ label="&menuFavoriteFolder.label;"
+ checked="false"
+ command="cmd_toggleFavoriteFolder"/>
+ <toolbarbutton id="appmenu_properties"
+ class="subviewbutton subviewbutton-iconic"
+ command="cmd_properties"/>
+ </vbox>
+ </panelview>
+
+ <!-- View / Messages / Tags -->
+ <!-- Dynamically populated when shown. -->
+ <panelview id="appMenu-viewMessagesTagsView"
+ title="&viewTags.label;"
+ class="PanelUI-subView"
+ oncommand="ViewChangeByMenuitem(event.target);">
+ <vbox class="panel-subview-body"/>
+ </panelview>
+
+ <!-- View / Messages / Custom Views -->
+ <!-- Dynamically populated when shown. -->
+ <panelview id="appMenu-viewMessagesCustomViewsView"
+ title="&viewCustomViews.label;"
+ class="PanelUI-subView"
+ oncommand="ViewChangeByMenuitem(event.target);">
+ <vbox class="panel-subview-body"/>
+ </panelview>
+
+ <!-- Tools -->
+ <panelview id="appMenu-toolsView"
+ data-l10n-id="appmenu-tools-panel-title"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_import"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-import"
+ oncommand="toImport();"/>
+ <toolbarbutton id="appmenu_export"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-export"
+ oncommand="toExport();"/>
+ <toolbarseparator id="importExportSeparator"/>
+ <toolbarbutton id="appmenu_searchCmd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-message-search"
+ key="key_searchMail"
+ command="cmd_searchMessages"/>
+ <toolbarbutton id="appmenu_filtersCmd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-message-filters"
+ oncommand="MsgFilters();"/>
+ <toolbarbutton id="appmenu_manageKeysOpenPGP"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="openpgp-manage-keys-openpgp-cmd"
+ oncommand="openKeyManager()"/>
+ <toolbarbutton id="appmenu_openSavedFilesWnd"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-download-manager"
+ key="key_savedFiles"
+ oncommand="openSavedFilesWnd();"/>
+ <toolbarbutton id="appmenu_activityManager"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-activity-manager"
+ oncommand="openActivityMgr();"/>
+ <toolbarseparator id="devToolsSeparator"/>
+ <toolbarbutton id="appmenu_devtoolsToolbox"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-tools-dev-tools"
+ key="key_devtoolsToolbox"
+ oncommand="BrowserToolboxLauncher.init();"/>
+ </vbox>
+ </panelview>
+
+ <!-- Help -->
+ <panelview id="appMenu-helpView"
+ data-l10n-id="appmenu-help-panel-title"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu_openHelp"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-get-help"
+ key="key_openHelp"
+ oncommand="openSupportURL();"/>
+ <toolbarbutton id="appmenu_openTour"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-explore-features"
+ oncommand="openLinkText(event, 'tourURL');"/>
+ <toolbarbutton id="appmenu_keyboardShortcuts"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-shortcuts"
+ oncommand="openLinkText(event, 'keyboardShortcutsURL');"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_getInvolved"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-get-involved"
+ oncommand="openLinkText(event, 'getInvolvedURL');"/>
+ <toolbarbutton id="appmenu_makeDonation"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-donation"
+ oncommand="openLinkText(event, 'donateURL');"/>
+ <toolbarbutton id="appmenu_submitFeedback"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-share-feedback"
+ oncommand="openLinkText(event, 'feedbackURL');"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_troubleshootMode"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-enter-troubleshoot-mode2"
+ oncommand="safeModeRestart();"/>
+ <toolbarbutton id="appmenu_troubleshootingInfo"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-troubleshooting-info"
+ oncommand="openAboutSupport();"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appmenu_about"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenu-help-about-product"
+ oncommand="openAboutDialog();"/>
+ </vbox>
+ </panelview>
+ </panelmultiview>
+</panel>
diff --git a/comm/mail/components/customizableui/content/panelUI.js b/comm/mail/components/customizableui/content/panelUI.js
new file mode 100644
index 0000000000..bad418abb4
--- /dev/null
+++ b/comm/mail/components/customizableui/content/panelUI.js
@@ -0,0 +1,882 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../base/content/globalOverlay.js */
+/* import-globals-from ../../../base/content/mailCore.js */
+/* import-globals-from ../../../base/content/mailWindowOverlay.js */
+/* import-globals-from ../../../base/content/messenger.js */
+/* import-globals-from ../../../extensions/mailviews/content/msgViewPickerOverlay.js */
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ShortcutUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ShortcutUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionsUI",
+ "resource:///modules/ExtensionsUI.jsm"
+);
+
+/**
+ * Maintains the state and dispatches events for the main menu panel.
+ */
+const PanelUI = {
+ /** Panel events that we listen for. */
+ get kEvents() {
+ return [
+ "popupshowing",
+ "popupshown",
+ "popuphiding",
+ "popuphidden",
+ "ViewShowing",
+ ];
+ },
+ /**
+ * Used for lazily getting and memoizing elements from the document. Lazy
+ * getters are set in init, and memoizing happens after the first retrieval.
+ */
+ get kElements() {
+ return {
+ mainView: "appMenu-mainView",
+ multiView: "appMenu-multiView",
+ menuButton: "button-appmenu",
+ panel: "appMenu-popup",
+ addonNotificationContainer: "appMenu-addon-banners",
+ navbar: "mail-bar3",
+ };
+ },
+
+ kAppMenuButtons: new Set(),
+
+ _initialized: false,
+ _notifications: null,
+
+ init() {
+ this._initElements();
+ this.initAppMenuButton("button-appmenu", "mail-toolbox");
+
+ this.menuButton = this.menuButtonMail;
+
+ Services.obs.addObserver(this, "fullscreen-nav-toolbox");
+ Services.obs.addObserver(this, "appMenu-notifications");
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "autoHideToolbarInFullScreen",
+ "browser.fullscreen.autohide",
+ false,
+ (pref, previousValue, newValue) => {
+ // On OSX, or with autohide preffed off, MozDOMFullscreen is the only
+ // event we care about, since fullscreen should behave just like non
+ // fullscreen. Otherwise, we don't want to listen to these because
+ // we'd just be spamming ourselves with both of them whenever a user
+ // opened a video.
+ if (newValue) {
+ window.removeEventListener("MozDOMFullscreen:Entered", this);
+ window.removeEventListener("MozDOMFullscreen:Exited", this);
+ window.addEventListener("fullscreen", this);
+ } else {
+ window.addEventListener("MozDOMFullscreen:Entered", this);
+ window.addEventListener("MozDOMFullscreen:Exited", this);
+ window.removeEventListener("fullscreen", this);
+ }
+
+ this._updateNotifications(false);
+ },
+ autoHidePref => autoHidePref && Services.appinfo.OS !== "Darwin"
+ );
+
+ if (this.autoHideToolbarInFullScreen) {
+ window.addEventListener("fullscreen", this);
+ } else {
+ window.addEventListener("MozDOMFullscreen:Entered", this);
+ window.addEventListener("MozDOMFullscreen:Exited", this);
+ }
+
+ window.addEventListener("activate", this);
+
+ Services.obs.notifyObservers(
+ null,
+ "appMenu-notifications-request",
+ "refresh"
+ );
+
+ this._initialized = true;
+ },
+
+ _initElements() {
+ for (let [k, v] of Object.entries(this.kElements)) {
+ // Need to do fresh let-bindings per iteration
+ let getKey = k;
+ let id = v;
+ this.__defineGetter__(getKey, function () {
+ delete this[getKey];
+ // eslint-disable-next-line consistent-return
+ return (this[getKey] = document.getElementById(id));
+ });
+ }
+ },
+
+ initAppMenuButton(id, toolboxId) {
+ let button = document.getElementById(id);
+ if (!button) {
+ // If not in the document, the button should be in the toolbox palette,
+ // which isn't part of the document.
+ let toolbox = document.getElementById(toolboxId);
+ if (toolbox) {
+ button = toolbox.palette.querySelector(`#${id}`);
+ }
+ }
+
+ if (button) {
+ button.addEventListener("mousedown", PanelUI);
+ button.addEventListener("keypress", PanelUI);
+
+ this.kAppMenuButtons.add(button);
+ }
+ },
+
+ _eventListenersAdded: false,
+ _ensureEventListenersAdded() {
+ if (this._eventListenersAdded) {
+ return;
+ }
+ this._addEventListeners();
+ },
+
+ _addEventListeners() {
+ for (let event of this.kEvents) {
+ this.panel.addEventListener(event, this);
+ }
+ this._eventListenersAdded = true;
+ },
+
+ _removeEventListeners() {
+ for (let event of this.kEvents) {
+ this.panel.removeEventListener(event, this);
+ }
+ this._eventListenersAdded = false;
+ },
+
+ uninit() {
+ this._removeEventListeners();
+
+ Services.obs.removeObserver(this, "fullscreen-nav-toolbox");
+ Services.obs.removeObserver(this, "appMenu-notifications");
+
+ window.removeEventListener("MozDOMFullscreen:Entered", this);
+ window.removeEventListener("MozDOMFullscreen:Exited", this);
+ window.removeEventListener("fullscreen", this);
+ window.removeEventListener("activate", this);
+
+ [this.menuButtonMail, this.menuButtonChat].forEach(button => {
+ // There's no chat button in the messageWindow.xhtml context.
+ if (button) {
+ button.removeEventListener("mousedown", this);
+ button.removeEventListener("keypress", this);
+ }
+ });
+ },
+
+ /**
+ * Opens the menu panel if it's closed, or closes it if it's open.
+ *
+ * @param event the event that triggers the toggle.
+ */
+ toggle(event) {
+ // Don't show the panel if the window is in customization mode,
+ // since this button doubles as an exit path for the user in this case.
+ if (document.documentElement.hasAttribute("customizing")) {
+ return;
+ }
+
+ // Since we have several menu buttons, make sure the current one is used.
+ // This works for now, but in the long run, if we're showing badges etc.
+ // then the current menuButton needs to be set when the app's view/tab
+ // changes, not just when the menu is toggled.
+ this.menuButton = event.target;
+
+ this._ensureEventListenersAdded();
+ if (this.panel.state == "open") {
+ this.hide();
+ } else if (this.panel.state == "closed") {
+ this.show(event);
+ }
+ },
+
+ /**
+ * Opens the menu panel. If the event target has a child with the
+ * toolbarbutton-icon attribute, the panel will be anchored on that child.
+ * Otherwise, the panel is anchored on the event target itself.
+ *
+ * @param aEvent the event (if any) that triggers showing the menu.
+ */
+ show(aEvent) {
+ this._ensureShortcutsShown();
+ (async () => {
+ await this.ensureReady();
+
+ if (
+ this.panel.state == "open" ||
+ document.documentElement.hasAttribute("customizing")
+ ) {
+ return;
+ }
+
+ let domEvent = null;
+ if (aEvent && aEvent.type != "command") {
+ domEvent = aEvent;
+ }
+
+ // We try to use the event.target to account for clicks triggered
+ // from the #button-chat-appmenu. In case the opening of the menu isn't
+ // triggered by a click event, fallback to the main menu button as anchor.
+ let anchor = this._getPanelAnchor(
+ aEvent ? aEvent.target : this.menuButton
+ );
+ await PanelMultiView.openPopup(this.panel, anchor, {
+ triggerEvent: domEvent,
+ });
+ })().catch(console.error);
+ },
+
+ /**
+ * If the menu panel is being shown, hide it.
+ */
+ hide() {
+ if (document.documentElement.hasAttribute("customizing")) {
+ return;
+ }
+
+ PanelMultiView.hidePopup(this.panel);
+ },
+
+ observe(subject, topic, status) {
+ switch (topic) {
+ case "fullscreen-nav-toolbox":
+ if (this._notifications) {
+ this._updateNotifications(false);
+ }
+ break;
+ case "appMenu-notifications":
+ // Don't initialize twice.
+ if (status == "init" && this._notifications) {
+ break;
+ }
+ this._notifications = AppMenuNotifications.notifications;
+ this._updateNotifications(true);
+ break;
+ }
+ },
+
+ handleEvent(event) {
+ // Ignore context menus and menu button menus showing and hiding:
+ if (event.type.startsWith("popup") && event.target != this.panel) {
+ return;
+ }
+ switch (event.type) {
+ case "popupshowing":
+ initAppMenuPopup();
+ // Fall through
+ case "popupshown":
+ if (event.type == "popupshown") {
+ CustomizableUI.addPanelCloseListeners(this.panel);
+ }
+ // Fall through
+ case "popuphiding":
+ // Fall through
+ case "popuphidden":
+ this._updateNotifications();
+ this._updatePanelButton(event.target);
+ if (event.type == "popuphidden") {
+ CustomizableUI.removePanelCloseListeners(this.panel);
+ }
+ break;
+ case "mousedown":
+ if (event.button == 0) {
+ this.toggle(event);
+ }
+ break;
+ case "keypress":
+ if (event.key == " " || event.key == "Enter") {
+ this.toggle(event);
+ event.stopPropagation();
+ }
+ break;
+ case "MozDOMFullscreen:Entered":
+ case "MozDOMFullscreen:Exited":
+ case "fullscreen":
+ case "activate":
+ this._updateNotifications();
+ break;
+ case "ViewShowing":
+ PanelUI._handleViewShowingEvent(event);
+ break;
+ }
+ },
+
+ /**
+ * When a ViewShowing event happens when a <panelview> element is shown,
+ * do any required set up for that particular view.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ */
+ _handleViewShowingEvent(event) {
+ // Typically event.target for "ViewShowing" is a <panelview> element.
+ PanelUI._ensureShortcutsShown(event.target);
+
+ switch (event.target.id) {
+ case "appMenu-foldersView":
+ this._onFoldersViewShow(event);
+ break;
+ case "appMenu-addonsView":
+ initAddonPrefsMenu(
+ event.target.querySelector(".panel-subview-body"),
+ "toolbarbutton",
+ "subviewbutton subviewbutton-iconic",
+ "subviewbutton subviewbutton-iconic"
+ );
+ break;
+ case "appMenu-toolbarsView":
+ onViewToolbarsPopupShowing(
+ event,
+ "mail-toolbox",
+ document.getElementById("appmenu_quickFilterBar"),
+ "toolbarbutton",
+ "subviewbutton subviewbutton-iconic",
+ true
+ );
+ break;
+ case "appMenu-preferencesLayoutView":
+ PanelUI._onPreferencesLayoutViewShow(event);
+ break;
+ // View
+ case "appMenu-viewMessagesTagsView":
+ PanelUI._refreshDynamicView(event, RefreshTagsPopup);
+ break;
+ case "appMenu-viewMessagesCustomViewsView":
+ PanelUI._refreshDynamicView(event, RefreshCustomViewsPopup);
+ break;
+ }
+ },
+
+ /**
+ * Refreshes some views that are dynamically populated. Typically called by
+ * event listeners responding to a ViewShowing event. It calls a given refresh
+ * function (that populates the view), passing appmenu-specific arguments.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ * @param {Function} refreshFunction - Function that refreshes a particular view.
+ */
+ _refreshDynamicView(event, refreshFunction) {
+ refreshFunction(
+ event.target.querySelector(".panel-subview-body"),
+ "toolbarbutton",
+ "subviewbutton subviewbutton-iconic",
+ "toolbarseparator"
+ );
+ },
+
+ get isReady() {
+ return !!this._isReady;
+ },
+
+ /**
+ * Registering the menu panel is done lazily for performance reasons. This
+ * method is exposed so that CustomizationMode can force panel-readyness in the
+ * event that customization mode is started before the panel has been opened
+ * by the user.
+ *
+ * @param aCustomizing (optional) set to true if this was called while entering
+ * customization mode. If that's the case, we trust that customization
+ * mode will handle calling beginBatchUpdate and endBatchUpdate.
+ *
+ * @returns a Promise that resolves once the panel is ready to roll.
+ */
+ async ensureReady() {
+ if (this._isReady) {
+ return;
+ }
+
+ await window.delayedStartupPromise;
+ this._ensureEventListenersAdded();
+ this.panel.hidden = false;
+ this._isReady = true;
+ },
+
+ /**
+ * Shows a subview in the panel with a given ID.
+ *
+ * @param aViewId the ID of the subview to show.
+ * @param aAnchor the element that spawned the subview.
+ */
+ async showSubView(aViewId, aAnchor) {
+ this._ensureEventListenersAdded();
+ let viewNode = document.getElementById(aViewId);
+ if (!viewNode) {
+ console.error("Could not show panel subview with id: " + aViewId);
+ return;
+ }
+
+ if (!aAnchor) {
+ console.error(
+ "Expected an anchor when opening subview with id: " + aViewId
+ );
+ return;
+ }
+
+ let container = aAnchor.closest("panelmultiview");
+ if (container) {
+ container.showSubView(aViewId, aAnchor);
+ }
+ },
+
+ /**
+ * NB: The enable- and disableSingleSubviewPanelAnimations methods only
+ * affect the hiding/showing animations of single-subview panels (tempPanel
+ * in the showSubView method).
+ */
+ disableSingleSubviewPanelAnimations() {
+ this._disableAnimations = true;
+ },
+
+ enableSingleSubviewPanelAnimations() {
+ this._disableAnimations = false;
+ },
+
+ /**
+ * Sets the anchor node into the open or closed state, depending
+ * on the state of the panel.
+ */
+ _updatePanelButton() {
+ this.menuButton.open =
+ this.panel.state == "open" || this.panel.state == "showing";
+ },
+
+ /**
+ * Event handler for showing the Preferences/Layout view. Removes "checked"
+ * from all layout menu items and then checks the current layout menu item.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ */
+ _onPreferencesLayoutViewShow(event) {
+ event.target
+ .querySelectorAll("[name='viewlayoutgroup']")
+ .forEach(item => item.removeAttribute("checked"));
+
+ InitViewLayoutStyleMenu(event, true);
+ },
+
+ /**
+ * Event listener for showing the Folders view.
+ *
+ * @param {ViewShowingEvent} event - ViewShowing event.
+ */
+ _onFoldersViewShow(event) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let folder = about3Pane.gFolder;
+
+ const paneHeaderMenuitem = event.target.querySelector(
+ '[name="paneheader"]'
+ );
+ if (about3Pane.folderPane.isFolderPaneHeaderHidden()) {
+ paneHeaderMenuitem.removeAttribute("checked");
+ } else {
+ paneHeaderMenuitem.setAttribute("checked", "true");
+ }
+
+ let { activeModes, canBeCompact, isCompact } = about3Pane.folderPane;
+ if (isCompact) {
+ activeModes.push("compact");
+ }
+
+ for (let item of event.target.querySelectorAll('[name="viewmessages"]')) {
+ let mode = item.getAttribute("value");
+ if (activeModes.includes(mode)) {
+ item.setAttribute("checked", "true");
+ if (mode == "all") {
+ item.disabled = activeModes.length == 1;
+ }
+ } else {
+ item.removeAttribute("checked");
+ }
+ if (mode == "compact") {
+ item.disabled = !canBeCompact;
+ }
+ }
+
+ goUpdateCommand("cmd_properties");
+ let propertiesMenuItem = document.getElementById("appmenu_properties");
+ if (folder?.server.type == "nntp") {
+ document.l10n.setAttributes(
+ propertiesMenuItem,
+ "menu-edit-newsgroup-properties"
+ );
+ } else {
+ document.l10n.setAttributes(
+ propertiesMenuItem,
+ "menu-edit-folder-properties"
+ );
+ }
+
+ let favoriteFolderMenu = document.getElementById("appmenu_favoriteFolder");
+ if (folder?.getFlag(Ci.nsMsgFolderFlags.Favorite)) {
+ favoriteFolderMenu.setAttribute("checked", "true");
+ } else {
+ favoriteFolderMenu.removeAttribute("checked");
+ }
+ },
+
+ _onToolsMenuShown(event) {
+ let noAccounts = MailServices.accounts.accounts.length == 0;
+ event.target.querySelector("#appmenu_searchCmd").disabled = noAccounts;
+ event.target.querySelector("#appmenu_filtersCmd").disabled = noAccounts;
+ },
+
+ _updateNotifications(notificationsChanged) {
+ let notifications = this._notifications;
+ if (!notifications || !notifications.length) {
+ if (notificationsChanged) {
+ this._clearAllNotifications();
+ }
+ return;
+ }
+
+ let doorhangers = notifications.filter(
+ n => !n.dismissed && !n.options.badgeOnly
+ );
+
+ if (this.panel.state == "showing" || this.panel.state == "open") {
+ // If the menu is already showing, then we need to dismiss all notifications
+ // since we don't want their doorhangers competing for attention
+ doorhangers.forEach(n => {
+ n.dismissed = true;
+ if (n.options.onDismissed) {
+ n.options.onDismissed(window);
+ }
+ });
+ this._clearBadge();
+ if (!notifications[0].options.badgeOnly) {
+ this._showBannerItem(notifications[0]);
+ }
+ } else if (doorhangers.length > 0) {
+ // Only show the doorhanger if the window is focused and not fullscreen
+ if (
+ (window.fullScreen && this.autoHideToolbarInFullScreen) ||
+ Services.focus.activeWindow !== window
+ ) {
+ this._showBadge(doorhangers[0]);
+ this._showBannerItem(doorhangers[0]);
+ } else {
+ this._clearBadge();
+ }
+ } else {
+ this._showBadge(notifications[0]);
+ this._showBannerItem(notifications[0]);
+ }
+ },
+
+ _clearAllNotifications() {
+ this._clearBadge();
+ this._clearBannerItem();
+ },
+
+ _formatDescriptionMessage(n) {
+ let text = {};
+ let array = n.options.message.split("<>");
+ text.start = array[0] || "";
+ text.name = n.options.name || "";
+ text.end = array[1] || "";
+ return text;
+ },
+
+ _showBadge(notification) {
+ let badgeStatus = this._getBadgeStatus(notification);
+ for (let menuButton of this.kAppMenuButtons) {
+ menuButton.setAttribute("badge-status", badgeStatus);
+ }
+ },
+
+ // "Banner item" here refers to an item in the hamburger panel menu. They will
+ // typically show up as a colored row in the panel.
+ _showBannerItem(notification) {
+ const supportedIds = [
+ "update-downloading",
+ "update-available",
+ "update-manual",
+ "update-unsupported",
+ "update-restart",
+ ];
+ if (!supportedIds.includes(notification.id)) {
+ return;
+ }
+
+ if (!this._panelBannerItem) {
+ this._panelBannerItem = this.mainView.querySelector(".panel-banner-item");
+ }
+
+ let l10nId = "appmenuitem-banner-" + notification.id;
+ document.l10n.setAttributes(this._panelBannerItem, l10nId);
+
+ this._panelBannerItem.setAttribute("notificationid", notification.id);
+ this._panelBannerItem.hidden = false;
+ this._panelBannerItem.notification = notification;
+ },
+
+ _clearBadge() {
+ for (let menuButton of this.kAppMenuButtons) {
+ menuButton.removeAttribute("badge-status");
+ }
+ },
+
+ _clearBannerItem() {
+ if (this._panelBannerItem) {
+ this._panelBannerItem.notification = null;
+ this._panelBannerItem.hidden = true;
+ }
+ },
+
+ _onNotificationButtonEvent(event, type) {
+ let notificationEl = getNotificationFromElement(event.target);
+
+ if (!notificationEl) {
+ throw new Error(
+ "PanelUI._onNotificationButtonEvent: couldn't find notification element"
+ );
+ }
+
+ if (!notificationEl.notification) {
+ throw new Error(
+ "PanelUI._onNotificationButtonEvent: couldn't find notification"
+ );
+ }
+
+ let notification = notificationEl.notification;
+
+ if (type == "secondarybuttoncommand") {
+ AppMenuNotifications.callSecondaryAction(window, notification);
+ } else {
+ AppMenuNotifications.callMainAction(window, notification, true);
+ }
+ },
+
+ _onBannerItemSelected(event) {
+ let target = event.target;
+ if (!target.notification) {
+ throw new Error(
+ "menucommand target has no associated action/notification"
+ );
+ }
+
+ event.stopPropagation();
+ AppMenuNotifications.callMainAction(window, target.notification, false);
+ },
+
+ _getPopupId(notification) {
+ return "appMenu-" + notification.id + "-notification";
+ },
+
+ _getBadgeStatus(notification) {
+ return notification.id;
+ },
+
+ _getPanelAnchor(candidate) {
+ let iconAnchor = candidate.badgeStack || candidate.icon;
+ return iconAnchor || candidate;
+ },
+
+ _ensureShortcutsShown(view = this.mainView) {
+ if (view.hasAttribute("added-shortcuts")) {
+ return;
+ }
+ view.setAttribute("added-shortcuts", "true");
+ for (let button of view.querySelectorAll("toolbarbutton[key]")) {
+ let keyId = button.getAttribute("key");
+ let key = document.getElementById(keyId);
+ if (!key) {
+ continue;
+ }
+ button.setAttribute("shortcut", ShortcutUtils.prettifyShortcut(key));
+ }
+ },
+
+ folderViewMenuOnCommand(event) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ if (!about3Pane) {
+ return;
+ }
+
+ let mode = event.target.getAttribute("value");
+ if (mode == "toggle-header") {
+ about3Pane.folderPane.toggleHeader(event.target.hasAttribute("checked"));
+ return;
+ }
+
+ let activeModes = about3Pane.folderPane.activeModes;
+ let index = activeModes.indexOf(mode);
+ if (event.target.hasAttribute("checked")) {
+ if (index == -1) {
+ activeModes.push(mode);
+ }
+ } else if (index >= 0) {
+ activeModes.splice(index, 1);
+ }
+ about3Pane.folderPane.activeModes = activeModes;
+
+ this._onFoldersViewShow({ target: event.target.parentNode });
+ },
+
+ folderCompactMenuOnCommand(event) {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ if (!about3Pane) {
+ return;
+ }
+
+ about3Pane.folderPane.isCompact = event.target.hasAttribute("checked");
+ },
+
+ setUIDensity(event) {
+ // Loops through all available options and uncheck them. This is necessary
+ // since the toolbarbuttons don't uncheck themselves even if they're radio.
+ for (let item of event.originalTarget
+ .closest(".panel-subview-body")
+ .querySelectorAll("toolbarbutton")) {
+ // Skip this item if it's the one clicked.
+ if (item == event.originalTarget) {
+ continue;
+ }
+
+ item.removeAttribute("checked");
+ }
+ // Update the UI density.
+ UIDensity.setMode(event.originalTarget.mode);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "PanelUI", PanelUI);
+
+/**
+ * Gets the currently selected locale for display.
+ *
+ * @returns the selected locale
+ */
+function getLocale() {
+ return Services.locale.appLocaleAsBCP47;
+}
+
+/**
+ * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
+ */
+function getNotificationFromElement(aElement) {
+ return aElement.closest("popupnotification");
+}
+
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/base/content/browser-addons.js.
+ */
+var gExtensionsNotifications = {
+ initialized: false,
+ init() {
+ this.updateAlerts();
+ this.boundUpdate = this.updateAlerts.bind(this);
+ ExtensionsUI.on("change", this.boundUpdate);
+ this.initialized = true;
+ },
+
+ uninit() {
+ // uninit() can race ahead of init() in some cases, if that happens,
+ // we have no handler to remove.
+ if (!this.initialized) {
+ return;
+ }
+ ExtensionsUI.off("change", this.boundUpdate);
+ },
+
+ get l10n() {
+ if (this._l10n) {
+ return this._l10n;
+ }
+ return (this._l10n = new Localization(
+ ["messenger/addonNotifications.ftl", "branding/brand.ftl"],
+ true
+ ));
+ },
+
+ _createAddonButton(l10nId, addon, callback) {
+ let text = this.l10n.formatValueSync(l10nId, { addonName: addon.name });
+ let button = document.createXULElement("toolbarbutton");
+ button.setAttribute("wrap", "true");
+ button.setAttribute("label", text);
+ button.setAttribute("tooltiptext", text);
+ const DEFAULT_EXTENSION_ICON =
+ "chrome://messenger/skin/icons/new/compact/extension.svg";
+ button.setAttribute("image", addon.iconURL || DEFAULT_EXTENSION_ICON);
+ button.className = "addon-banner-item subviewbutton";
+
+ button.addEventListener("command", callback);
+ PanelUI.addonNotificationContainer.appendChild(button);
+ },
+
+ updateAlerts() {
+ let gBrowser = document.getElementById("tabmail");
+ let sideloaded = ExtensionsUI.sideloaded;
+ let updates = ExtensionsUI.updates;
+
+ let container = PanelUI.addonNotificationContainer;
+
+ while (container.firstChild) {
+ container.firstChild.remove();
+ }
+
+ let items = 0;
+ for (let update of updates) {
+ if (++items > 4) {
+ break;
+ }
+ this._createAddonButton(
+ "webext-perms-update-menu-item",
+ update.addon,
+ evt => {
+ ExtensionsUI.showUpdate(gBrowser, update);
+ }
+ );
+ }
+
+ for (let addon of sideloaded) {
+ if (++items > 4) {
+ break;
+ }
+ this._createAddonButton("webext-perms-sideload-menu-item", addon, evt => {
+ // We need to hide the main menu manually because the toolbarbutton is
+ // removed immediately while processing this event, and PanelUI is
+ // unable to identify which panel should be closed automatically.
+ PanelUI.hide();
+ ExtensionsUI.showSideloaded(gBrowser, addon);
+ });
+ }
+ },
+};
+
+addEventListener("unload", () => gExtensionsNotifications.uninit(), {
+ once: true,
+});
diff --git a/comm/mail/components/customizableui/moz.build b/comm/mail/components/customizableui/moz.build
new file mode 100644
index 0000000000..4bc53e73ea
--- /dev/null
+++ b/comm/mail/components/customizableui/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "content",
+]
+
+EXTRA_JS_MODULES += [
+ "CustomizableUI.sys.mjs",
+ "PanelMultiView.sys.mjs",
+]
diff --git a/comm/mail/components/devtools/components.conf b/comm/mail/components/devtools/components.conf
new file mode 100644
index 0000000000..023940a05f
--- /dev/null
+++ b/comm/mail/components/devtools/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{089694e9-106a-4704-abf7-62a88545e194}',
+ 'contract_ids': ['@mozilla.org/messenger/devtools-startup-clh;1'],
+ 'jsm': 'resource:///modules/devtools-loader.jsm',
+ 'constructor': 'DevToolsStartup',
+ 'categories': {'command-line-handler': 'm-aaa-tb-devtools'},
+ },
+]
diff --git a/comm/mail/components/devtools/devtools-loader.jsm b/comm/mail/components/devtools/devtools-loader.jsm
new file mode 100644
index 0000000000..a951bb2b94
--- /dev/null
+++ b/comm/mail/components/devtools/devtools-loader.jsm
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+function DevToolsStartup() {}
+
+DevToolsStartup.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsICommandLineHandler"]),
+
+ helpInfo: "",
+ handle(cmdLine) {
+ this.initialize();
+
+ // We want to overwrite the -devtools flag and open the toolbox instead
+ let devtoolsFlag = cmdLine.handleFlag("devtools", false);
+ if (devtoolsFlag) {
+ this.handleDevToolsFlag(cmdLine);
+ }
+ },
+
+ handleDevToolsFlag(cmdLine) {
+ const { BrowserToolboxLauncher } = ChromeUtils.importESModule(
+ "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs"
+ );
+ BrowserToolboxLauncher.init();
+
+ if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
+ cmdLine.preventDefault = true;
+ }
+ },
+
+ initialize() {
+ let { loader, require, DevToolsLoader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ let { DevToolsServer } = require("devtools/server/devtools-server");
+ let { gDevTools } = require("devtools/client/framework/devtools");
+
+ // Set up the client and server chrome window type, make sure it can't be set
+ Object.defineProperty(DevToolsServer, "chromeWindowType", {
+ get: () => "mail:3pane",
+ set: () => {},
+ configurable: true,
+ });
+ Object.defineProperty(gDevTools, "chromeWindowType", {
+ get: () => "mail:3pane",
+ set: () => {},
+ configurable: true,
+ });
+
+ // Make sure our root actor is always registered, no matter how devtools are called.
+ let devtoolsRegisterActors =
+ DevToolsServer.registerActors.bind(DevToolsServer);
+ DevToolsServer.registerActors = function (options) {
+ devtoolsRegisterActors(options);
+ if (options.root) {
+ const {
+ createRootActor,
+ } = require("resource:///modules/tb-root-actor.js");
+ DevToolsServer.setRootActor(createRootActor);
+ }
+ };
+
+ // Make the loader visible to the debugger by default and for the already
+ // loaded instance. Thunderbird now also provides the Browser Toolbox for
+ // chrome debugging, which uses its own separate loader instance.
+ DevToolsLoader.prototype.invisibleToDebugger = false;
+ loader.invisibleToDebugger = false;
+ DevToolsServer.allowChromeProcess = true;
+
+ // Initialize and load the toolkit/browser actors. This will also call above function to set the
+ // Thunderbird root actor
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+ },
+};
+
+var EXPORTED_SYMBOLS = ["DevToolsStartup"];
diff --git a/comm/mail/components/devtools/moz.build b/comm/mail/components/devtools/moz.build
new file mode 100644
index 0000000000..fbe8acc2cb
--- /dev/null
+++ b/comm/mail/components/devtools/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "devtools-loader.jsm",
+ "tb-root-actor.js",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/devtools/tb-root-actor.js b/comm/mail/components/devtools/tb-root-actor.js
new file mode 100644
index 0000000000..3f546605f6
--- /dev/null
+++ b/comm/mail/components/devtools/tb-root-actor.js
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals loader, require, exports */
+
+/**
+ * Actors for Thunderbird Developer Tools, for example the root actor or tab
+ * list actor.
+ */
+
+var { ActorRegistry } = require("devtools/server/actors/utils/actor-registry");
+
+loader.lazyRequireGetter(
+ this,
+ "RootActor",
+ "devtools/server/actors/root",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BrowserTabList",
+ "devtools/server/actors/webbrowser",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BrowserAddonList",
+ "devtools/server/actors/webbrowser",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "sendShutdownEvent",
+ "devtools/server/actors/webbrowser",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WorkerDescriptorActorList",
+ "devtools/server/actors/worker/worker-descriptor-actor-list",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ServiceWorkerRegistrationActorList",
+ "devtools/server/actors/worker/service-worker-registration-list",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ProcessActorList",
+ "devtools/server/actors/process",
+ true
+);
+
+/**
+ * Create the root actor for Thunderbird.
+ *
+ * @param aConnection The debugger connection to create the actor for.
+ * @returns The mail actor for the connection.
+ */
+exports.createRootActor = function (aConnection) {
+ let parameters = {
+ tabList: new TBTabList(aConnection),
+ addonList: new BrowserAddonList(aConnection),
+ workerList: new WorkerDescriptorActorList(aConnection, {}),
+ serviceWorkerRegistrationList: new ServiceWorkerRegistrationActorList(
+ aConnection
+ ),
+ processList: new ProcessActorList(),
+ globalActorFactories: ActorRegistry.globalActorFactories,
+ onShutdown: sendShutdownEvent,
+ };
+
+ // Create the root actor and set the application type
+ let rootActor = new RootActor(aConnection, parameters);
+ rootActor.applicationType = "mail";
+
+ return rootActor;
+};
+
+/**
+ * Thunderbird's version of the tab list. We don't have gBrowser, but tabmail has similar functions
+ * that will be helpful. The tabs displayed are those tabs in tabmail that have a browser element.
+ * This is mainly the contentTabs, but can also be others such as the start page.
+ */
+class TBTabList extends BrowserTabList {
+ _getSelectedBrowser(window) {
+ let tabmail = window.document.getElementById("tabmail");
+ return tabmail ? tabmail.selectedBrowser : null;
+ }
+
+ _getChildren(window) {
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return [];
+ }
+
+ return tabmail.tabInfo
+ .map(tab => tabmail.getBrowserForTab(tab))
+ .filter(Boolean);
+ }
+}
diff --git a/comm/mail/components/downloads/content/aboutDownloads.js b/comm/mail/components/downloads/content/aboutDownloads.js
new file mode 100644
index 0000000000..6cd7e2973c
--- /dev/null
+++ b/comm/mail/components/downloads/content/aboutDownloads.js
@@ -0,0 +1,414 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals goUpdateCommand */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+});
+
+window.addEventListener("load", event => {
+ DownloadsView.init();
+});
+
+var DownloadsView = {
+ init() {
+ window.controllers.insertControllerAt(0, this);
+ this.listElement = document.getElementById("msgDownloadsRichListBox");
+
+ this.items = new Map();
+
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.addView(this))
+ .catch(console.error);
+
+ window.addEventListener("unload", aEvent => {
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.removeView(this))
+ .catch(console.error);
+ window.controllers.removeController(this);
+ });
+ },
+
+ insertOrMoveItem(aItem) {
+ let compare = (a, b) => {
+ // active downloads always before stopped downloads
+ if (a.stopped != b.stopped) {
+ return b.stopped ? -1 : 1;
+ }
+ // most recent downloads first
+ return b.startTime - a.startTime;
+ };
+
+ let at = this.listElement.firstElementChild;
+ while (at && compare(aItem.download, at.download) > 0) {
+ at = at.nextElementSibling;
+ }
+ this.listElement.insertBefore(aItem.element, at);
+ },
+
+ onDownloadAdded(aDownload) {
+ let isPurgedFromDisk = download => {
+ if (!download.succeeded) {
+ return false;
+ }
+ let targetFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ targetFile.initWithPath(download.target.path);
+ return !targetFile.exists();
+ };
+ if (isPurgedFromDisk(aDownload)) {
+ Downloads.getList(Downloads.ALL).then(list => list.remove(aDownload));
+ return;
+ }
+
+ let item = new DownloadItem(aDownload);
+ this.items.set(aDownload, item);
+ this.insertOrMoveItem(item);
+ },
+
+ onDownloadChanged(aDownload) {
+ let item = this.items.get(aDownload);
+ if (!item) {
+ console.error("No DownloadItem found for download");
+ return;
+ }
+
+ if (item.stateChanged) {
+ this.insertOrMoveItem(item);
+ }
+
+ item.onDownloadChanged();
+ },
+
+ onDownloadRemoved(aDownload) {
+ let item = this.items.get(aDownload);
+ if (!item) {
+ console.error("No DownloadItem found for download");
+ return;
+ }
+
+ this.items.delete(aDownload);
+ this.listElement.removeChild(item.element);
+ },
+
+ onDownloadContextMenu() {
+ this.updateCommands();
+ },
+
+ clearDownloads() {
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.removeFinished())
+ .catch(console.error);
+ },
+
+ searchDownloads() {
+ let searchString = document.getElementById("searchBox").value.toLowerCase();
+ for (let i = 0; i < this.listElement.itemCount; i++) {
+ let downloadElem = this.listElement.getItemAtIndex(i);
+ downloadElem.collapsed = !downloadElem.downloadItem.fileName
+ .toLowerCase()
+ .includes(searchString);
+ }
+ this.listElement.clearSelection();
+ },
+
+ supportsCommand(aCommand) {
+ return (
+ this.commands.includes(aCommand) ||
+ DownloadItem.prototype.supportsCommand(aCommand)
+ );
+ },
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_clearDownloads":
+ case "msgDownloadsCmd_searchDownloads":
+ // We could disable these if there are no downloads in the list, but
+ // updating the commands when new items become available is tricky.
+ return true;
+ }
+
+ let element = this.listElement.selectedItem;
+ if (element) {
+ return element.downloadItem.isCommandEnabled(aCommand);
+ }
+
+ return false;
+ },
+
+ doCommand(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_clearDownloads":
+ this.clearDownloads();
+ return;
+ case "msgDownloadsCmd_searchDownloads":
+ this.searchDownloads();
+ return;
+ }
+
+ if (this.listElement.selectedCount == 0) {
+ return;
+ }
+
+ for (let element of this.listElement.selectedItems) {
+ element.downloadItem.doCommand(aCommand);
+ }
+ },
+
+ onEvent() {},
+
+ updateCommands() {
+ this.commands.forEach(goUpdateCommand);
+ DownloadItem.prototype.commands.forEach(goUpdateCommand);
+ },
+
+ commands: [
+ "msgDownloadsCmd_clearDownloads",
+ "msgDownloadsCmd_searchDownloads",
+ ],
+};
+
+function DownloadItem(aDownload) {
+ this._download = aDownload;
+ this._updateFromDownload();
+
+ if (aDownload._unknownProperties && aDownload._unknownProperties.sender) {
+ this._sender = aDownload._unknownProperties.sender;
+ } else {
+ this._sender = "";
+ }
+ this._fileName = this._htmlEscape(PathUtils.filename(aDownload.target.path));
+ this._iconUrl = "moz-icon://" + this._fileName + "?size=32";
+ this._startDate = this._htmlEscape(
+ DownloadUtils.getReadableDates(aDownload.startTime)[0]
+ );
+ this._filePath = aDownload.target.path;
+}
+
+var kDownloadStatePropertyNames = [
+ "stopped",
+ "succeeded",
+ "canceled",
+ "error",
+ "startTime",
+];
+
+DownloadItem.prototype = {
+ _htmlEscape(s) {
+ s = s.replace(/&/g, "&amp;");
+ s = s.replace(/>/g, "&gt;");
+ s = s.replace(/</g, "&lt;");
+ s = s.replace(/"/g, "&quot;");
+ s = s.replace(/'/g, "&apos;");
+ return s;
+ },
+
+ _updateFromDownload() {
+ this._state = {};
+ for (let name of kDownloadStatePropertyNames) {
+ this._state[name] = this._download[name];
+ }
+ },
+
+ get stateChanged() {
+ for (let name of kDownloadStatePropertyNames) {
+ if (this._state[name] != this._download[name]) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get download() {
+ return this._download;
+ },
+
+ get element() {
+ if (!this._element) {
+ this._element = this.createXULElement();
+ }
+
+ return this._element;
+ },
+
+ createXULElement() {
+ let element = document.createXULElement("richlistitem");
+ element.classList.add("download");
+ element.setAttribute("align", "center");
+
+ let image = document.createElement("img");
+ image.setAttribute("alt", "");
+ // Allow the given src to be invalid.
+ image.classList.add("fileTypeIcon", "invisible-on-broken");
+
+ let vbox = document.createXULElement("vbox");
+ vbox.setAttribute("pack", "center");
+ vbox.setAttribute("flex", "1");
+
+ let hbox = document.createXULElement("hbox");
+ let hbox2 = document.createXULElement("hbox");
+
+ let sender = document.createXULElement("description");
+ sender.classList.add("sender");
+
+ let fileName = document.createXULElement("description");
+ fileName.setAttribute("crop", "center");
+ fileName.classList.add("fileName");
+
+ let size = document.createXULElement("description");
+ size.classList.add("size");
+
+ let startDate = document.createXULElement("description");
+ startDate.setAttribute("crop", "end");
+ startDate.classList.add("startDate");
+
+ hbox.appendChild(fileName);
+ hbox.appendChild(size);
+ hbox2.appendChild(sender);
+ hbox2.appendChild(startDate);
+
+ vbox.appendChild(hbox);
+ vbox.appendChild(hbox2);
+
+ let vbox2 = document.createXULElement("vbox");
+
+ let downloadButton = document.createXULElement("button");
+ downloadButton.classList.add("downloadButton", "downloadIconShow");
+
+ vbox2.appendChild(downloadButton);
+
+ element.appendChild(image);
+ element.appendChild(vbox2);
+ element.appendChild(vbox);
+
+ // launch the download if double clicked
+ vbox.addEventListener("dblclick", aEvent => this.launch());
+
+ // Show the downloaded file in folder if the folder icon is clicked.
+ downloadButton.addEventListener("click", aEvent => this.show());
+
+ // set download as an expando property for the context menu
+ element.download = this.download;
+ element.downloadItem = this;
+
+ this.updateElement(element);
+
+ return element;
+ },
+
+ updateElement(element) {
+ let fileTypeIcon = element.querySelector(".fileTypeIcon");
+ fileTypeIcon.setAttribute("src", this.iconUrl);
+
+ let size = element.querySelector(".size");
+ size.setAttribute("value", this.size);
+ size.setAttribute("tooltiptext", this.size);
+
+ let fileName = element.querySelector(".fileName");
+ fileName.setAttribute("value", this.fileName);
+ fileName.setAttribute("tooltiptext", this.fileName);
+
+ let sender = element.querySelector(".sender");
+ sender.setAttribute("value", this.sender);
+ sender.setAttribute("tooltiptext", this.sender);
+
+ let startDate = element.querySelector(".startDate");
+ startDate.setAttribute("value", this.startDate);
+ startDate.setAttribute("tooltiptext", this.startDate);
+ },
+
+ launch() {
+ if (this.download.succeeded) {
+ this.download.launch().catch(console.error);
+ }
+ },
+
+ remove() {
+ Downloads.getList(Downloads.ALL)
+ .then(list => list.remove(this.download))
+ .then(() => this.download.finalize(true))
+ .catch(console.error);
+ },
+
+ show() {
+ if (this.download.succeeded) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(this._filePath);
+ file.reveal();
+ }
+ },
+
+ onDownloadChanged() {
+ this._updateFromDownload();
+ this.updateElement(this.element);
+ },
+
+ get fileName() {
+ return this._fileName;
+ },
+
+ get iconUrl() {
+ return this._iconUrl;
+ },
+
+ get sender() {
+ return this._sender;
+ },
+
+ get size() {
+ let bytes;
+ if (this.download.succeeded || this.download.hasProgress) {
+ bytes = this.download.target.size;
+ } else {
+ bytes = this.download.currentBytes;
+ }
+ return DownloadUtils.convertByteUnits(bytes).join("");
+ },
+
+ get startDate() {
+ return this._startDate;
+ },
+
+ supportsCommand(aCommand) {
+ return this.commands.includes(aCommand);
+ },
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_open":
+ case "msgDownloadsCmd_show":
+ return this.download.succeeded;
+ case "msgDownloadsCmd_remove":
+ return true;
+ }
+ return false;
+ },
+
+ doCommand(aCommand) {
+ switch (aCommand) {
+ case "msgDownloadsCmd_open":
+ this.launch();
+ break;
+ case "msgDownloadsCmd_show":
+ this.show();
+ break;
+ case "msgDownloadsCmd_remove":
+ this.remove();
+ break;
+ }
+ },
+
+ commands: [
+ "msgDownloadsCmd_remove",
+ "msgDownloadsCmd_open",
+ "msgDownloadsCmd_show",
+ ],
+};
diff --git a/comm/mail/components/downloads/content/aboutDownloads.xhtml b/comm/mail/components/downloads/content/aboutDownloads.xhtml
new file mode 100644
index 0000000000..fdc570c06f
--- /dev/null
+++ b/comm/mail/components/downloads/content/aboutDownloads.xhtml
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet href="chrome://global/skin/in-content/common.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/downloads/aboutDownloads.css"?>
+
+<!DOCTYPE html [
+<!ENTITY % aboutDownloadsDTD SYSTEM "chrome://messenger/locale/aboutDownloads.dtd">
+%aboutDownloadsDTD;
+]>
+
+<html id="aboutDownloads" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+ lightweightthemes="true">
+<head>
+ <title>&aboutDownloads.title;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/downloads/aboutDownloads.js"></script>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome:; img-src chrome: moz-icon:; object-src 'none'; script-src chrome: 'unsafe-inline'" />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/download.svg" />
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <commandset id="msgDownloadCommands"
+ commandupdater="true"
+ events="focus,select,contextmenu">
+ <command id="msgDownloadsCmd_open"
+ oncommand="goDoCommand('msgDownloadsCmd_open')"/>
+ <command id="msgDownloadsCmd_show"
+ oncommand="goDoCommand('msgDownloadsCmd_show')"/>
+ <command id="msgDownloadsCmd_remove"
+ oncommand="goDoCommand('msgDownloadsCmd_remove')"/>
+ <command id="msgDownloadsCmd_clearDownloads"
+ oncommand="goDoCommand('msgDownloadsCmd_clearDownloads')"/>
+ <command id="msgDownloadsCmd_searchDownloads"
+ oncommand="goDoCommand('msgDownloadsCmd_searchDownloads')"/>
+ </commandset>
+
+ <keyset id="downloadKeys">
+ <key keycode="&cmd.searchDownloads.key;" modifiers="accel"
+ oncommand="document.getElementById('searchBox').focus();"/>
+ </keyset>
+
+ <hbox id="downloadTopBox"
+ align="center">
+ <button id="clearDownloads"
+ command="msgDownloadsCmd_clearDownloads"
+ label="&cmd.clearList.label;"
+ accesskey="&cmd.clearList.accesskey;"
+ tooltiptext="&cmd.clearList.tooltip;"/>
+
+ <spacer flex="1"/>
+ <search-textbox id="searchBox"
+ class="themeableSearchBox"
+ command="msgDownloadsCmd_searchDownloads"
+ placeholder="&cmd.searchDownloads.label;"/>
+ </hbox>
+
+ <hbox id="downloadBottomBox" flex="1">
+ <richlistbox id="msgDownloadsRichListBox"
+ flex="1"
+ seltype="multiple"
+ context="msgDownloadsContextMenu"
+ oncontextmenu="DownloadsView.onDownloadContextMenu();"/>
+ </hbox>
+
+ <menupopup id="msgDownloadsContextMenu">
+ <menuitem command="msgDownloadsCmd_remove"
+ class="msgDownloadRemoveFromHistoryMenuItem"
+ label="&cmd.removeFromHistory.label;"
+ accesskey="&cmd.removeFromHistory.accesskey;"/>
+ <menuitem command="msgDownloadsCmd_open"
+ label="&cmd.open.label;"
+ accesskey="&cmd.open.accesskey;"/>
+ <menuitem command="msgDownloadsCmd_show"
+ class="msgDownloadShowMenuItem"
+#ifdef XP_MACOSX
+ label="&cmd.showMac.label;"
+ accesskey="&cmd.showMac.accesskey;"
+#else
+ label="&cmd.show.label;"
+ accesskey="&cmd.show.accesskey;"
+#endif
+ />
+ <menuitem command="msgDownloadsCmd_clearDownloads"
+ label="&cmd.clearList.label;"
+ accesskey="&cmd.clearList.accesskey;"
+ tooltiptext="&cmd.clearList.tooltip;"/>
+ </menupopup>
+</html:body>
+</html>
diff --git a/comm/mail/components/downloads/jar.mn b/comm/mail/components/downloads/jar.mn
new file mode 100644
index 0000000000..ff6628e82d
--- /dev/null
+++ b/comm/mail/components/downloads/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/downloads/aboutDownloads.js (content/aboutDownloads.js)
+* content/messenger/downloads/aboutDownloads.xhtml (content/aboutDownloads.xhtml)
diff --git a/comm/mail/components/downloads/moz.build b/comm/mail/components/downloads/moz.build
new file mode 100644
index 0000000000..ea0b25aae8
--- /dev/null
+++ b/comm/mail/components/downloads/moz.build
@@ -0,0 +1,5 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/enterprisepolicies/Policies.sys.mjs b/comm/mail/components/enterprisepolicies/Policies.sys.mjs
new file mode 100644
index 0000000000..d35e9a2d30
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/Policies.sys.mjs
@@ -0,0 +1,1758 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
+ gExternalProtocolService: [
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService",
+ ],
+ gHandlerService: [
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService",
+ ],
+ gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ProxyPolicies: "resource:///modules/policies/ProxyPolicies.sys.mjs",
+});
+
+const PREF_LOGLEVEL = "browser.policies.loglevel";
+const ABOUT_CONTRACT = "@mozilla.org/network/protocol/about;1?what=";
+
+const isXpcshell = Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ prefix: "Policies.jsm",
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.jsm for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: PREF_LOGLEVEL,
+ });
+});
+
+/*
+ * ============================
+ * = POLICIES IMPLEMENTATIONS =
+ * ============================
+ *
+ * The Policies object below is where the implementation for each policy
+ * happens. An object for each policy should be defined, containing
+ * callback functions that will be called by the engine.
+ *
+ * See the _callbacks object in EnterprisePolicies.js for the list of
+ * possible callbacks and an explanation of each.
+ *
+ * Each callback will be called with two parameters:
+ * - manager
+ * This is the EnterprisePoliciesManager singleton object from
+ * EnterprisePolicies.js
+ *
+ * - param
+ * The parameter defined for this policy in policies-schema.json.
+ * It will be different for each policy. It could be a boolean,
+ * a string, an array or a complex object. All parameters have
+ * been validated according to the schema, and no unknown
+ * properties will be present on them.
+ *
+ * The callbacks will be bound to their parent policy object.
+ */
+export var Policies = {
+ // Used for cleaning up policies.
+ // Use the same timing that you used for setting up the policy.
+ _cleanup: {
+ onBeforeAddons(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onBeforeAddons");
+ clearBlockedAboutPages();
+ }
+ },
+ onProfileAfterChange(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onProfileAfterChange");
+ }
+ },
+ onBeforeUIStartup(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onBeforeUIStartup");
+ }
+ },
+ onAllWindowsRestored(manager) {
+ if (Cu.isInAutomation || isXpcshell) {
+ console.log("_cleanup from onAllWindowsRestored");
+ }
+ },
+ },
+
+ "3rdparty": {
+ onBeforeAddons(manager, param) {
+ manager.setExtensionPolicies(param.Extensions);
+ },
+ },
+
+ AppAutoUpdate: {
+ onBeforeUIStartup(manager, param) {
+ // Logic feels a bit reversed here, but it's correct. If AppAutoUpdate is
+ // true, we disallow turning off auto updating, and visa versa.
+ if (param) {
+ manager.disallowFeature("app-auto-updates-off");
+ } else {
+ manager.disallowFeature("app-auto-updates-on");
+ }
+ },
+ },
+
+ AppUpdatePin: {
+ validate(param) {
+ // This is the version when pinning was introduced. Attempting to set a
+ // pin before this will not work, because Balrog's pinning table will
+ // never have the necessary entry.
+ const earliestPinMajorVersion = 102;
+ const earliestPinMinorVersion = 0;
+
+ let pinParts = param.split(".");
+
+ if (pinParts.length < 2) {
+ lazy.log.error("AppUpdatePin has too few dots.");
+ return false;
+ }
+ if (pinParts.length > 3) {
+ lazy.log.error("AppUpdatePin has too many dots.");
+ return false;
+ }
+
+ const trailingPinPart = pinParts.pop();
+ if (trailingPinPart != "") {
+ lazy.log.error("AppUpdatePin does not end with a trailing dot.");
+ return false;
+ }
+
+ const pinMajorVersionStr = pinParts.shift();
+ if (!pinMajorVersionStr.length) {
+ lazy.log.error("AppUpdatePin's major version is empty.");
+ return false;
+ }
+ if (!/^\d+$/.test(pinMajorVersionStr)) {
+ lazy.log.error(
+ "AppUpdatePin's major version contains a non-numeric character."
+ );
+ return false;
+ }
+ if (/^0/.test(pinMajorVersionStr)) {
+ lazy.log.error("AppUpdatePin's major version contains a leading 0.");
+ return false;
+ }
+ const pinMajorVersionInt = parseInt(pinMajorVersionStr, 10);
+ if (isNaN(pinMajorVersionInt)) {
+ lazy.log.error(
+ "AppUpdatePin's major version could not be parsed to an integer."
+ );
+ return false;
+ }
+ if (pinMajorVersionInt < earliestPinMajorVersion) {
+ lazy.log.error(
+ `AppUpdatePin must not be earlier than '${earliestPinMajorVersion}.${earliestPinMinorVersion}.'.`
+ );
+ return false;
+ }
+
+ if (pinParts.length) {
+ const pinMinorVersionStr = pinParts.shift();
+ if (!pinMinorVersionStr.length) {
+ lazy.log.error("AppUpdatePin's minor version is empty.");
+ return false;
+ }
+ if (!/^\d+$/.test(pinMinorVersionStr)) {
+ lazy.log.error(
+ "AppUpdatePin's minor version contains a non-numeric character."
+ );
+ return false;
+ }
+ if (/^0\d/.test(pinMinorVersionStr)) {
+ lazy.log.error("AppUpdatePin's minor version contains a leading 0.");
+ return false;
+ }
+ const pinMinorVersionInt = parseInt(pinMinorVersionStr, 10);
+ if (isNaN(pinMinorVersionInt)) {
+ lazy.log.error(
+ "AppUpdatePin's minor version could not be parsed to an integer."
+ );
+ return false;
+ }
+ if (
+ pinMajorVersionInt == earliestPinMajorVersion &&
+ pinMinorVersionInt < earliestPinMinorVersion
+ ) {
+ lazy.log.error(
+ `AppUpdatePin must not be earlier than '${earliestPinMajorVersion}.${earliestPinMinorVersion}.'.`
+ );
+ return false;
+ }
+ }
+
+ return true;
+ },
+ // No additional implementation needed here. UpdateService.sys.mjs will
+ // check for this policy directly when determining the update URL.
+ },
+
+ AppUpdateURL: {
+ // No implementation needed here. UpdateService.sys.mjs will check for this
+ // policy directly when determining the update URL.
+ },
+
+ Authentication: {
+ onBeforeAddons(manager, param) {
+ let locked = true;
+ if ("Locked" in param) {
+ locked = param.Locked;
+ }
+
+ if ("SPNEGO" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.trusted-uris",
+ param.SPNEGO.join(", "),
+ locked
+ );
+ }
+ if ("Delegated" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.delegation-uris",
+ param.Delegated.join(", "),
+ locked
+ );
+ }
+ if ("NTLM" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.automatic-ntlm-auth.trusted-uris",
+ param.NTLM.join(", "),
+ locked
+ );
+ }
+ if ("AllowNonFQDN" in param) {
+ if ("NTLM" in param.AllowNonFQDN) {
+ PoliciesUtils.setDefaultPref(
+ "network.automatic-ntlm-auth.allow-non-fqdn",
+ param.AllowNonFQDN.NTLM,
+ locked
+ );
+ }
+ if ("SPNEGO" in param.AllowNonFQDN) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.allow-non-fqdn",
+ param.AllowNonFQDN.SPNEGO,
+ locked
+ );
+ }
+ }
+ if ("AllowProxies" in param) {
+ if ("NTLM" in param.AllowProxies) {
+ PoliciesUtils.setDefaultPref(
+ "network.automatic-ntlm-auth.allow-proxies",
+ param.AllowProxies.NTLM,
+ locked
+ );
+ }
+ if ("SPNEGO" in param.AllowProxies) {
+ PoliciesUtils.setDefaultPref(
+ "network.negotiate-auth.allow-proxies",
+ param.AllowProxies.SPNEGO,
+ locked
+ );
+ }
+ }
+ if ("PrivateBrowsing" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.auth.private-browsing-sso",
+ param.PrivateBrowsing,
+ locked
+ );
+ }
+ },
+ },
+
+ BackgroundAppUpdate: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("app-background-update-off");
+ } else {
+ manager.disallowFeature("app-background-update-on");
+ }
+ },
+ },
+
+ BlockAboutAddons: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:addons", true);
+ }
+ },
+ },
+
+ BlockAboutConfig: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:config");
+ setAndLockPref("devtools.chrome.enabled", false);
+ }
+ },
+ },
+
+ BlockAboutProfiles: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:profiles");
+ }
+ },
+ },
+
+ BlockAboutSupport: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ blockAboutPage(manager, "about:support");
+ }
+ },
+ },
+
+ CaptivePortal: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("network.captive-portal-service.enabled", param);
+ },
+ },
+
+ Certificates: {
+ onBeforeAddons(manager, param) {
+ if ("ImportEnterpriseRoots" in param) {
+ setAndLockPref(
+ "security.enterprise_roots.enabled",
+ param.ImportEnterpriseRoots
+ );
+ }
+ if ("Install" in param) {
+ (async () => {
+ let dirs = [];
+ let platform = AppConstants.platform;
+ if (platform == "win") {
+ dirs = [
+ // Ugly, but there is no official way to get %USERNAME\AppData\Roaming\Mozilla.
+ Services.dirsvc.get("XREUSysExt", Ci.nsIFile).parent,
+ // Even more ugly, but there is no official way to get %USERNAME\AppData\Local\Mozilla.
+ Services.dirsvc.get("DefProfLRt", Ci.nsIFile).parent.parent,
+ ];
+ } else if (platform == "macosx" || platform == "linux") {
+ dirs = [
+ // These two keys are named wrong. They return the Mozilla directory.
+ Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile),
+ Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile),
+ ];
+ }
+ dirs.unshift(Services.dirsvc.get("XREAppDist", Ci.nsIFile));
+ for (let certfilename of param.Install) {
+ let certfile;
+ try {
+ certfile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ certfile.initWithPath(certfilename);
+ } catch (e) {
+ for (let dir of dirs) {
+ certfile = dir.clone();
+ certfile.append(
+ platform == "linux" ? "certificates" : "Certificates"
+ );
+ certfile.append(certfilename);
+ if (certfile.exists()) {
+ break;
+ }
+ }
+ }
+ let file;
+ try {
+ file = await File.createFromNsIFile(certfile);
+ } catch (e) {
+ lazy.log.error(`Unable to find certificate - ${certfilename}`);
+ continue;
+ }
+ let reader = new FileReader();
+ reader.onloadend = function () {
+ if (reader.readyState != reader.DONE) {
+ lazy.log.error(`Unable to read certificate - ${certfile.path}`);
+ return;
+ }
+ let certFile = reader.result;
+ let certFileArray = [];
+ for (let i = 0; i < certFile.length; i++) {
+ certFileArray.push(certFile.charCodeAt(i));
+ }
+ let cert;
+ try {
+ cert = lazy.gCertDB.constructX509(certFileArray);
+ } catch (e) {
+ lazy.log.debug(
+ `constructX509 failed with error '${e}' - trying constructX509FromBase64.`
+ );
+ try {
+ // It might be PEM instead of DER.
+ cert = lazy.gCertDB.constructX509FromBase64(
+ pemToBase64(certFile)
+ );
+ } catch (ex) {
+ lazy.log.error(
+ `Unable to add certificate - ${certfile.path}`,
+ ex
+ );
+ }
+ }
+ if (cert) {
+ if (
+ lazy.gCertDB.isCertTrusted(
+ cert,
+ Ci.nsIX509Cert.CA_CERT,
+ Ci.nsIX509CertDB.TRUSTED_SSL
+ )
+ ) {
+ // Certificate is already installed.
+ return;
+ }
+ try {
+ lazy.gCertDB.addCert(certFile, "CT,CT,");
+ } catch (e) {
+ // It might be PEM instead of DER.
+ lazy.gCertDB.addCertFromBase64(
+ pemToBase64(certFile),
+ "CT,CT,"
+ );
+ }
+ }
+ };
+ reader.readAsBinaryString(file);
+ }
+ })();
+ }
+ },
+ },
+
+ Cookies: {
+ onBeforeUIStartup(manager, param) {
+ addAllowDenyPermissions("cookie", param.Allow, param.Block);
+
+ if (param.Block) {
+ const hosts = param.Block.map(url => url.hostname)
+ .sort()
+ .join("\n");
+ runOncePerModification("clearCookiesForBlockedHosts", hosts, () => {
+ for (let blocked of param.Block) {
+ Services.cookies.removeCookiesWithOriginAttributes(
+ "{}",
+ blocked.hostname
+ );
+ }
+ });
+ }
+
+ if (
+ param.Default !== undefined ||
+ param.AcceptThirdParty !== undefined ||
+ param.Locked
+ ) {
+ const ACCEPT_COOKIES = 0;
+ const REJECT_THIRD_PARTY_COOKIES = 1;
+ const REJECT_ALL_COOKIES = 2;
+ const REJECT_UNVISITED_THIRD_PARTY = 3;
+
+ let newCookieBehavior = ACCEPT_COOKIES;
+ if (param.Default !== undefined && !param.Default) {
+ newCookieBehavior = REJECT_ALL_COOKIES;
+ } else if (param.AcceptThirdParty) {
+ if (param.AcceptThirdParty == "never") {
+ newCookieBehavior = REJECT_THIRD_PARTY_COOKIES;
+ } else if (param.AcceptThirdParty == "from-visited") {
+ newCookieBehavior = REJECT_UNVISITED_THIRD_PARTY;
+ }
+ }
+
+ PoliciesUtils.setDefaultPref(
+ "network.cookie.cookieBehavior",
+ newCookieBehavior,
+ param.Locked
+ );
+ PoliciesUtils.setDefaultPref(
+ "network.cookie.cookieBehavior.pbmode",
+ newCookieBehavior,
+ param.Locked
+ );
+ }
+
+ if (param.ExpireAtSessionEnd != undefined) {
+ lazy.log.error(
+ "'ExpireAtSessionEnd' has been deprecated and it has no effect anymore."
+ );
+ }
+ },
+ },
+
+ DefaultDownloadDirectory: {
+ onBeforeAddons(manager, param) {
+ PoliciesUtils.setDefaultPref(
+ "browser.download.dir",
+ replacePathVariables(param)
+ );
+ // If a custom download directory is being used, just lock folder list to 2.
+ setAndLockPref("browser.download.folderList", 2);
+ },
+ },
+
+ DisableAppUpdate: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("appUpdate");
+ }
+ },
+ },
+
+ DisableBuiltinPDFViewer: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ setAndLockPref("pdfjs.disabled", true);
+ }
+ },
+ },
+
+ DisabledCiphers: {
+ onBeforeAddons(manager, param) {
+ let cipherPrefs = {
+ TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:
+ "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256",
+ TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:
+ "security.ssl3.ecdhe_ecdsa_aes_128_gcm_sha256",
+ TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:
+ "security.ssl3.ecdhe_ecdsa_chacha20_poly1305_sha256",
+ TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256:
+ "security.ssl3.ecdhe_rsa_chacha20_poly1305_sha256",
+ TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:
+ "security.ssl3.ecdhe_ecdsa_aes_256_gcm_sha384",
+ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:
+ "security.ssl3.ecdhe_rsa_aes_256_gcm_sha384",
+ TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA:
+ "security.ssl3.ecdhe_rsa_aes_128_sha",
+ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA:
+ "security.ssl3.ecdhe_ecdsa_aes_128_sha",
+ TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:
+ "security.ssl3.ecdhe_rsa_aes_256_sha",
+ TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA:
+ "security.ssl3.ecdhe_ecdsa_aes_256_sha",
+ TLS_DHE_RSA_WITH_AES_128_CBC_SHA: "security.ssl3.dhe_rsa_aes_128_sha",
+ TLS_DHE_RSA_WITH_AES_256_CBC_SHA: "security.ssl3.dhe_rsa_aes_256_sha",
+ TLS_RSA_WITH_AES_128_GCM_SHA256: "security.ssl3.rsa_aes_128_gcm_sha256",
+ TLS_RSA_WITH_AES_256_GCM_SHA384: "security.ssl3.rsa_aes_256_gcm_sha384",
+ TLS_RSA_WITH_AES_128_CBC_SHA: "security.ssl3.rsa_aes_128_sha",
+ TLS_RSA_WITH_AES_256_CBC_SHA: "security.ssl3.rsa_aes_256_sha",
+ TLS_RSA_WITH_3DES_EDE_CBC_SHA:
+ "security.ssl3.deprecated.rsa_des_ede3_sha",
+ };
+
+ for (let cipher in param) {
+ setAndLockPref(cipherPrefs[cipher], !param[cipher]);
+ }
+ },
+ },
+
+ DisableDeveloperTools: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ setAndLockPref("devtools.policy.disabled", true);
+ setAndLockPref("devtools.chrome.enabled", false);
+
+ manager.disallowFeature("devtools");
+ blockAboutPage(manager, "about:debugging");
+ blockAboutPage(manager, "about:devtools-toolbox");
+ }
+ },
+ },
+
+ DisableMasterPasswordCreation: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ manager.disallowFeature("createMasterPassword");
+ }
+ },
+ },
+
+ DisablePasswordReveal: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ manager.disallowFeature("passwordReveal");
+ }
+ },
+ },
+
+ DisableSafeMode: {
+ onBeforeUIStartup(manager, param) {
+ if (param) {
+ manager.disallowFeature("safeMode");
+ }
+ },
+ },
+
+ DisableSecurityBypass: {
+ onBeforeUIStartup(manager, param) {
+ if ("InvalidCertificate" in param) {
+ setAndLockPref(
+ "security.certerror.hideAddException",
+ param.InvalidCertificate
+ );
+ }
+
+ if ("SafeBrowsing" in param) {
+ setAndLockPref(
+ "browser.safebrowsing.allowOverride",
+ !param.SafeBrowsing
+ );
+ }
+ },
+ },
+
+ DisableSystemAddonUpdate: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("SysAddonUpdate");
+ }
+ },
+ },
+
+ DisableTelemetry: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ setAndLockPref("datareporting.healthreport.uploadEnabled", false);
+ setAndLockPref("datareporting.policy.dataSubmissionEnabled", false);
+ setAndLockPref("toolkit.telemetry.archive.enabled", false);
+ blockAboutPage(manager, "about:telemetry");
+ }
+ },
+ },
+
+ DNSOverHTTPS: {
+ onBeforeAddons(manager, param) {
+ let locked = false;
+ if ("Locked" in param) {
+ locked = param.Locked;
+ }
+ if ("Enabled" in param) {
+ let mode = param.Enabled ? 2 : 5;
+ PoliciesUtils.setDefaultPref("network.trr.mode", mode, locked);
+ }
+ if ("ProviderURL" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.trr.uri",
+ param.ProviderURL.href,
+ locked
+ );
+ }
+ if ("ExcludedDomains" in param) {
+ PoliciesUtils.setDefaultPref(
+ "network.trr.excluded-domains",
+ param.ExcludedDomains.join(","),
+ locked
+ );
+ }
+ },
+ },
+
+ DownloadDirectory: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("browser.download.dir", replacePathVariables(param));
+ // If a custom download directory is being used, just lock folder list to 2.
+ setAndLockPref("browser.download.folderList", 2);
+ // Per Chrome spec, user can't choose to download every time
+ // if this is set.
+ setAndLockPref("browser.download.useDownloadDir", true);
+ },
+ },
+
+ Extensions: {
+ onBeforeUIStartup(manager, param) {
+ let uninstallingPromise = Promise.resolve();
+ if ("Uninstall" in param) {
+ uninstallingPromise = runOncePerModification(
+ "extensionsUninstall",
+ JSON.stringify(param.Uninstall),
+ async () => {
+ // If we're uninstalling add-ons, re-run the extensionsInstall runOnce even if it hasn't
+ // changed, which will allow add-ons to be updated.
+ Services.prefs.clearUserPref(
+ "browser.policies.runOncePerModification.extensionsInstall"
+ );
+ let addons = await lazy.AddonManager.getAddonsByIDs(
+ param.Uninstall
+ );
+ for (let addon of addons) {
+ if (addon) {
+ try {
+ await addon.uninstall();
+ } catch (e) {
+ // This can fail for add-ons that can't be uninstalled.
+ lazy.log.debug(
+ `Add-on ID (${addon.id}) couldn't be uninstalled.`
+ );
+ }
+ }
+ }
+ }
+ );
+ }
+ if ("Install" in param) {
+ runOncePerModification(
+ "extensionsInstall",
+ JSON.stringify(param.Install),
+ async () => {
+ await uninstallingPromise;
+ for (let location of param.Install) {
+ let uri;
+ try {
+ // We need to try as a file first because
+ // Windows paths are valid URIs.
+ // This is done for legacy support (old API)
+ let xpiFile = new lazy.FileUtils.File(location);
+ uri = Services.io.newFileURI(xpiFile);
+ } catch (e) {
+ uri = Services.io.newURI(location);
+ }
+ installAddonFromURL(uri.spec);
+ }
+ }
+ );
+ }
+ if ("Locked" in param) {
+ for (let ID of param.Locked) {
+ manager.disallowFeature(`uninstall-extension:${ID}`);
+ manager.disallowFeature(`disable-extension:${ID}`);
+ }
+ }
+ },
+ },
+
+ ExtensionSettings: {
+ onBeforeAddons(manager, param) {
+ try {
+ manager.setExtensionSettings(param);
+ } catch (e) {
+ lazy.log.error("Invalid ExtensionSettings");
+ }
+ },
+ async onBeforeUIStartup(manager, param) {
+ let extensionSettings = param;
+ let blockAllExtensions = false;
+ if ("*" in extensionSettings) {
+ if (
+ "installation_mode" in extensionSettings["*"] &&
+ extensionSettings["*"].installation_mode == "blocked"
+ ) {
+ blockAllExtensions = true;
+ // Turn off discovery pane in about:addons
+ setAndLockPref("extensions.getAddons.showPane", false);
+ // Turn off recommendations
+ setAndLockPref(
+ "extensions.htmlaboutaddons.recommendations.enable",
+ false
+ );
+ // Block about:debugging
+ blockAboutPage(manager, "about:debugging");
+ }
+ if ("restricted_domains" in extensionSettings["*"]) {
+ let restrictedDomains = Services.prefs
+ .getCharPref("extensions.webextensions.restrictedDomains")
+ .split(",");
+ setAndLockPref(
+ "extensions.webextensions.restrictedDomains",
+ restrictedDomains
+ .concat(extensionSettings["*"].restricted_domains)
+ .join(",")
+ );
+ }
+ }
+ let addons = await lazy.AddonManager.getAllAddons();
+ let allowedExtensions = [];
+ for (let extensionID in extensionSettings) {
+ if (extensionID == "*") {
+ // Ignore global settings
+ continue;
+ }
+ if ("installation_mode" in extensionSettings[extensionID]) {
+ if (
+ extensionSettings[extensionID].installation_mode ==
+ "force_installed" ||
+ extensionSettings[extensionID].installation_mode ==
+ "normal_installed"
+ ) {
+ if (!extensionSettings[extensionID].install_url) {
+ throw new Error(`Missing install_url for ${extensionID}`);
+ }
+ installAddonFromURL(
+ extensionSettings[extensionID].install_url,
+ extensionID,
+ addons.find(addon => addon.id == extensionID)
+ );
+ manager.disallowFeature(`uninstall-extension:${extensionID}`);
+ if (
+ extensionSettings[extensionID].installation_mode ==
+ "force_installed"
+ ) {
+ manager.disallowFeature(`disable-extension:${extensionID}`);
+ }
+ allowedExtensions.push(extensionID);
+ } else if (
+ extensionSettings[extensionID].installation_mode == "allowed"
+ ) {
+ allowedExtensions.push(extensionID);
+ } else if (
+ extensionSettings[extensionID].installation_mode == "blocked"
+ ) {
+ if (addons.find(addon => addon.id == extensionID)) {
+ // Can't use the addon from getActiveAddons since it doesn't have uninstall.
+ let addon = await lazy.AddonManager.getAddonByID(extensionID);
+ try {
+ await addon.uninstall();
+ } catch (e) {
+ // This can fail for add-ons that can't be uninstalled.
+ lazy.log.debug(
+ `Add-on ID (${addon.id}) couldn't be uninstalled.`
+ );
+ }
+ }
+ }
+ }
+ }
+ if (blockAllExtensions) {
+ for (let addon of addons) {
+ if (
+ addon.isSystem ||
+ addon.isBuiltin ||
+ !(addon.scope & lazy.AddonManager.SCOPE_PROFILE)
+ ) {
+ continue;
+ }
+ if (!allowedExtensions.includes(addon.id)) {
+ try {
+ // Can't use the addon from getActiveAddons since it doesn't have uninstall.
+ let addonToUninstall = await lazy.AddonManager.getAddonByID(
+ addon.id
+ );
+ await addonToUninstall.uninstall();
+ } catch (e) {
+ // This can fail for add-ons that can't be uninstalled.
+ lazy.log.debug(
+ `Add-on ID (${addon.id}) couldn't be uninstalled.`
+ );
+ }
+ }
+ }
+ }
+ },
+ },
+
+ ExtensionUpdate: {
+ onBeforeAddons(manager, param) {
+ if (!param) {
+ setAndLockPref("extensions.update.enabled", param);
+ }
+ },
+ },
+
+ Handlers: {
+ onBeforeAddons(manager, param) {
+ if ("mimeTypes" in param) {
+ for (let mimeType in param.mimeTypes) {
+ let mimeInfo = param.mimeTypes[mimeType];
+ let realMIMEInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ mimeType,
+ ""
+ );
+ processMIMEInfo(mimeInfo, realMIMEInfo);
+ }
+ }
+ if ("extensions" in param) {
+ for (let extension in param.extensions) {
+ let mimeInfo = param.extensions[extension];
+ try {
+ let realMIMEInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ "",
+ extension
+ );
+ processMIMEInfo(mimeInfo, realMIMEInfo);
+ } catch (e) {
+ lazy.log.error(`Invalid file extension (${extension})`);
+ }
+ }
+ }
+ if ("schemes" in param) {
+ for (let scheme in param.schemes) {
+ let handlerInfo = param.schemes[scheme];
+ let realHandlerInfo =
+ lazy.gExternalProtocolService.getProtocolHandlerInfo(scheme);
+ processMIMEInfo(handlerInfo, realHandlerInfo);
+ }
+ }
+ },
+ },
+
+ HardwareAcceleration: {
+ onBeforeAddons(manager, param) {
+ if (!param) {
+ setAndLockPref("layers.acceleration.disabled", true);
+ }
+ },
+ },
+
+ InstallAddonsPermission: {
+ onBeforeUIStartup(manager, param) {
+ if ("Allow" in param) {
+ addAllowDenyPermissions("install", param.Allow, null);
+ }
+ if ("Default" in param) {
+ setAndLockPref("xpinstall.enabled", param.Default);
+ if (!param.Default) {
+ blockAboutPage(manager, "about:debugging");
+ manager.disallowFeature("xpinstall");
+ }
+ }
+ },
+ },
+
+ ManualAppUpdateOnly: {
+ onBeforeAddons(manager, param) {
+ if (param) {
+ manager.disallowFeature("autoAppUpdateChecking");
+ }
+ },
+ },
+
+ NetworkPrediction: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("network.dns.disablePrefetch", !param);
+ setAndLockPref("network.dns.disablePrefetchFromHTTPS", !param);
+ },
+ },
+
+ OfferToSaveLogins: {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("signon.rememberSignons", param);
+ setAndLockPref("services.passwordSavingEnabled", param);
+ },
+ },
+
+ OfferToSaveLoginsDefault: {
+ onBeforeUIStartup(manager, param) {
+ let policies = Services.policies.getActivePolicies();
+ if ("OfferToSaveLogins" in policies) {
+ lazy.log.error(
+ `OfferToSaveLoginsDefault ignored because OfferToSaveLogins is present.`
+ );
+ } else {
+ PoliciesUtils.setDefaultPref("signon.rememberSignons", param);
+ }
+ },
+ },
+
+ PasswordManagerEnabled: {
+ onBeforeUIStartup(manager, param) {
+ if (!param) {
+ blockAboutPage(manager, "about:logins", true);
+ setAndLockPref("pref.privacy.disable_button.view_passwords", true);
+ }
+ setAndLockPref("signon.rememberSignons", param);
+ },
+ },
+
+ PDFjs: {
+ onBeforeAddons(manager, param) {
+ if ("Enabled" in param) {
+ setAndLockPref("pdfjs.disabled", !param.Enabled);
+ }
+ if ("EnablePermissions" in param) {
+ setAndLockPref("pdfjs.enablePermissions", !param.Enabled);
+ }
+ },
+ },
+
+ Preferences: {
+ onBeforeAddons(manager, param) {
+ const allowedPrefixes = [
+ "accessibility.",
+ "app.update.",
+ "browser.",
+ "calendar.",
+ "chat.",
+ "datareporting.policy.",
+ "dom.",
+ "extensions.",
+ "general.autoScroll",
+ "general.smoothScroll",
+ "geo.",
+ "gfx.",
+ "intl.",
+ "layers.",
+ "layout.",
+ "mail.",
+ "mailnews.",
+ "media.",
+ "network.",
+ "pdfjs.",
+ "places.",
+ "print.",
+ "signon.",
+ "spellchecker.",
+ "ui.",
+ "widget.",
+ ];
+ const allowedSecurityPrefs = [
+ "security.default_personal_cert",
+ "security.insecure_connection_text.enabled",
+ "security.insecure_connection_text.pbmode.enabled",
+ "security.insecure_field_warning.contextual.enabled",
+ "security.mixed_content.block_active_content",
+ "security.osclientcerts.autoload",
+ "security.ssl.errorReporting.enabled",
+ "security.tls.hello_downgrade_check",
+ "security.tls.version.enable-deprecated",
+ "security.warn_submit_secure_to_insecure",
+ ];
+ const blockedPrefs = [
+ "app.update.channel",
+ "app.update.lastUpdateTime",
+ "app.update.migrated",
+ ];
+
+ for (let preference in param) {
+ if (blockedPrefs.includes(preference)) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Preference not allowed for security reasons.`
+ );
+ continue;
+ }
+ if (preference.startsWith("security.")) {
+ if (!allowedSecurityPrefs.includes(preference)) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Preference not allowed for security reasons.`
+ );
+ continue;
+ }
+ } else if (
+ !allowedPrefixes.some(prefix => preference.startsWith(prefix))
+ ) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Preference not allowed for stability reasons.`
+ );
+ continue;
+ }
+ if (typeof param[preference] != "object") {
+ // Legacy policy preferences
+ setAndLockPref(preference, param[preference]);
+ } else {
+ if (param[preference].Status == "clear") {
+ Services.prefs.clearUserPref(preference);
+ continue;
+ }
+
+ if (param[preference].Status == "user") {
+ var prefBranch = Services.prefs;
+ } else {
+ prefBranch = Services.prefs.getDefaultBranch("");
+ }
+
+ try {
+ switch (typeof param[preference].Value) {
+ case "boolean":
+ prefBranch.setBoolPref(preference, param[preference].Value);
+ break;
+
+ case "number":
+ if (!Number.isInteger(param[preference].Value)) {
+ throw new Error(`Non-integer value for ${preference}`);
+ }
+
+ // This is ugly, but necessary. On Windows GPO and macOS
+ // configs, booleans are converted to 0/1. In the previous
+ // Preferences implementation, the schema took care of
+ // automatically converting these values to booleans.
+ // Since we allow arbitrary prefs now, we have to do
+ // something different. See bug 1666836.
+ if (
+ prefBranch.getPrefType(preference) == prefBranch.PREF_INT ||
+ ![0, 1].includes(param[preference].Value)
+ ) {
+ prefBranch.setIntPref(preference, param[preference].Value);
+ } else {
+ prefBranch.setBoolPref(preference, !!param[preference].Value);
+ }
+ break;
+
+ case "string":
+ prefBranch.setStringPref(preference, param[preference].Value);
+ break;
+ }
+ } catch (e) {
+ lazy.log.error(
+ `Unable to set preference ${preference}. Probable type mismatch.`
+ );
+ }
+
+ if (param[preference].Status == "locked") {
+ Services.prefs.lockPref(preference);
+ }
+ }
+ }
+ },
+ },
+
+ PrimaryPassword: {
+ onAllWindowsRestored(manager, param) {
+ if (param) {
+ manager.disallowFeature("removeMasterPassword");
+ } else {
+ manager.disallowFeature("createMasterPassword");
+ }
+ },
+ },
+
+ PromptForDownloadLocation: {
+ onBeforeAddons(manager, param) {
+ setAndLockPref("browser.download.useDownloadDir", !param);
+ },
+ },
+
+ Proxy: {
+ onBeforeAddons(manager, param) {
+ if (param.Locked) {
+ manager.disallowFeature("changeProxySettings");
+ lazy.ProxyPolicies.configureProxySettings(param, setAndLockPref);
+ } else {
+ lazy.ProxyPolicies.configureProxySettings(
+ param,
+ PoliciesUtils.setDefaultPref
+ );
+ }
+ },
+ },
+
+ RequestedLocales: {
+ onBeforeAddons(manager, param) {
+ let requestedLocales;
+ if (Array.isArray(param)) {
+ requestedLocales = param;
+ } else if (param) {
+ requestedLocales = param.split(",");
+ } else {
+ requestedLocales = [];
+ }
+ runOncePerModification(
+ "requestedLocales",
+ JSON.stringify(requestedLocales),
+ () => {
+ Services.locale.requestedLocales = requestedLocales;
+ }
+ );
+ },
+ },
+
+ SearchEngines: {
+ onBeforeUIStartup(manager, param) {
+ if (param.PreventInstalls) {
+ manager.disallowFeature("installSearchEngine", true);
+ }
+ },
+ onAllWindowsRestored(manager, param) {
+ Services.search.init().then(async () => {
+ // Adding of engines is handled by the SearchService in the init().
+ // Remove can happen after those are added - no engines are allowed
+ // to replace the application provided engines, even if they have been
+ // removed.
+ if (param.Remove) {
+ // Only rerun if the list of engine names has changed.
+ await runOncePerModification(
+ "removeSearchEngines",
+ JSON.stringify(param.Remove),
+ async function () {
+ for (let engineName of param.Remove) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (engine) {
+ try {
+ await Services.search.removeEngine(engine);
+ } catch (ex) {
+ lazy.log.error("Unable to remove the search engine", ex);
+ }
+ }
+ }
+ }
+ );
+ }
+ if (param.Default) {
+ await runOncePerModification(
+ "setDefaultSearchEngine",
+ param.Default,
+ async () => {
+ let defaultEngine;
+ try {
+ defaultEngine = Services.search.getEngineByName(param.Default);
+ if (!defaultEngine) {
+ throw new Error("No engine by that name could be found");
+ }
+ } catch (ex) {
+ lazy.log.error(
+ `Search engine lookup failed when attempting to set ` +
+ `the default engine. Requested engine was ` +
+ `"${param.Default}".`,
+ ex
+ );
+ }
+ if (defaultEngine) {
+ try {
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_ENTERPRISE
+ );
+ } catch (ex) {
+ lazy.log.error("Unable to set the default search engine", ex);
+ }
+ }
+ }
+ );
+ }
+ if (param.DefaultPrivate) {
+ await runOncePerModification(
+ "setDefaultPrivateSearchEngine",
+ param.DefaultPrivate,
+ async () => {
+ let defaultPrivateEngine;
+ try {
+ defaultPrivateEngine = Services.search.getEngineByName(
+ param.DefaultPrivate
+ );
+ if (!defaultPrivateEngine) {
+ throw new Error("No engine by that name could be found");
+ }
+ } catch (ex) {
+ lazy.log.error(
+ `Search engine lookup failed when attempting to set ` +
+ `the default private engine. Requested engine was ` +
+ `"${param.DefaultPrivate}".`,
+ ex
+ );
+ }
+ if (defaultPrivateEngine) {
+ try {
+ await Services.search.setDefaultPrivate(
+ defaultPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_ENTERPRISE
+ );
+ } catch (ex) {
+ lazy.log.error(
+ "Unable to set the default private search engine",
+ ex
+ );
+ }
+ }
+ }
+ );
+ }
+ });
+ },
+ },
+
+ SSLVersionMax: {
+ onBeforeAddons(manager, param) {
+ let tlsVersion;
+ switch (param) {
+ case "tls1":
+ tlsVersion = 1;
+ break;
+ case "tls1.1":
+ tlsVersion = 2;
+ break;
+ case "tls1.2":
+ tlsVersion = 3;
+ break;
+ case "tls1.3":
+ tlsVersion = 4;
+ break;
+ }
+ setAndLockPref("security.tls.version.max", tlsVersion);
+ },
+ },
+
+ SSLVersionMin: {
+ onBeforeAddons(manager, param) {
+ let tlsVersion;
+ switch (param) {
+ case "tls1":
+ tlsVersion = 1;
+ break;
+ case "tls1.1":
+ tlsVersion = 2;
+ break;
+ case "tls1.2":
+ tlsVersion = 3;
+ break;
+ case "tls1.3":
+ tlsVersion = 4;
+ break;
+ }
+ setAndLockPref("security.tls.version.min", tlsVersion);
+ },
+ },
+};
+
+/*
+ * ====================
+ * = HELPER FUNCTIONS =
+ * ====================
+ *
+ * The functions below are helpers to be used by several policies.
+ */
+
+/**
+ * setAndLockPref
+ *
+ * Sets the _default_ value of a pref, and locks it (meaning that
+ * the default value will always be returned, independent from what
+ * is stored as the user value).
+ * The value is only changed in memory, and not stored to disk.
+ *
+ * @param {string} prefName
+ * The pref to be changed
+ * @param {boolean,number,string} prefValue
+ * The value to set and lock
+ */
+export function setAndLockPref(prefName, prefValue) {
+ PoliciesUtils.setDefaultPref(prefName, prefValue, true);
+}
+
+/**
+ * setDefaultPref
+ *
+ * Sets the _default_ value of a pref and optionally locks it.
+ * The value is only changed in memory, and not stored to disk.
+ *
+ * @param {string} prefName
+ * The pref to be changed
+ * @param {boolean,number,string} prefValue
+ * The value to set
+ * @param {boolean} locked
+ * Optionally lock the pref
+ */
+export var PoliciesUtils = {
+ setDefaultPref(prefName, prefValue, locked = false) {
+ if (Services.prefs.prefIsLocked(prefName)) {
+ Services.prefs.unlockPref(prefName);
+ }
+
+ let defaults = Services.prefs.getDefaultBranch("");
+
+ switch (typeof prefValue) {
+ case "boolean":
+ defaults.setBoolPref(prefName, prefValue);
+ break;
+
+ case "number":
+ if (!Number.isInteger(prefValue)) {
+ throw new Error(`Non-integer value for ${prefName}`);
+ }
+
+ // This is ugly, but necessary. On Windows GPO and macOS
+ // configs, booleans are converted to 0/1. In the previous
+ // Preferences implementation, the schema took care of
+ // automatically converting these values to booleans.
+ // Since we allow arbitrary prefs now, we have to do
+ // something different. See bug 1666836.
+ if (
+ defaults.getPrefType(prefName) == defaults.PREF_INT ||
+ ![0, 1].includes(prefValue)
+ ) {
+ defaults.setIntPref(prefName, prefValue);
+ } else {
+ defaults.setBoolPref(prefName, !!prefValue);
+ }
+ break;
+
+ case "string":
+ defaults.setStringPref(prefName, prefValue);
+ break;
+ }
+
+ if (locked) {
+ Services.prefs.lockPref(prefName);
+ }
+ },
+};
+
+/**
+ * addAllowDenyPermissions
+ *
+ * Helper function to call the permissions manager (Services.perms.addFromPrincipal)
+ * for two arrays of URLs.
+ *
+ * @param {string} permissionName
+ * The name of the permission to change
+ * @param {Array} allowList
+ * The list of URLs to be set as ALLOW_ACTION for the chosen permission.
+ * @param {Array} blockList
+ * The list of URLs to be set as DENY_ACTION for the chosen permission.
+ */
+function addAllowDenyPermissions(permissionName, allowList, blockList) {
+ allowList = allowList || [];
+ blockList = blockList || [];
+
+ for (let origin of allowList) {
+ try {
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin),
+ permissionName,
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_POLICY
+ );
+ } catch (ex) {
+ lazy.log
+ .error(`Added by default for ${permissionName} permission in the permission
+ manager - ${origin.href}`);
+ }
+ }
+
+ for (let origin of blockList) {
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin),
+ permissionName,
+ Ci.nsIPermissionManager.DENY_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_POLICY
+ );
+ }
+}
+
+/**
+ * runOnce
+ *
+ * Helper function to run a callback only once per policy.
+ *
+ * @param {string} actionName
+ * A given name which will be used to track if this callback has run.
+ * @param {Function} callback
+ * The callback to run only once.
+ */
+export function runOnce(actionName, callback) {
+ let prefName = `browser.policies.runonce.${actionName}`;
+ if (Services.prefs.getBoolPref(prefName, false)) {
+ lazy.log.debug(
+ `Not running action ${actionName} again because it has already run.`
+ );
+ return;
+ }
+ Services.prefs.setBoolPref(prefName, true);
+ callback();
+}
+
+/**
+ * runOncePerModification
+ *
+ * Helper function similar to runOnce. The difference is that runOnce runs the
+ * callback once when the policy is set, then never again.
+ * runOncePerModification runs the callback once each time the policy value
+ * changes from its previous value.
+ * If the callback that was passed is an async function, you can await on this
+ * function to await for the callback.
+ *
+ * @param {string} actionName
+ * A given name which will be used to track if this callback has run.
+ * This string will be part of a pref name.
+ * @param {string} policyValue
+ * The current value of the policy. This will be compared to previous
+ * values given to this function to determine if the policy value has
+ * changed. Regardless of the data type of the policy, this must be a
+ * string.
+ * @param {Function} callback
+ * The callback to be run when the pref value changes
+ * @returns Promise
+ * A promise that will resolve once the callback finishes running.
+ *
+ */
+async function runOncePerModification(actionName, policyValue, callback) {
+ let prefName = `browser.policies.runOncePerModification.${actionName}`;
+ let oldPolicyValue = Services.prefs.getStringPref(prefName, undefined);
+ if (policyValue === oldPolicyValue) {
+ lazy.log.debug(
+ `Not running action ${actionName} again because the policy's value is unchanged`
+ );
+ return Promise.resolve();
+ }
+ Services.prefs.setStringPref(prefName, policyValue);
+ return callback();
+}
+
+/**
+ * clearRunOnceModification
+ *
+ * Helper function that clears a runOnce policy.
+ */
+function clearRunOnceModification(actionName) {
+ let prefName = `browser.policies.runOncePerModification.${actionName}`;
+ Services.prefs.clearUserPref(prefName);
+}
+
+function replacePathVariables(path) {
+ if (path.includes("${home}")) {
+ return path.replace("${home}", lazy.FileUtils.getFile("Home", []).path);
+ }
+ return path;
+}
+
+/**
+ * installAddonFromURL
+ *
+ * Helper function that installs an addon from a URL
+ * and verifies that the addon ID matches.
+ */
+function installAddonFromURL(url, extensionID, addon) {
+ if (
+ addon &&
+ addon.sourceURI &&
+ addon.sourceURI.spec == url &&
+ !addon.sourceURI.schemeIs("file")
+ ) {
+ // It's the same addon, don't reinstall.
+ return;
+ }
+ lazy.AddonManager.getInstallForURL(url, {
+ telemetryInfo: { source: "enterprise-policy" },
+ }).then(install => {
+ if (install.addon && install.addon.appDisabled) {
+ lazy.log.error(`Incompatible add-on - ${install.addon.id}`);
+ install.cancel();
+ return;
+ }
+ let listener = {
+ /* eslint-disable-next-line no-shadow */
+ onDownloadEnded: install => {
+ // Install failed, error will be reported elsewhere.
+ if (!install.addon) {
+ return;
+ }
+ if (extensionID && install.addon.id != extensionID) {
+ lazy.log.error(
+ `Add-on downloaded from ${url} had unexpected id (got ${install.addon.id} expected ${extensionID})`
+ );
+ install.removeListener(listener);
+ install.cancel();
+ }
+ if (install.addon.appDisabled) {
+ lazy.log.error(`Incompatible add-on - ${url}`);
+ install.removeListener(listener);
+ install.cancel();
+ }
+ if (
+ addon &&
+ Services.vc.compare(addon.version, install.addon.version) == 0
+ ) {
+ lazy.log.debug(
+ "Installation cancelled because versions are the same"
+ );
+ install.removeListener(listener);
+ install.cancel();
+ }
+ },
+ onDownloadFailed: () => {
+ install.removeListener(listener);
+ lazy.log.error(
+ `Download failed - ${lazy.AddonManager.errorToString(
+ install.error
+ )} - ${url}`
+ );
+ clearRunOnceModification("extensionsInstall");
+ },
+ onInstallFailed: () => {
+ install.removeListener(listener);
+ lazy.log.error(
+ `Installation failed - ${lazy.AddonManager.errorToString(
+ install.error
+ )} - {url}`
+ );
+ },
+ /* eslint-disable-next-line no-shadow */
+ onInstallEnded: (install, addon) => {
+ if (addon.type == "theme") {
+ addon.enable();
+ }
+ install.removeListener(listener);
+ lazy.log.debug(`Installation succeeded - ${url}`);
+ },
+ };
+ install.addListener(listener);
+ install.install();
+ });
+}
+
+let gBlockedAboutPages = [];
+
+function clearBlockedAboutPages() {
+ gBlockedAboutPages = [];
+}
+
+function blockAboutPage(manager, feature, neededOnContentProcess = false) {
+ addChromeURLBlocker();
+ gBlockedAboutPages.push(feature);
+
+ try {
+ let aboutModule = Cc[ABOUT_CONTRACT + feature.split(":")[1]].getService(
+ Ci.nsIAboutModule
+ );
+ let chromeURL = aboutModule.getChromeURI(Services.io.newURI(feature)).spec;
+ gBlockedAboutPages.push(chromeURL);
+ } catch (e) {
+ // Some about pages don't have chrome URLS (compat)
+ }
+}
+
+let ChromeURLBlockPolicy = {
+ shouldLoad(contentLocation, loadInfo, mimeTypeGuess) {
+ let contentType = loadInfo.externalContentPolicyType;
+ if (
+ (contentLocation.scheme != "chrome" &&
+ contentLocation.scheme != "about") ||
+ (contentType != Ci.nsIContentPolicy.TYPE_DOCUMENT &&
+ contentType != Ci.nsIContentPolicy.TYPE_SUBDOCUMENT)
+ ) {
+ return Ci.nsIContentPolicy.ACCEPT;
+ }
+ if (
+ gBlockedAboutPages.some(function (aboutPage) {
+ return contentLocation.spec.startsWith(aboutPage);
+ })
+ ) {
+ return Ci.nsIContentPolicy.REJECT_POLICY;
+ }
+ return Ci.nsIContentPolicy.ACCEPT;
+ },
+ shouldProcess(contentLocation, loadInfo, mimeTypeGuess) {
+ return Ci.nsIContentPolicy.ACCEPT;
+ },
+ classDescription: "Policy Engine Content Policy",
+ contractID: "@mozilla-org/policy-engine-content-policy-service;1",
+ classID: Components.ID("{ba7b9118-cabc-4845-8b26-4215d2a59ed7}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPolicy"]),
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ },
+};
+
+function addChromeURLBlocker() {
+ if (Cc[ChromeURLBlockPolicy.contractID]) {
+ return;
+ }
+
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.registerFactory(
+ ChromeURLBlockPolicy.classID,
+ ChromeURLBlockPolicy.classDescription,
+ ChromeURLBlockPolicy.contractID,
+ ChromeURLBlockPolicy
+ );
+
+ Services.catMan.addCategoryEntry(
+ "content-policy",
+ ChromeURLBlockPolicy.contractID,
+ ChromeURLBlockPolicy.contractID,
+ false,
+ true
+ );
+}
+
+function pemToBase64(pem) {
+ return pem
+ .replace(/(.*)-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----(.*)/, "")
+ .replace(/[\r\n]/g, "");
+}
+
+function processMIMEInfo(mimeInfo, realMIMEInfo) {
+ if ("handlers" in mimeInfo) {
+ let firstHandler = true;
+ for (let handler of mimeInfo.handlers) {
+ // handler can be null which means they don't
+ // want a preferred handler.
+ if (handler) {
+ let handlerApp;
+ if ("path" in handler) {
+ try {
+ let file = new lazy.FileUtils.File(handler.path);
+ handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = file;
+ } catch (ex) {
+ lazy.log.error(
+ `Unable to create handler executable (${handler.path})`
+ );
+ continue;
+ }
+ } else if ("uriTemplate" in handler) {
+ let templateURL = new URL(handler.uriTemplate);
+ if (templateURL.protocol != "https:") {
+ lazy.log.error(
+ `Web handler must be https (${handler.uriTemplate})`
+ );
+ continue;
+ }
+ if (
+ !templateURL.pathname.includes("%s") &&
+ !templateURL.search.includes("%s")
+ ) {
+ lazy.log.error(
+ `Web handler must contain %s (${handler.uriTemplate})`
+ );
+ continue;
+ }
+ handlerApp = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+ ].createInstance(Ci.nsIWebHandlerApp);
+ handlerApp.uriTemplate = handler.uriTemplate;
+ } else {
+ lazy.log.error("Invalid handler");
+ continue;
+ }
+ if ("name" in handler) {
+ handlerApp.name = handler.name;
+ }
+ realMIMEInfo.possibleApplicationHandlers.appendElement(handlerApp);
+ if (firstHandler) {
+ realMIMEInfo.preferredApplicationHandler = handlerApp;
+ }
+ }
+ firstHandler = false;
+ }
+ }
+ if ("action" in mimeInfo) {
+ let action = realMIMEInfo[mimeInfo.action];
+ if (
+ action == realMIMEInfo.useHelperApp &&
+ !realMIMEInfo.possibleApplicationHandlers.length
+ ) {
+ lazy.log.error("useHelperApp requires a handler");
+ return;
+ }
+ realMIMEInfo.preferredAction = action;
+ }
+ if ("ask" in mimeInfo) {
+ realMIMEInfo.alwaysAskBeforeHandling = mimeInfo.ask;
+ }
+ lazy.gHandlerService.store(realMIMEInfo);
+}
diff --git a/comm/mail/components/enterprisepolicies/content/aboutPolicies.js b/comm/mail/components/enterprisepolicies/content/aboutPolicies.js
new file mode 100644
index 0000000000..850286e001
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/aboutPolicies.js
@@ -0,0 +1,410 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ schema: "resource:///modules/policies/schema.sys.mjs",
+});
+
+function col(text, className) {
+ let column = document.createElement("td");
+ if (className) {
+ column.classList.add(className);
+ }
+ let content = document.createTextNode(text);
+ column.appendChild(content);
+ return column;
+}
+
+function addMissingColumns() {
+ const table = document.getElementById("activeContent");
+ let maxColumns = 0;
+
+ // count the number of columns per row and set the max number of columns
+ for (let i = 0, length = table.rows.length; i < length; i++) {
+ if (maxColumns < table.rows[i].cells.length) {
+ maxColumns = table.rows[i].cells.length;
+ }
+ }
+
+ // add the missing columns
+ for (let i = 0, length = table.rows.length; i < length; i++) {
+ const rowLength = table.rows[i].cells.length;
+
+ if (rowLength < maxColumns) {
+ let missingColumns = maxColumns - rowLength;
+
+ while (missingColumns > 0) {
+ table.rows[i].insertCell();
+ missingColumns--;
+ }
+ }
+ }
+}
+
+/*
+ * This function generates the Active Policies content to be displayed by calling
+ * a recursive function called generatePolicy() according to the policy schema.
+ */
+
+function generateActivePolicies(data) {
+ let new_cont = document.getElementById("activeContent");
+ new_cont.classList.add("active-policies");
+
+ let policy_count = 0;
+
+ for (let policyName in data) {
+ const color_class = ++policy_count % 2 === 0 ? "even" : "odd";
+
+ if (lazy.schema.properties[policyName].type == "array") {
+ for (let count in data[policyName]) {
+ let isFirstRow = count == 0;
+ let isLastRow = count == data[policyName].length - 1;
+ let row = document.createElement("tr");
+ row.classList.add(color_class);
+ row.appendChild(col(isFirstRow ? policyName : ""));
+ generatePolicy(
+ data[policyName][count],
+ row,
+ 1,
+ new_cont,
+ isLastRow,
+ data[policyName].length > 1
+ );
+ }
+ } else if (lazy.schema.properties[policyName].type == "object") {
+ let count = 0;
+ for (let obj in data[policyName]) {
+ let isFirstRow = count == 0;
+ let isLastRow = count == Object.keys(data[policyName]).length - 1;
+ let row = document.createElement("tr");
+ row.classList.add(color_class);
+ row.appendChild(col(isFirstRow ? policyName : ""));
+ row.appendChild(col(obj));
+ generatePolicy(
+ data[policyName][obj],
+ row,
+ 2,
+ new_cont,
+ isLastRow,
+ true
+ );
+ count++;
+ }
+ } else {
+ let row = document.createElement("tr");
+ row.appendChild(col(policyName));
+ row.appendChild(col(JSON.stringify(data[policyName])));
+ row.classList.add(color_class, "last_row");
+ new_cont.appendChild(row);
+ }
+ }
+
+ if (policy_count < 1) {
+ let current_tab = document.querySelector(".active");
+ if (Services.policies.status == Services.policies.ACTIVE) {
+ current_tab.classList.add("no-specified-policies");
+ } else {
+ current_tab.classList.add("inactive-service");
+ }
+ }
+
+ addMissingColumns();
+}
+
+/*
+ * This is a helper recursive function that iterates levels of each
+ * policy and formats the content to be displayed accordingly.
+ */
+
+function generatePolicy(data, row, depth, new_cont, islast, arr_sep = false) {
+ const color_class = row.classList.contains("odd") ? "odd" : "even";
+
+ if (Array.isArray(data)) {
+ for (let count in data) {
+ if (count == 0) {
+ if (count == data.length - 1) {
+ generatePolicy(
+ data[count],
+ row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ true
+ );
+ } else {
+ generatePolicy(data[count], row, depth + 1, new_cont, false, false);
+ }
+ } else if (count == data.length - 1) {
+ let last_row = document.createElement("tr");
+ last_row.classList.add(color_class, "arr_sep");
+
+ for (let i = 0; i < depth; i++) {
+ last_row.appendChild(col(""));
+ }
+
+ generatePolicy(
+ data[count],
+ last_row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ arr_sep
+ );
+ } else {
+ let new_row = document.createElement("tr");
+ new_row.classList.add(color_class);
+
+ for (let i = 0; i < depth; i++) {
+ new_row.appendChild(col(""));
+ }
+
+ generatePolicy(data[count], new_row, depth + 1, new_cont, false, false);
+ }
+ }
+ } else if (typeof data == "object" && Object.keys(data).length > 0) {
+ let count = 0;
+ for (let obj in data) {
+ if (count == 0) {
+ row.appendChild(col(obj));
+ if (count == Object.keys(data).length - 1) {
+ generatePolicy(
+ data[obj],
+ row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ arr_sep
+ );
+ } else {
+ generatePolicy(data[obj], row, depth + 1, new_cont, false, false);
+ }
+ } else if (count == Object.keys(data).length - 1) {
+ let last_row = document.createElement("tr");
+ for (let i = 0; i < depth; i++) {
+ last_row.appendChild(col(""));
+ }
+
+ last_row.appendChild(col(obj));
+ last_row.classList.add(color_class);
+
+ if (arr_sep) {
+ last_row.classList.add("arr_sep");
+ }
+
+ generatePolicy(
+ data[obj],
+ last_row,
+ depth + 1,
+ new_cont,
+ islast ? islast : false,
+ false
+ );
+ } else {
+ let new_row = document.createElement("tr");
+ new_row.classList.add(color_class);
+
+ for (let i = 0; i < depth; i++) {
+ new_row.appendChild(col(""));
+ }
+
+ new_row.appendChild(col(obj));
+ generatePolicy(data[obj], new_row, depth + 1, new_cont, false, false);
+ }
+ count++;
+ }
+ } else {
+ row.appendChild(col(JSON.stringify(data)));
+
+ if (arr_sep) {
+ row.classList.add("arr_sep");
+ }
+ if (islast) {
+ row.classList.add("last_row");
+ }
+ new_cont.appendChild(row);
+ }
+}
+
+function generateErrors() {
+ const consoleStorage = Cc["@mozilla.org/consoleAPI-storage;1"];
+ const storage = consoleStorage.getService(Ci.nsIConsoleAPIStorage);
+ const consoleEvents = storage.getEvents();
+ const prefixes = [
+ "Enterprise Policies",
+ "JsonSchemaValidator.jsm",
+ "Policies.jsm",
+ "GPOParser.jsm",
+ "Enterprise Policies Child",
+ "BookmarksPolicies.jsm",
+ "ProxyPolicies.jsm",
+ "WebsiteFilter Policy",
+ "macOSPoliciesParser.jsm",
+ ];
+
+ let new_cont = document.getElementById("errorsContent");
+ new_cont.classList.add("errors");
+
+ let flag = false;
+ for (let err of consoleEvents) {
+ if (prefixes.includes(err.prefix)) {
+ flag = true;
+ let row = document.createElement("tr");
+ row.appendChild(col(err.arguments[0]));
+ new_cont.appendChild(row);
+ }
+ }
+ if (!flag) {
+ let errors_tab = document.getElementById("category-errors");
+ errors_tab.style.display = "none";
+ }
+}
+
+function generateDocumentation() {
+ let new_cont = document.getElementById("documentationContent");
+ new_cont.setAttribute("id", "documentationContent");
+
+ // map specific policies to a different string ID, to allow updates to
+ // existing descriptions
+ let string_mapping = {
+ BackgroundAppUpdate: "BackgroundAppUpdate2",
+ Certificates: "CertificatesDescription",
+ };
+
+ for (let policyName in lazy.schema.properties) {
+ let main_tbody = document.createElement("tbody");
+ main_tbody.classList.add("collapsible");
+ main_tbody.addEventListener("click", function () {
+ let content = this.nextElementSibling;
+ content.classList.toggle("content");
+ });
+ let row = document.createElement("tr");
+ row.appendChild(col(policyName));
+ let descriptionColumn = col("");
+ let stringID = string_mapping[policyName] || policyName;
+ descriptionColumn.setAttribute("data-l10n-id", `policy-${stringID}`);
+ row.appendChild(descriptionColumn);
+ main_tbody.appendChild(row);
+ let sec_tbody = document.createElement("tbody");
+ sec_tbody.classList.add("content");
+ sec_tbody.classList.add("content-style");
+ let schema_row = document.createElement("tr");
+ if (lazy.schema.properties[policyName].properties) {
+ let column = col(
+ JSON.stringify(lazy.schema.properties[policyName].properties, null, 1),
+ "schema"
+ );
+ column.colSpan = "2";
+ schema_row.appendChild(column);
+ sec_tbody.appendChild(schema_row);
+ } else if (lazy.schema.properties[policyName].items) {
+ let column = col(
+ JSON.stringify(lazy.schema.properties[policyName], null, 1),
+ "schema"
+ );
+ column.colSpan = "2";
+ schema_row.appendChild(column);
+ sec_tbody.appendChild(schema_row);
+ } else {
+ let column = col(
+ "type: " + lazy.schema.properties[policyName].type,
+ "schema"
+ );
+ column.colSpan = "2";
+ schema_row.appendChild(column);
+ sec_tbody.appendChild(schema_row);
+ if (lazy.schema.properties[policyName].enum) {
+ let enum_row = document.createElement("tr");
+ column = col(
+ "enum: " +
+ JSON.stringify(lazy.schema.properties[policyName].enum, null, 1),
+ "schema"
+ );
+ column.colSpan = "2";
+ enum_row.appendChild(column);
+ sec_tbody.appendChild(enum_row);
+ }
+ }
+ new_cont.appendChild(main_tbody);
+ new_cont.appendChild(sec_tbody);
+ }
+}
+
+let gInited = false;
+function init() {
+ if (gInited) {
+ return;
+ }
+ gInited = true;
+
+ let data = Services.policies.getActivePolicies();
+ generateActivePolicies(data);
+ generateErrors();
+ generateDocumentation();
+
+ // Event delegation on #categories element
+ let menu = document.getElementById("categories");
+ for (let category of menu.children) {
+ category.addEventListener("click", () => show(category));
+ }
+
+ if (location.hash) {
+ let sectionButton = document.getElementById(
+ "category-" + location.hash.substring(1)
+ );
+ if (sectionButton) {
+ sectionButton.click();
+ }
+ }
+
+ window.addEventListener("hashchange", function () {
+ if (location.hash) {
+ let sectionButton = document.getElementById(
+ "category-" + location.hash.substring(1)
+ );
+ sectionButton.click();
+ }
+ });
+}
+
+function show(button) {
+ let current_tab = document.querySelector(".active");
+ let category = button.getAttribute("id").substring("category-".length);
+ let content = document.getElementById(category);
+ if (current_tab == content) {
+ return;
+ }
+ saveScrollPosition(current_tab.id);
+ current_tab.classList.remove("active");
+ current_tab.hidden = true;
+ content.classList.add("active");
+ content.hidden = false;
+
+ let current_button = document.querySelector("[selected=true]");
+ current_button.removeAttribute("selected");
+ button.setAttribute("selected", "true");
+
+ let title = document.getElementById("sectionTitle");
+ title.textContent = button.children[1].textContent;
+ location.hash = category;
+ restoreScrollPosition(category);
+}
+
+const scrollPositions = {};
+function saveScrollPosition(category) {
+ const mainContent = document.querySelector(".main-content");
+ scrollPositions[category] = mainContent.scrollTop;
+}
+
+function restoreScrollPosition(category) {
+ const scrollY = scrollPositions[category] || 0;
+ const mainContent = document.querySelector(".main-content");
+ mainContent.scrollTo(0, scrollY);
+}
diff --git a/comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml b/comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml
new file mode 100644
index 0000000000..6ded7130a4
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/aboutPolicies.xhtml
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+-->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title data-l10n-id="about-policies-title" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/aboutPolicies.css"
+ type="text/css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/policies/aboutPolicies.ftl" />
+ <link
+ rel="localization"
+ href="messenger/policies/policies-descriptions.ftl"
+ />
+ <script
+ type="application/javascript"
+ src="chrome://messenger/content/policies/aboutPolicies.js"
+ />
+ </head>
+ <body id="body" onload="init()">
+ <div id="categories">
+ <div class="category" selected="true" id="category-active">
+ <img
+ class="category-icon"
+ src="chrome://messenger/content/policies/policies-active.svg"
+ alt=""
+ />
+ <label class="category-name" data-l10n-id="active-policies-tab"></label>
+ </div>
+ <div class="category" id="category-documentation">
+ <img
+ class="category-icon"
+ src="chrome://messenger/content/policies/policies-documentation.svg"
+ alt=""
+ />
+ <label class="category-name" data-l10n-id="documentation-tab"></label>
+ </div>
+ <div class="category" id="category-errors">
+ <img
+ class="category-icon"
+ src="chrome://messenger/content/policies/policies-error.svg"
+ alt=""
+ />
+ <label class="category-name" data-l10n-id="errors-tab"></label>
+ </div>
+ </div>
+ <div class="main-content">
+ <div class="header">
+ <div
+ id="sectionTitle"
+ class="header-name"
+ data-l10n-id="active-policies-tab"
+ />
+ </div>
+
+ <div id="active" class="tab active">
+ <h3
+ class="inactive-service-message"
+ data-l10n-id="inactive-message"
+ ></h3>
+ <h3
+ class="no-specified-policies-message"
+ data-l10n-id="no-specified-policies-message"
+ ></h3>
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="policy-name" />
+ <th data-l10n-id="policy-value" />
+ </tr>
+ </thead>
+ <tbody id="activeContent" />
+ </table>
+ </div>
+
+ <div id="documentation" class="tab" hidden="true">
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="policy-name" />
+ </tr>
+ </thead>
+ <tbody id="documentationContent" />
+ </table>
+ </div>
+
+ <div id="errors" class="tab" hidden="true">
+ <table>
+ <thead>
+ <tr>
+ <th data-l10n-id="policy-errors" />
+ </tr>
+ </thead>
+ <tbody id="errorsContent" />
+ </table>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/components/enterprisepolicies/content/policies-active.svg b/comm/mail/components/enterprisepolicies/content/policies-active.svg
new file mode 100644
index 0000000000..9ec258d4e0
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/policies-active.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 10a1 1 0 01-1-1V5H3v8a1 1 0 001 1h2a1 1 0 010 2H3a2 2 0 01-2-2V4c0-1.1.9-2 2-2h1.05a2.5 2.5 0 014.9 0H10a2 2 0 012 2v5a1 1 0 01-1 1zm-1-6V3H8.95a1 1 0 01-.98-.8 1.5 1.5 0 00-2.94 0 1 1 0 01-.98.8H3v1zM6.5 2a.5.5 0 110 1 .5.5 0 010-1zm-2 5a.5.5 0 010-1h4a.5.5 0 010 1zm0 2a.5.5 0 010-1h2a.5.5 0 010 1zm0 2a.5.5 0 110-1h3a.5.5 0 110 1zm5.16 5a.67.67 0 01-.47-.2l-2-2a.67.67 0 01.94-.95l1.44 1.44 4.22-6.02a.67.67 0 011.1.77L10.2 15.7a.67.67 0 01-.55.29z"/>
+</svg>
diff --git a/comm/mail/components/enterprisepolicies/content/policies-documentation.svg b/comm/mail/components/enterprisepolicies/content/policies-documentation.svg
new file mode 100644
index 0000000000..a2817d3514
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/policies-documentation.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 7a1 1 0 01-1-1V5H3v8a1 1 0 001 1h2a1 1 0 110 2H3a2 2 0 01-2-2V4c0-1.1.9-2 2-2h1.05a2.5 2.5 0 014.9 0H10a2 2 0 012 2v2a1 1 0 01-1 1zm-1-3V3H8.95a1 1 0 01-.98-.8 1.5 1.5 0 00-2.94 0 1 1 0 01-.98.8H3v1zM6.5 2a.5.5 0 110 1 .5.5 0 010-1zm-2 5a.5.5 0 110-1h4a.5.5 0 010 1zm0 2a.5.5 0 110-1h2a.5.5 0 010 1zm0 2a.5.5 0 110-1h1a.5.5 0 110 1zm6.5 5a4 4 0 110-8 4 4 0 010 8zm.46-2a.46.46 0 10-.92 0 .46.46 0 00.92 0zm-1.06-3c0-.3.25-.6.6-.6s.6.3.6.6c0 .12-.06.25-.16.39-.08.1-.16.17-.23.24l-.1.09-.01.02a1.3 1.3 0 00-.5 1.06.4.4 0 10.8 0c0-.17.04-.26.08-.3a.61.61 0 01.08-.1l.05-.05.07-.07.04-.03c.12-.11.24-.24.35-.37.16-.2.33-.5.33-.88a1.4 1.4 0 00-2.8 0 .4.4 0 10.8 0z"/>
+</svg>
diff --git a/comm/mail/components/enterprisepolicies/content/policies-error.svg b/comm/mail/components/enterprisepolicies/content/policies-error.svg
new file mode 100644
index 0000000000..fad8ddd632
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/content/policies-error.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M11 7a1 1 0 01-1-1V5H3v8a1 1 0 001 1h1a1 1 0 110 2H3a2 2 0 01-2-2V4c0-1.1.9-2 2-2h1.05a2.5 2.5 0 014.9 0H10a2 2 0 012 2v2a1 1 0 01-1 1zm-1-3V3H8.95a1 1 0 01-.98-.8 1.5 1.5 0 00-2.94 0 1 1 0 01-.98.8H3v1zM6.5 2a.5.5 0 110 1 .5.5 0 010-1zm-2 5a.5.5 0 110-1h4a.5.5 0 010 1zm0 2a.5.5 0 110-1h3a.5.5 0 010 1zm0 2a.5.5 0 110-1h2a.5.5 0 110 1zm10.4 3.34A1.15 1.15 0 0113.83 16h-5.7a1.15 1.15 0 01-1-1.66l2.85-5.7a1.15 1.15 0 012.06 0zm-4.44-3.78v1.73a.58.58 0 001.15 0v-1.73a.58.58 0 10-1.15 0zm.57 4.13a.68.68 0 100-1.36.68.68 0 000 1.36z"/>
+</svg>
diff --git a/comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs b/comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs
new file mode 100644
index 0000000000..086a928c60
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/helpers/ProxyPolicies.sys.mjs
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+const PREF_LOGLEVEL = "browser.policies.loglevel";
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ prefix: "ProxyPolicies.jsm",
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.jsm for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: PREF_LOGLEVEL,
+ });
+});
+
+// Don't use const here because this is accessed by
+// tests through the BackstagePass object.
+export var PROXY_TYPES_MAP = new Map([
+ ["none", Ci.nsIProtocolProxyService.PROXYCONFIG_DIRECT],
+ ["system", Ci.nsIProtocolProxyService.PROXYCONFIG_SYSTEM],
+ ["manual", Ci.nsIProtocolProxyService.PROXYCONFIG_MANUAL],
+ ["autoDetect", Ci.nsIProtocolProxyService.PROXYCONFIG_WPAD],
+ ["autoConfig", Ci.nsIProtocolProxyService.PROXYCONFIG_PAC],
+]);
+
+export var ProxyPolicies = {
+ configureProxySettings(param, setPref) {
+ if (param.Mode) {
+ setPref("network.proxy.type", PROXY_TYPES_MAP.get(param.Mode));
+ }
+
+ if (param.AutoConfigURL) {
+ setPref("network.proxy.autoconfig_url", param.AutoConfigURL.href);
+ }
+
+ if (param.UseProxyForDNS !== undefined) {
+ setPref("network.proxy.socks_remote_dns", param.UseProxyForDNS);
+ }
+
+ if (param.AutoLogin !== undefined) {
+ setPref("signon.autologin.proxy", param.AutoLogin);
+ }
+
+ if (param.SOCKSVersion !== undefined) {
+ if (param.SOCKSVersion != 4 && param.SOCKSVersion != 5) {
+ lazy.log.error("Invalid SOCKS version");
+ } else {
+ setPref("network.proxy.socks_version", param.SOCKSVersion);
+ }
+ }
+
+ if (param.Passthrough !== undefined) {
+ setPref("network.proxy.no_proxies_on", param.Passthrough);
+ }
+
+ if (param.UseHTTPProxyForAllProtocols !== undefined) {
+ setPref(
+ "network.proxy.share_proxy_settings",
+ param.UseHTTPProxyForAllProtocols
+ );
+ }
+
+ if (param.FTPProxy) {
+ lazy.log.warn("FTPProxy support was removed in bug 1574475");
+ }
+
+ function setProxyHostAndPort(type, address) {
+ let url;
+ try {
+ // Prepend https just so we can use the URL parser
+ // instead of parsing manually.
+ url = new URL(`https://${address}`);
+ } catch (e) {
+ lazy.log.error(`Invalid address for ${type} proxy: ${address}`);
+ return;
+ }
+
+ setPref(`network.proxy.${type}`, url.hostname);
+ if (url.port) {
+ setPref(`network.proxy.${type}_port`, Number(url.port));
+ }
+ }
+
+ if (param.HTTPProxy) {
+ setProxyHostAndPort("http", param.HTTPProxy);
+
+ // network.proxy.share_proxy_settings is a UI feature, not handled by the
+ // network code. That pref only controls if the checkbox is checked, and
+ // then we must manually set the other values.
+ if (param.UseHTTPProxyForAllProtocols) {
+ param.SSLProxy = param.SOCKSProxy = param.HTTPProxy;
+ }
+ }
+
+ if (param.SSLProxy) {
+ setProxyHostAndPort("ssl", param.SSLProxy);
+ }
+
+ if (param.SOCKSProxy) {
+ setProxyHostAndPort("socks", param.SOCKSProxy);
+ }
+ },
+};
diff --git a/comm/mail/components/enterprisepolicies/helpers/moz.build b/comm/mail/components/enterprisepolicies/helpers/moz.build
new file mode 100644
index 0000000000..5f07c95153
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/helpers/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "OS Integration")
+
+EXTRA_JS_MODULES.policies += [
+ "ProxyPolicies.sys.mjs",
+]
diff --git a/comm/mail/components/enterprisepolicies/jar.mn b/comm/mail/components/enterprisepolicies/jar.mn
new file mode 100644
index 0000000000..b344553811
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/jar.mn
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/policies/aboutPolicies.xhtml (content/aboutPolicies.xhtml)
+ content/messenger/policies/aboutPolicies.js (content/aboutPolicies.js)
+ content/messenger/policies/policies-active.svg (content/policies-active.svg)
+ content/messenger/policies/policies-documentation.svg (content/policies-documentation.svg)
+ content/messenger/policies/policies-error.svg (content/policies-error.svg)
diff --git a/comm/mail/components/enterprisepolicies/moz.build b/comm/mail/components/enterprisepolicies/moz.build
new file mode 100644
index 0000000000..ac89d4fc17
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/moz.build
@@ -0,0 +1,23 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "OS Integration")
+
+DIRS += [
+ "helpers",
+ "schemas",
+]
+
+TEST_DIRS += ["tests"]
+
+EXTRA_JS_MODULES.policies += [
+ "Policies.sys.mjs",
+]
+
+JAR_MANIFESTS += [
+ "jar.mn",
+]
diff --git a/comm/mail/components/enterprisepolicies/schemas/configuration.json b/comm/mail/components/enterprisepolicies/schemas/configuration.json
new file mode 100644
index 0000000000..8d3e9e43c2
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/configuration.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "type": "object",
+ "properties": {
+ "policies": {
+ "$ref": "policies.json"
+ }
+ },
+ "required": ["policies"]
+}
diff --git a/comm/mail/components/enterprisepolicies/schemas/moz.build b/comm/mail/components/enterprisepolicies/schemas/moz.build
new file mode 100644
index 0000000000..658f0b1ed7
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "OS Integration")
+
+EXTRA_PP_JS_MODULES.policies += [
+ "schema.sys.mjs",
+]
diff --git a/comm/mail/components/enterprisepolicies/schemas/policies-schema.json b/comm/mail/components/enterprisepolicies/schemas/policies-schema.json
new file mode 100644
index 0000000000..c49aa5ac16
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/policies-schema.json
@@ -0,0 +1,634 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "type": "object",
+ "properties": {
+ "3rdparty": {
+ "type": "object",
+ "properties": {
+ "Extensions": {
+ "type": "object",
+ "patternProperties": {
+ "^.*$": {
+ "type": "JSON"
+ }
+ }
+ }
+ }
+ },
+
+ "AppAutoUpdate": {
+ "type": "boolean"
+ },
+
+ "AppUpdatePin": {
+ "type": "string"
+ },
+
+ "AppUpdateURL": {
+ "type": "URL"
+ },
+
+ "Authentication": {
+ "type": "object",
+ "properties": {
+ "SPNEGO": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Delegated": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "NTLM": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AllowNonFQDN": {
+ "type": "object",
+ "properties": {
+ "SPNEGO": {
+ "type": "boolean"
+ },
+
+ "NTLM": {
+ "type": "boolean"
+ }
+ }
+ },
+ "AllowProxies": {
+ "type": "object",
+ "properties": {
+ "SPNEGO": {
+ "type": "boolean"
+ },
+
+ "NTLM": {
+ "type": "boolean"
+ }
+ }
+ },
+ "Locked": {
+ "type": "boolean"
+ },
+ "PrivateBrowsing": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "BackgroundAppUpdate": {
+ "type": "boolean"
+ },
+
+ "BlockAboutAddons": {
+ "type": "boolean"
+ },
+
+ "BlockAboutConfig": {
+ "type": "boolean"
+ },
+
+ "BlockAboutProfiles": {
+ "type": "boolean"
+ },
+
+ "BlockAboutSupport": {
+ "type": "boolean"
+ },
+
+ "CaptivePortal": {
+ "type": "boolean"
+ },
+
+ "Certificates": {
+ "type": "object",
+ "properties": {
+ "ImportEnterpriseRoots": {
+ "type": "boolean"
+ },
+ "Install": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+
+ "Cookies": {
+ "type": "object",
+ "properties": {
+ "Allow": {
+ "type": "array",
+ "strict": false,
+ "items": {
+ "type": "origin"
+ }
+ },
+
+ "Block": {
+ "type": "array",
+ "strict": false,
+ "items": {
+ "type": "origin"
+ }
+ },
+
+ "Default": {
+ "type": "boolean"
+ },
+
+ "AcceptThirdParty": {
+ "type": "string",
+ "enum": ["always", "never", "from-visited"]
+ },
+
+ "ExpireAtSessionEnd": {
+ "type": "boolean"
+ },
+
+ "Locked": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DefaultDownloadDirectory": {
+ "type": "string"
+ },
+
+ "DisableAppUpdate": {
+ "type": "boolean"
+ },
+
+ "DisableBuiltinPDFViewer": {
+ "type": "boolean"
+ },
+
+ "DisabledCiphers": {
+ "type": "object",
+ "properties": {
+ "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_DHE_RSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_DHE_RSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_128_GCM_SHA256": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_256_GCM_SHA384": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_128_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_AES_256_CBC_SHA": {
+ "type": "boolean"
+ },
+ "TLS_RSA_WITH_3DES_EDE_CBC_SHA": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DisableDeveloperTools": {
+ "type": "boolean"
+ },
+
+ "DisableMasterPasswordCreation": {
+ "type": "boolean"
+ },
+
+ "DisablePasswordReveal": {
+ "type": "boolean"
+ },
+
+ "DisableSafeMode": {
+ "type": "boolean"
+ },
+
+ "DisableSecurityBypass": {
+ "type": "object",
+ "properties": {
+ "InvalidCertificate": {
+ "type": "boolean"
+ },
+
+ "SafeBrowsing": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DisableSystemAddonUpdate": {
+ "type": "boolean"
+ },
+
+ "DisableTelemetry": {
+ "type": "boolean"
+ },
+
+ "DNSOverHTTPS": {
+ "type": "object",
+ "properties": {
+ "Enabled": {
+ "type": "boolean"
+ },
+ "ProviderURL": {
+ "type": "URLorEmpty"
+ },
+ "ExcludedDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Locked": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "DownloadDirectory": {
+ "type": "string"
+ },
+
+ "Extensions": {
+ "type": "object",
+ "properties": {
+ "Install": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Uninstall": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Locked": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+
+ "ExtensionSettings": {
+ "type": "object",
+ "properties": {
+ "*": {
+ "type": "object",
+ "properties": {
+ "installation_mode": {
+ "type": "string",
+ "enum": ["allowed", "blocked"]
+ },
+ "allowed_types": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["extension", "dictionary", "locale", "theme"]
+ }
+ },
+ "blocked_install_message": {
+ "type": "string"
+ },
+ "install_sources": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "restricted_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "installation_mode": {
+ "type": "string",
+ "enum": [
+ "allowed",
+ "blocked",
+ "force_installed",
+ "normal_installed"
+ ]
+ },
+ "install_url": {
+ "type": "string"
+ },
+ "blocked_install_message": {
+ "type": "string"
+ },
+ "updates_disabled": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+
+ "ExtensionUpdate": {
+ "type": "boolean"
+ },
+
+ "Handlers": {
+ "type": "object",
+ "patternProperties": {
+ "^(mimeTypes|extensions|schemes)$": {
+ "type": "object",
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["saveToDisk", "useHelperApp", "useSystemDefault"]
+ },
+ "ask": {
+ "type": "boolean"
+ },
+ "handlers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "uriTemplate": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ "HardwareAcceleration": {
+ "type": "boolean"
+ },
+
+ "InstallAddonsPermission": {
+ "type": "object",
+ "properties": {
+ "Allow": {
+ "type": "array",
+ "strict": false,
+ "items": {
+ "type": "origin"
+ }
+ },
+ "Default": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "ManualAppUpdateOnly": {
+ "type": "boolean"
+ },
+
+ "NetworkPrediction": {
+ "type": "boolean"
+ },
+
+ "OfferToSaveLogins": {
+ "type": "boolean"
+ },
+
+ "OfferToSaveLoginsDefault": {
+ "type": "boolean"
+ },
+
+ "PasswordManagerEnabled": {
+ "type": "boolean"
+ },
+
+ "PDFjs": {
+ "type": "object",
+ "properties": {
+ "Enabled": {
+ "type": "boolean"
+ },
+ "EnablePermissions": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "Preferences": {
+ "type": "object",
+ "patternProperties": {
+ "^.*$": {
+ "type": ["number", "boolean", "string", "object"],
+ "properties": {
+ "Value": {
+ "type": ["number", "boolean", "string"]
+ },
+ "Status": {
+ "type": "string",
+ "enum": ["default", "locked", "user", "clear"]
+ }
+ }
+ }
+ }
+ },
+
+ "PrimaryPassword": {
+ "type": "boolean"
+ },
+
+ "PromptForDownloadLocation": {
+ "type": "boolean"
+ },
+
+ "Proxy": {
+ "type": "object",
+ "properties": {
+ "Mode": {
+ "type": "string",
+ "enum": ["none", "system", "manual", "autoDetect", "autoConfig"]
+ },
+
+ "Locked": {
+ "type": "boolean"
+ },
+
+ "AutoConfigURL": {
+ "type": "URLorEmpty"
+ },
+
+ "FTPProxy": {
+ "type": "string"
+ },
+
+ "HTTPProxy": {
+ "type": "string"
+ },
+
+ "SSLProxy": {
+ "type": "string"
+ },
+
+ "SOCKSProxy": {
+ "type": "string"
+ },
+
+ "SOCKSVersion": {
+ "type": "number",
+ "enum": [4, 5]
+ },
+
+ "UseHTTPProxyForAllProtocols": {
+ "type": "boolean"
+ },
+
+ "Passthrough": {
+ "type": "string"
+ },
+
+ "UseProxyForDNS": {
+ "type": "boolean"
+ },
+
+ "AutoLogin": {
+ "type": "boolean"
+ }
+ }
+ },
+
+ "RequestedLocales": {
+ "type": ["string", "array"],
+ "items": {
+ "type": "string"
+ }
+ },
+
+ "SearchEngines": {
+ "enterprise_only": true,
+
+ "type": "object",
+ "properties": {
+ "Add": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["Name", "URLTemplate"],
+
+ "properties": {
+ "Name": {
+ "type": "string"
+ },
+ "IconURL": {
+ "type": "URLorEmpty"
+ },
+ "Alias": {
+ "type": "string"
+ },
+ "Description": {
+ "type": "string"
+ },
+ "Encoding": {
+ "type": "string"
+ },
+ "Method": {
+ "type": "string",
+ "enum": ["GET", "POST"]
+ },
+ "URLTemplate": {
+ "type": "string"
+ },
+ "PostData": {
+ "type": "string"
+ },
+ "SuggestURLTemplate": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "Default": {
+ "type": "string"
+ },
+ "DefaultPrivate": {
+ "type": "string"
+ },
+ "PreventInstalls": {
+ "type": "boolean"
+ },
+ "Remove": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+
+ "SSLVersionMax": {
+ "type": "string",
+ "enum": ["tls1", "tls1.1", "tls1.2", "tls1.3"]
+ },
+
+ "SSLVersionMin": {
+ "type": "string",
+ "enum": ["tls1", "tls1.1", "tls1.2", "tls1.3"]
+ }
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs b/comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs
new file mode 100644
index 0000000000..671a8cfca1
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/schemas/schema.sys.mjs
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const initialSchema =
+#include policies-schema.json
+
+export let schema = initialSchema;
+
+export function modifySchemaForTests(customSchema) {
+ if (customSchema) {
+ schema = customSchema;
+ } else {
+ schema = initialSchema;
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/browser.ini
new file mode 100644
index 0000000000..9c709131a2
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser.ini
@@ -0,0 +1,32 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files =
+ policytest_v0.1.xpi
+ policytest_v0.2.xpi
+ extensionsettings.html
+
+[browser_policies_setAndLockPref_API.js]
+[browser_policy_app_auto_update.js]
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+[browser_policy_app_update.js]
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+[browser_policy_background_app_update.js]
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+[browser_policy_block_about.js]
+[browser_policy_cookie_settings.js]
+[browser_policy_disable_safemode.js]
+[browser_policy_disable_telemetry.js]
+[browser_policy_downloads.js]
+[browser_policy_extensions.js]
+[browser_policy_extensionsettings.js]
+[browser_policy_extensionsettings2.js]
+[browser_policy_handlers.js]
+[browser_policy_masterpassword.js]
+[browser_policy_passwordmanager.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js
new file mode 100644
index 0000000000..0cad8e5aa3
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policies_setAndLockPref_API.js
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { Policies, setAndLockPref, PoliciesUtils } = ChromeUtils.importESModule(
+ "resource:///modules/policies/Policies.sys.mjs"
+);
+
+add_task(async function test_API_directly() {
+ await setupPolicyEngineWithJson("");
+ setAndLockPref("policies.test.boolPref", true);
+ checkLockedPref("policies.test.boolPref", true);
+
+ // Check that a previously-locked pref can be changed
+ // (it will be unlocked first).
+ setAndLockPref("policies.test.boolPref", false);
+ checkLockedPref("policies.test.boolPref", false);
+
+ setAndLockPref("policies.test.intPref", 2);
+ checkLockedPref("policies.test.intPref", 2);
+
+ setAndLockPref("policies.test.stringPref", "policies test");
+ checkLockedPref("policies.test.stringPref", "policies test");
+
+ PoliciesUtils.setDefaultPref(
+ "policies.test.lockedPref",
+ "policies test",
+ true
+ );
+ checkLockedPref("policies.test.lockedPref", "policies test");
+
+ // Test that user values do not override the prefs, and the get*Pref call
+ // still return the value set through setAndLockPref
+ Services.prefs.setBoolPref("policies.test.boolPref", true);
+ checkLockedPref("policies.test.boolPref", false);
+
+ Services.prefs.setIntPref("policies.test.intPref", 10);
+ checkLockedPref("policies.test.intPref", 2);
+
+ Services.prefs.setStringPref("policies.test.stringPref", "policies test");
+ checkLockedPref("policies.test.stringPref", "policies test");
+
+ try {
+ // Test that a non-integer value is correctly rejected, even though
+ // typeof(val) == "number"
+ setAndLockPref("policies.test.intPref", 1.5);
+ ok(false, "Integer value should be rejected");
+ } catch (ex) {
+ ok(true, "Integer value was rejected");
+ }
+});
+
+add_task(async function test_API_through_policies() {
+ // Ensure that the values received by the policies have the correct
+ // type to make sure things are properly working.
+
+ // Implement functions to handle the three simple policies
+ // that will be added to the schema.
+ Policies.bool_policy = {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("policies.test2.boolPref", param);
+ },
+ };
+
+ Policies.int_policy = {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("policies.test2.intPref", param);
+ },
+ };
+
+ Policies.string_policy = {
+ onBeforeUIStartup(manager, param) {
+ setAndLockPref("policies.test2.stringPref", param);
+ },
+ };
+
+ await setupPolicyEngineWithJson(
+ // policies.json
+ {
+ policies: {
+ bool_policy: true,
+ int_policy: 42,
+ string_policy: "policies test 2",
+ },
+ },
+
+ // custom schema
+ {
+ properties: {
+ bool_policy: {
+ type: "boolean",
+ },
+
+ int_policy: {
+ type: "integer",
+ },
+
+ string_policy: {
+ type: "string",
+ },
+ },
+ }
+ );
+
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ // The expected values come from config_setAndLockPref.json
+ checkLockedPref("policies.test2.boolPref", true);
+ checkLockedPref("policies.test2.intPref", 42);
+ checkLockedPref("policies.test2.stringPref", "policies test 2");
+
+ delete Policies.bool_policy;
+ delete Policies.int_policy;
+ delete Policies.string_policy;
+});
+
+add_task(async function test_pref_tracker() {
+ // Tests the test harness functionality that tracks usage of
+ // the setAndLockPref and setDefualtPref APIs.
+
+ let defaults = Services.prefs.getDefaultBranch("");
+
+ // Test prefs that had a default value and got changed to another
+ defaults.setIntPref("test1.pref1", 10);
+ defaults.setStringPref("test1.pref2", "test");
+
+ setAndLockPref("test1.pref1", 20);
+ PoliciesUtils.setDefaultPref("test1.pref2", "NEW VALUE");
+ setAndLockPref("test1.pref3", "NEW VALUE");
+ PoliciesUtils.setDefaultPref("test1.pref4", 20);
+
+ PoliciesPrefTracker.restoreDefaultValues();
+
+ is(
+ Services.prefs.getIntPref("test1.pref1"),
+ 10,
+ "Expected value for test1.pref1"
+ );
+ is(
+ Services.prefs.getStringPref("test1.pref2"),
+ "test",
+ "Expected value for test1.pref2"
+ );
+ is(
+ Services.prefs.prefIsLocked("test1.pref1"),
+ false,
+ "test1.pref1 got unlocked"
+ );
+ ok(
+ !Services.prefs.getStringPref("test1.pref3", undefined),
+ "test1.pref3 should have had its value unset"
+ );
+ is(
+ Services.prefs.getIntPref("test1.pref4", -1),
+ -1,
+ "test1.pref4 should have had its value unset"
+ );
+
+ // Test a pref that had a default value and a user value
+ defaults.setIntPref("test2.pref1", 10);
+ Services.prefs.setIntPref("test2.pref1", 20);
+
+ setAndLockPref("test2.pref1", 20);
+
+ PoliciesPrefTracker.restoreDefaultValues();
+
+ is(Services.prefs.getIntPref("test2.pref1"), 20, "Correct user value");
+ is(defaults.getIntPref("test2.pref1"), 10, "Correct default value");
+ is(
+ Services.prefs.prefIsLocked("test2.pref1"),
+ false,
+ "felipe pref is not locked"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js
new file mode 100644
index 0000000000..433408642f
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_auto_update.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+async function test_app_update_auto(expectedEnabled, expectedLocked) {
+ let actualEnabled = await UpdateUtils.getAppUpdateAutoEnabled();
+ is(
+ actualEnabled,
+ expectedEnabled,
+ `Actual auto update enabled setting should match the expected value of ${expectedEnabled}`
+ );
+
+ let actualLocked = UpdateUtils.appUpdateAutoSettingIsLocked();
+ is(
+ actualLocked,
+ expectedLocked,
+ `Auto update enabled setting ${
+ expectedLocked ? "should" : "should not"
+ } be locked`
+ );
+
+ let setSuccess = true;
+ try {
+ await UpdateUtils.setAppUpdateAutoEnabled(actualEnabled);
+ } catch (error) {
+ setSuccess = false;
+ }
+ is(
+ setSuccess,
+ !expectedLocked,
+ `Setting auto update ${expectedLocked ? "should" : "should not"} fail`
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL == "about:preferences") {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ window.openPreferencesTab("paneGeneral", "updateApp");
+ });
+
+ await new Promise(resolve => setTimeout(resolve));
+
+ is(
+ prefsDocument.getElementById("updateSettingsContainer").hidden,
+ expectedLocked,
+ `When auto update ${
+ expectedLocked ? "is" : "isn't"
+ } locked, the corresponding preferences entry ${
+ expectedLocked ? "should" : "shouldn't"
+ } be hidden`
+ );
+
+ tabmail.closeTab(prefsTabMode.tabs[0]);
+}
+
+add_task(async function test_app_auto_update_policy() {
+ let originalUpdateAutoValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ registerCleanupFunction(async () => {
+ await UpdateUtils.setAppUpdateAutoEnabled(originalUpdateAutoValue);
+ });
+
+ await UpdateUtils.setAppUpdateAutoEnabled(true);
+ await test_app_update_auto(true, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppAutoUpdate: false,
+ },
+ });
+ await test_app_update_auto(false, true);
+
+ await setupPolicyEngineWithJson({});
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ await test_app_update_auto(false, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppAutoUpdate: true,
+ },
+ });
+ await test_app_update_auto(true, true);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js
new file mode 100644
index 0000000000..14a9c92bc5
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_app_update.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+var updateService = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+);
+
+// This test is intended to ensure that nsIUpdateService::canCheckForUpdates
+// is true before the "DisableAppUpdate" policy is applied. Testing that
+// nsIUpdateService::canCheckForUpdates is false after the "DisableAppUpdate"
+// policy is applied needs to occur in a different test since the policy does
+// not properly take effect unless it is applied during application startup.
+add_task(async function test_updates_pre_policy() {
+ // Turn off automatic update before we set app.update.disabledForTesting to
+ // false so that we don't cause an actual update.
+ let originalUpdateAutoValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ registerCleanupFunction(async () => {
+ await UpdateUtils.setAppUpdateAutoEnabled(originalUpdateAutoValue);
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.update.disabledForTesting", false]],
+ });
+
+ is(
+ Services.policies.isAllowed("appUpdate"),
+ true,
+ "Since no policies have been set, appUpdate should be allowed by default"
+ );
+
+ is(
+ updateService.canCheckForUpdates,
+ true,
+ "Should be able to check for updates before any policies are in effect."
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js
new file mode 100644
index 0000000000..4c0369df3d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_background_app_update.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+const PREF_NAME = "app.update.background.enabled";
+
+async function test_background_update_pref(expectedEnabled, expectedLocked) {
+ let actualEnabled = await UpdateUtils.readUpdateConfigSetting(PREF_NAME);
+ is(
+ actualEnabled,
+ expectedEnabled,
+ `Actual background update enabled setting should be ${expectedEnabled}`
+ );
+
+ let actualLocked = UpdateUtils.appUpdateSettingIsLocked(PREF_NAME);
+ is(
+ actualLocked,
+ expectedLocked,
+ `Background update enabled setting ${
+ expectedLocked ? "should" : "should not"
+ } be locked`
+ );
+
+ let setSuccess = true;
+ try {
+ await UpdateUtils.writeUpdateConfigSetting(PREF_NAME, actualEnabled);
+ } catch (error) {
+ setSuccess = false;
+ }
+ is(
+ setSuccess,
+ !expectedLocked,
+ `Setting background update pref ${
+ expectedLocked ? "should" : "should not"
+ } fail`
+ );
+
+ if (AppConstants.MOZ_UPDATER && AppConstants.MOZ_UPDATE_AGENT) {
+ let shouldShowUI =
+ !expectedLocked && UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED;
+ await BrowserTestUtils.withNewTab("about:preferences", browser => {
+ is(
+ browser.contentDocument.getElementById("backgroundUpdate").hidden,
+ !shouldShowUI,
+ `When background update ${
+ expectedLocked ? "is" : "isn't"
+ } locked, and per-installation prefs ${
+ UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED ? "are" : "aren't"
+ } supported, the corresponding preferences entry ${
+ shouldShowUI ? "shouldn't" : "should"
+ } be hidden`
+ );
+ });
+ } else {
+ // The backgroundUpdate element is #ifdef'ed out if MOZ_UPDATER and
+ // MOZ_UPDATE_AGENT are not both defined.
+ info(
+ "Warning: UI testing skipped because support for background update is " +
+ "not present"
+ );
+ }
+}
+
+add_task(async function test_background_app_update_policy() {
+ // Turn on the background update UI so we can test it.
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.update.background.scheduling.enabled", true]],
+ });
+
+ const origBackgroundUpdateVal = await UpdateUtils.readUpdateConfigSetting(
+ PREF_NAME
+ );
+ registerCleanupFunction(async () => {
+ await UpdateUtils.writeUpdateConfigSetting(
+ PREF_NAME,
+ origBackgroundUpdateVal
+ );
+ });
+
+ await UpdateUtils.writeUpdateConfigSetting(PREF_NAME, true);
+ await test_background_update_pref(true, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ BackgroundAppUpdate: false,
+ },
+ });
+ await test_background_update_pref(false, true);
+
+ await setupPolicyEngineWithJson({});
+ await UpdateUtils.writeUpdateConfigSetting(PREF_NAME, false);
+ await test_background_update_pref(false, false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ BackgroundAppUpdate: true,
+ },
+ });
+ await test_background_update_pref(true, true);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js
new file mode 100644
index 0000000000..4be9c7fee8
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_block_about.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+const ABOUT_CONTRACT = "@mozilla.org/network/protocol/about;1?what=";
+
+const policiesToTest = [
+ {
+ policies: {
+ BlockAboutAddons: true,
+ },
+ urls: ["about:addons"],
+ },
+
+ {
+ policies: {
+ BlockAboutConfig: true,
+ },
+ urls: ["about:config"],
+ },
+ {
+ policies: {
+ BlockAboutProfiles: true,
+ },
+ urls: ["about:profiles"],
+ },
+
+ {
+ policies: {
+ BlockAboutSupport: true,
+ },
+ urls: ["about:support"],
+ },
+
+ {
+ policies: {
+ DisableDeveloperTools: true,
+ },
+ urls: ["about:debugging", "about:devtools-toolbox"],
+ },
+ {
+ policies: {
+ DisableTelemetry: true,
+ },
+ urls: ["about:telemetry"],
+ },
+];
+
+add_task(async function testAboutTask() {
+ for (let policyToTest of policiesToTest) {
+ let policyJSON = { policies: {} };
+ policyJSON.policies = policyToTest.policies;
+ for (let url of policyToTest.urls) {
+ if (url.startsWith("about")) {
+ let feature = url.split(":")[1];
+ let aboutModule = Cc[ABOUT_CONTRACT + feature].getService(
+ Ci.nsIAboutModule
+ );
+ let chromeURL = aboutModule.getChromeURI(Services.io.newURI(url)).spec;
+ await testPageBlockedByPolicy(policyJSON, chromeURL);
+ }
+ await testPageBlockedByPolicy(policyJSON, url);
+ }
+ }
+});
+
+async function testPageBlockedByPolicy(policyJSON, page) {
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(policyJSON);
+
+ await withNewTab({ url: "about:blank" }, async browser => {
+ BrowserTestUtils.loadURIString(browser, page);
+ await BrowserTestUtils.browserLoaded(browser, false, page, true);
+ await SpecialPowers.spawn(browser, [page], async function (innerPage) {
+ ok(
+ content.document.documentURI.startsWith(
+ "about:neterror?e=blockedByPolicy"
+ ),
+ content.document.documentURI +
+ " should start with about:neterror?e=blockedByPolicy"
+ );
+ });
+ });
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js
new file mode 100644
index 0000000000..f43500d723
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_cookie_settings.js
@@ -0,0 +1,323 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { UrlClassifierTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlClassifierTestUtils.sys.mjs"
+);
+Services.cookies.QueryInterface(Ci.nsICookieService);
+
+function restore_prefs() {
+ // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default"
+ Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault");
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+ Services.prefs.clearUserPref(
+ "network.cookieJarSettings.unblocked_for_testing"
+ );
+ Services.prefs.clearUserPref(
+ "network.cookie.rejectForeignWithExceptions.enabled"
+ );
+}
+
+registerCleanupFunction(restore_prefs);
+
+async function fake_profile_change() {
+ await new Promise(resolve => {
+ Services.obs.addObserver(function waitForDBClose() {
+ Services.obs.removeObserver(waitForDBClose, "cookie-db-closed");
+ resolve();
+ }, "cookie-db-closed");
+ Services.cookies
+ .QueryInterface(Ci.nsIObserver)
+ .observe(null, "profile-before-change", "shutdown-persist");
+ });
+ await new Promise(resolve => {
+ Services.obs.addObserver(function waitForDBOpen() {
+ Services.obs.removeObserver(waitForDBOpen, "cookie-db-read");
+ resolve();
+ }, "cookie-db-read");
+ Services.cookies
+ .QueryInterface(Ci.nsIObserver)
+ .observe(null, "profile-do-change", "");
+ });
+}
+
+async function test_cookie_settings({
+ cookiesEnabled,
+ thirdPartyCookiesEnabled,
+ cookieJarSettingsLocked,
+}) {
+ let firstPartyURI = NetUtil.newURI("https://example.com/");
+ let thirdPartyURI = NetUtil.newURI("https://example.org/");
+ let channel = NetUtil.newChannel({
+ uri: firstPartyURI,
+ loadUsingSystemPrincipal: true,
+ });
+ channel.QueryInterface(
+ Ci.nsIHttpChannelInternal
+ ).forceAllowThirdPartyCookie = true;
+ Services.cookies.removeAll();
+ Services.cookies.setCookieStringFromHttp(firstPartyURI, "key=value", channel);
+ Services.cookies.setCookieStringFromHttp(thirdPartyURI, "key=value", channel);
+
+ let expectedFirstPartyCookies = 1;
+ let expectedThirdPartyCookies = 1;
+ if (!cookiesEnabled) {
+ expectedFirstPartyCookies = 0;
+ }
+ if (!cookiesEnabled || !thirdPartyCookiesEnabled) {
+ expectedThirdPartyCookies = 0;
+ }
+ is(
+ Services.cookies.countCookiesFromHost(firstPartyURI.host),
+ expectedFirstPartyCookies,
+ "Number of first-party cookies should match expected"
+ );
+ is(
+ Services.cookies.countCookiesFromHost(thirdPartyURI.host),
+ expectedThirdPartyCookies,
+ "Number of third-party cookies should match expected"
+ );
+
+ // Add a cookie so we can check if it persists past the end of the session
+ // but, first remove existing cookies set by this host to put us in a known state
+ Services.cookies.removeAll();
+ Services.cookies.setCookieStringFromHttp(
+ firstPartyURI,
+ "key=value; max-age=1000",
+ channel
+ );
+
+ await fake_profile_change();
+
+ // Now check if the cookie persisted or not
+ let expectedCookieCount = 1;
+ if (!cookiesEnabled) {
+ expectedCookieCount = 0;
+ }
+ is(
+ Services.cookies.countCookiesFromHost(firstPartyURI.host),
+ expectedCookieCount,
+ "Number of cookies was not what expected after restarting session"
+ );
+
+ is(
+ Services.prefs.prefIsLocked("network.cookie.cookieBehavior"),
+ cookieJarSettingsLocked,
+ "Cookie behavior pref lock status should be what is expected"
+ );
+
+ window.openPreferencesTab("panePrivacy");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("acceptCookies")
+ );
+ let expectControlsDisabled = !cookiesEnabled || cookieJarSettingsLocked;
+
+ for (let id of ["acceptCookies", "showCookiesButton"]) {
+ is(
+ contentDocument.getElementById(id).disabled,
+ cookieJarSettingsLocked,
+ `#${id} disabled status should match expected`
+ );
+ }
+ for (let id of ["acceptThirdPartyMenu"]) {
+ is(
+ contentDocument.getElementById(id).disabled,
+ expectControlsDisabled,
+ `#${id} disabled status should match expected`
+ );
+ }
+
+ is(
+ contentDocument.getElementById("cookieExceptions").disabled,
+ cookieJarSettingsLocked,
+ "#cookieExceptions disabled status should matched expected"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+}
+
+add_task(async function prepare_tracker_tables() {
+ await UrlClassifierTestUtils.addTestTrackers();
+});
+
+add_task(async function test_initial_state() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false);
+ await test_cookie_settings({
+ cookiesEnabled: true,
+ thirdPartyCookiesEnabled: true,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_undefined_unlocked() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 3);
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {},
+ },
+ });
+ is(
+ Services.prefs.getIntPref("network.cookie.cookieBehavior", undefined),
+ 3,
+ "An empty cookie policy should not have changed the cookieBehavior preference"
+ );
+ restore_prefs();
+});
+
+add_task(async function test_disabled() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Default: false,
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: false,
+ thirdPartyCookiesEnabled: true,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_third_party_disabled() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ AcceptThirdParty: "never",
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: true,
+ thirdPartyCookiesEnabled: false,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_disabled_and_third_party_disabled() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Default: false,
+ AcceptThirdParty: "never",
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: false,
+ thirdPartyCookiesEnabled: false,
+ cookieJarSettingsLocked: false,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_disabled_and_third_party_disabled_locked() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Default: false,
+ AcceptThirdParty: "never",
+ Locked: true,
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: false,
+ thirdPartyCookiesEnabled: false,
+ cookieJarSettingsLocked: true,
+ });
+ restore_prefs();
+});
+
+add_task(async function test_undefined_locked() {
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "network.cookie.rejectForeignWithExceptions.enabled",
+ false
+ );
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Locked: true,
+ },
+ },
+ });
+
+ await test_cookie_settings({
+ cookiesEnabled: true,
+ thirdPartyCookiesEnabled: true,
+ cookieJarSettingsLocked: true,
+ });
+ restore_prefs();
+});
+
+add_task(async function prepare_tracker_tables() {
+ await UrlClassifierTestUtils.cleanupTestTrackers();
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js
new file mode 100644
index 0000000000..b69787422e
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_masterpassword.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const MASTER_PASSWORD = "omgsecret!";
+const mpToken = Cc["@mozilla.org/security/pk11tokendb;1"]
+ .getService(Ci.nsIPK11TokenDB)
+ .getInternalKeyToken();
+
+async function checkDeviceManager({ buttonIsDisabled }) {
+ let deviceManagerWindow = window.openDialog(
+ "chrome://pippki/content/device_manager.xhtml",
+ "",
+ ""
+ );
+ await BrowserTestUtils.waitForEvent(deviceManagerWindow, "load");
+
+ let tree = deviceManagerWindow.document.getElementById("device_tree");
+ ok(tree, "The device tree exists");
+
+ // Find and select the item related to the internal key token
+ for (let i = 0; i < tree.view.rowCount; i++) {
+ tree.view.selection.select(i);
+
+ try {
+ let selected_token = deviceManagerWindow.selected_slot.getToken();
+ if (selected_token.isInternalKeyToken) {
+ break;
+ }
+ } catch (e) {}
+ }
+
+ // Check to see if the button was updated correctly
+ let changePwButton =
+ deviceManagerWindow.document.getElementById("change_pw_button");
+ is(
+ changePwButton.getAttribute("disabled") == "true",
+ buttonIsDisabled,
+ "Change Password button is in the correct state: " + buttonIsDisabled
+ );
+
+ await BrowserTestUtils.closeWindow(deviceManagerWindow);
+}
+
+async function checkAboutPreferences({ checkboxIsDisabled }) {
+ await BrowserTestUtils.withNewTab(
+ "about:preferences#privacy",
+ async browser => {
+ is(
+ browser.contentDocument.getElementById("useMasterPassword").disabled,
+ checkboxIsDisabled,
+ "Primary Password checkbox is in the correct state: " +
+ checkboxIsDisabled
+ );
+ }
+ );
+}
+
+add_task(async function test_policy_disable_masterpassword() {
+ ok(!mpToken.hasPassword, "Starting the test with no password");
+
+ // No password and no policy: access to setting a primary password
+ // should be enabled.
+ await checkDeviceManager({ buttonIsDisabled: false });
+ await checkAboutPreferences({ checkboxIsDisabled: false });
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ DisableMasterPasswordCreation: true,
+ },
+ });
+
+ // With the `DisableMasterPasswordCreation: true` policy active, the
+ // UI entry points for creating a Primary Password should be disabled.
+ await checkDeviceManager({ buttonIsDisabled: true });
+ await checkAboutPreferences({ checkboxIsDisabled: true });
+
+ mpToken.changePassword("", MASTER_PASSWORD);
+ ok(mpToken.hasPassword, "Master password was set");
+
+ // If a Primary Password is already set, there's no point in disabling
+ // the
+ await checkDeviceManager({ buttonIsDisabled: false });
+ await checkAboutPreferences({ checkboxIsDisabled: false });
+
+ // Clean up
+ mpToken.changePassword(MASTER_PASSWORD, "");
+ ok(!mpToken.hasPassword, "Master password was cleaned up");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js
new file mode 100644
index 0000000000..3ce719b5f1
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_safemode.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_setup(async function () {
+ await setupPolicyEngineWithJson({
+ policies: {
+ DisableSafeMode: true,
+ },
+ });
+});
+
+add_task(async function test_help_menu() {
+ buildHelpMenu();
+ let safeModeMenu = document.getElementById("helpTroubleshootMode");
+ is(
+ safeModeMenu.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` item should be disabled"
+ );
+ let safeModeAppMenu = document.getElementById("appmenu_troubleshootMode");
+ is(
+ safeModeAppMenu.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` appmenu item should be disabled"
+ );
+});
+
+add_task(async function test_safemode_from_about_support() {
+ await withNewTab({ url: "about:support" }, browser => {
+ let button = content.document.getElementById("restart-in-safe-mode-button");
+ is(
+ button.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` button should be disabled"
+ );
+ });
+});
+
+add_task(async function test_safemode_from_about_profiles() {
+ await withNewTab({ url: "about:profiles" }, browser => {
+ let button = content.document.getElementById("restart-in-safe-mode-button");
+ is(
+ button.getAttribute("disabled"),
+ "true",
+ "The `Restart with Add-ons Disabled...` button should be disabled"
+ );
+ });
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js
new file mode 100644
index 0000000000..600be47763
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_disable_telemetry.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_policy_disable_telemetry() {
+ const { TelemetryReportingPolicy } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryReportingPolicy.sys.mjs"
+ );
+
+ ok(TelemetryReportingPolicy, "TelemetryReportingPolicy exists");
+ is(TelemetryReportingPolicy.canUpload(), true, "Telemetry is enabled");
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ DisableTelemetry: true,
+ },
+ });
+
+ is(TelemetryReportingPolicy.canUpload(), false, "Telemetry is disabled");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js
new file mode 100644
index 0000000000..520fcc67aa
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_downloads.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+add_task(async function test_defaultdownload() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ DefaultDownloadDirectory: "${home}/Downloads",
+ PromptForDownloadLocation: false,
+ },
+ });
+
+ window.openPreferencesTab("paneGeneral");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("alwaysAsk")
+ );
+ await new Promise(resolve =>
+ window.preferencesTabType.tab.browser.contentWindow.setTimeout(resolve)
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "alwaysAsk"
+ ).disabled,
+ true,
+ "alwaysAsk should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).selected,
+ true,
+ "saveTo should be selected."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).disabled,
+ true,
+ "saveTo should be disabled."
+ );
+ let home = FileUtils.getFile("Home", []).path;
+ is(
+ Services.prefs.getStringPref("browser.download.dir"),
+ home + "/Downloads",
+ "browser.download.dir should be ${home}/Downloads."
+ );
+ is(
+ Services.prefs.getBoolPref("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be true."
+ );
+ is(
+ Services.prefs.prefIsLocked("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be locked."
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+});
+
+add_task(async function test_download() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ DownloadDirectory: "${home}/Documents",
+ },
+ });
+
+ window.openPreferencesTab("paneGeneral");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("alwaysAsk")
+ );
+ await new Promise(resolve =>
+ window.preferencesTabType.tab.browser.contentWindow.setTimeout(resolve)
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "alwaysAsk"
+ ).disabled,
+ true,
+ "alwaysAsk should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).selected,
+ true,
+ "saveTo should be selected."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "saveTo"
+ ).disabled,
+ true,
+ "saveTo should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "downloadFolder"
+ ).disabled,
+ true,
+ "downloadFolder should be disabled."
+ );
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "chooseFolder"
+ ).disabled,
+ true,
+ "chooseFolder should be disabled."
+ );
+ let home = FileUtils.getFile("Home", []).path;
+ is(
+ Services.prefs.getStringPref("browser.download.dir"),
+ home + "/Documents",
+ "browser.download.dir should be ${home}/Documents."
+ );
+ is(
+ Services.prefs.getBoolPref("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be true."
+ );
+ is(
+ Services.prefs.prefIsLocked("browser.download.useDownloadDir"),
+ true,
+ "browser.download.useDownloadDir should be locked."
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js
new file mode 100644
index 0000000000..062fa2b714
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensions.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const ADDON_ID = "policytest@mozilla.com";
+const BASE_URL =
+ "http://mochi.test:8888/browser/comm/mail/components/enterprisepolicies/tests/browser";
+
+async function isExtensionLocked(win, addonID) {
+ let addonCard = await BrowserTestUtils.waitForCondition(() => {
+ return win.document.querySelector(`addon-card[addon-id="${addonID}"]`);
+ }, `Get addon-card for "${addonID}"`);
+ let disableBtn = addonCard.querySelector('[action="toggle-disabled"]');
+ let removeBtn = addonCard.querySelector('panel-item[action="remove"]');
+ ok(removeBtn.disabled, "Remove button should be disabled");
+ ok(disableBtn.hidden, "Disable button should be hidden");
+}
+
+add_task(async function test_addon_install() {
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Install: [`${BASE_URL}/policytest_v0.1.xpi`],
+ Locked: [ADDON_ID],
+ },
+ },
+ });
+ await installPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ isnot(addon, null, "Addon not installed.");
+ is(addon.version, "0.1", "Addon version is correct");
+
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ { source: "enterprise-policy" },
+ "Got the expected addon.installTelemetryInfo"
+ );
+});
+
+add_task(async function test_addon_locked() {
+ let tabmail = document.getElementById("tabmail");
+ let index = tabmail.tabInfo.length;
+ await window.openAddonsMgr("addons://list/extension");
+ let tab = tabmail.tabInfo[index];
+ let browser = tab.browser;
+
+ await isExtensionLocked(browser.contentWindow, ADDON_ID);
+
+ tabmail.closeTab(tab);
+});
+
+add_task(async function test_addon_reinstall() {
+ // Test that uninstalling and reinstalling the same addon ID works as expected.
+ // This can be used to update an addon.
+
+ let uninstallPromise = waitForAddonUninstall(ADDON_ID);
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Uninstall: [ADDON_ID],
+ Install: [`${BASE_URL}/policytest_v0.2.xpi`],
+ },
+ },
+ });
+
+ // Older version was uninstalled
+ await uninstallPromise;
+
+ // New version was installed
+ await installPromise;
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ isnot(
+ addon,
+ null,
+ "Addon still exists because the policy was used to update it."
+ );
+ is(addon.version, "0.2", "New version is correct");
+});
+
+add_task(async function test_addon_uninstall() {
+ EnterprisePolicyTesting.resetRunOnceState();
+
+ let uninstallPromise = waitForAddonUninstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Uninstall: [ADDON_ID],
+ },
+ },
+ });
+ await uninstallPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ is(addon, null, "Addon should be uninstalled.");
+});
+
+add_task(async function test_addon_download_failure() {
+ // Test that if the download fails, the runOnce pref
+ // is cleared so that the download will happen again.
+
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Install: [`${BASE_URL}/policytest_invalid.xpi`],
+ },
+ },
+ });
+
+ await installPromise;
+ is(
+ Services.prefs.prefHasUserValue(
+ "browser.policies.runOncePerModification.extensionsInstall"
+ ),
+ false,
+ "runOnce pref should be unset"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
new file mode 100644
index 0000000000..7fced9c1ad
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
@@ -0,0 +1,261 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+const BASE_URL =
+ "http://mochi.test:8888/browser/comm/mail/components/enterprisepolicies/tests/browser/";
+
+async function openTab(url) {
+ let tab = window.openContentTab(url, null, null);
+ if (
+ tab.browser.webProgress?.isLoadingDocument ||
+ tab.browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ }
+ return tab;
+}
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstElementChild);
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function dismissNotification(win = window) {
+ return new Promise(resolve => {
+ function popuphidden() {
+ PopupNotifications.panel.removeEventListener("popuphidden", popuphidden);
+ resolve();
+ }
+ PopupNotifications.panel.addEventListener("popuphidden", popuphidden);
+ executeSoon(function () {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
+ });
+ });
+}
+
+add_setup(async function setupTestEnvironment() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+});
+
+add_task(async function test_install_source_blocked_link() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://blocks.other.install.sources/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_installtrigger() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://blocks.other.install.sources/*"],
+ blocked_install_message: "blocked_install_message",
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_installtrigger").click();
+ });
+ let popup = await popupPromise;
+ let description = popup.querySelector(".popup-notification-description");
+ ok(
+ description.textContent.endsWith("blocked_install_message"),
+ "Custom install message present"
+ );
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_otherdomain() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_otherdomain").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_direct() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://blocks.other.install.sources/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-policy-blocked"
+ );
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ baseUrl: BASE_URL }],
+ async function ({ baseUrl }) {
+ content.document.location.href = baseUrl + "policytest_v0.1.xpi";
+ }
+ );
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_link() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_installtrigger() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_installtrigger").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_otherdomain() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*", "http://example.org/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("policytest_otherdomain").click();
+ });
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_direct() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["http://mochi.test/*"],
+ },
+ },
+ },
+ });
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let tab = await openTab(`${BASE_URL}extensionsettings.html`);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ baseUrl: BASE_URL }],
+ async function ({ baseUrl }) {
+ content.document.location.href = baseUrl + "policytest_v0.1.xpi";
+ }
+ );
+ await popupPromise;
+ await dismissNotification();
+ document.getElementById("tabmail").closeTab(tab);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js
new file mode 100644
index 0000000000..e335d70fe0
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings2.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const ADDON_ID = "policytest@mozilla.com";
+const BASE_URL =
+ "http://mochi.test:8888/browser/comm/mail/components/enterprisepolicies/tests/browser";
+
+async function isExtensionLockedAndUpdateDisabled(win, addonID) {
+ let addonCard = await BrowserTestUtils.waitForCondition(() => {
+ return win.document.querySelector(`addon-card[addon-id="${addonID}"]`);
+ }, `Get addon-card for "${addonID}"`);
+ let disableBtn = addonCard.querySelector('[action="toggle-disabled"]');
+ let removeBtn = addonCard.querySelector('panel-item[action="remove"]');
+ ok(removeBtn.disabled, "Remove button should be disabled");
+ ok(disableBtn.hidden, "Disable button should be hidden");
+ let updateRow = addonCard.querySelector(".addon-detail-row-updates");
+ is(updateRow.hidden, true, "Update row should be hidden");
+}
+
+add_task(async function test_addon_install() {
+ let installPromise = waitForAddonInstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "policytest@mozilla.com": {
+ install_url: `${BASE_URL}/policytest_v0.1.xpi`,
+ installation_mode: "force_installed",
+ updates_disabled: true,
+ },
+ },
+ },
+ });
+ await installPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ isnot(addon, null, "Addon not installed.");
+ is(addon.version, "0.1", "Addon version is correct");
+
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ { source: "enterprise-policy" },
+ "Got the expected addon.installTelemetryInfo"
+ );
+});
+
+add_task(async function test_addon_locked_update_disabled() {
+ let tabmail = document.getElementById("tabmail");
+ let index = tabmail.tabInfo.length;
+ await window.openAddonsMgr("addons://detail/" + encodeURIComponent(ADDON_ID));
+ let tab = tabmail.tabInfo[index];
+ let browser = tab.browser;
+ let win = browser.contentWindow;
+
+ await isExtensionLockedAndUpdateDisabled(win, ADDON_ID);
+
+ tabmail.closeTab(tab);
+});
+
+add_task(async function test_addon_uninstall() {
+ let uninstallPromise = waitForAddonUninstall(ADDON_ID);
+ await setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "policytest@mozilla.com": {
+ installation_mode: "blocked",
+ },
+ },
+ },
+ });
+ await uninstallPromise;
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ is(addon, null, "Addon should be uninstalled.");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js
new file mode 100644
index 0000000000..6242625756
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_handlers.js
@@ -0,0 +1,183 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+const gExternalProtocolService = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+].getService(Ci.nsIExternalProtocolService);
+
+const gHandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+
+// This seems odd, but for test purposes, this just has to be a file that we know exists,
+// and by using this file, we don't have to worry about different platforms.
+let exeFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+
+add_task(async function test_valid_handlers() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ mimeTypes: {
+ "application/marimba": {
+ action: "useHelperApp",
+ ask: true,
+ handlers: [
+ {
+ name: "Launch",
+ path: exeFile.path,
+ },
+ ],
+ },
+ },
+ schemes: {
+ fake_scheme: {
+ action: "useHelperApp",
+ ask: false,
+ handlers: [
+ {
+ name: "Name",
+ uriTemplate: "https://www.example.org/?%s",
+ },
+ ],
+ },
+ },
+ extensions: {
+ txt: {
+ action: "saveToDisk",
+ ask: false,
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo = gMIMEService.getFromTypeAndExtension(
+ "application/marimba",
+ ""
+ );
+ is(handlerInfo.preferredAction, handlerInfo.useHelperApp);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler.name, "Launch");
+ is(handlerInfo.preferredApplicationHandler.executable.path, exeFile.path);
+
+ handlerInfo.preferredApplicationHandler = null;
+ gHandlerService.store(handlerInfo);
+
+ handlerInfo = handlerInfo = gMIMEService.getFromTypeAndExtension(
+ "application/marimba",
+ ""
+ );
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+
+ handlerInfo = gExternalProtocolService.getProtocolHandlerInfo("fake_scheme");
+ is(handlerInfo.preferredAction, handlerInfo.useHelperApp);
+ is(handlerInfo.alwaysAskBeforeHandling, false);
+ is(handlerInfo.preferredApplicationHandler.name, "Name");
+ is(
+ handlerInfo.preferredApplicationHandler.uriTemplate,
+ "https://www.example.org/?%s"
+ );
+
+ handlerInfo.preferredApplicationHandler = null;
+ gHandlerService.store(handlerInfo);
+
+ handlerInfo = gExternalProtocolService.getProtocolHandlerInfo("fake_scheme");
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+
+ handlerInfo = gMIMEService.getFromTypeAndExtension("", "txt");
+ is(handlerInfo.preferredAction, handlerInfo.saveToDisk);
+ is(handlerInfo.alwaysAskBeforeHandling, false);
+
+ handlerInfo.preferredApplicationHandler = null;
+ gHandlerService.store(handlerInfo);
+ handlerInfo = gMIMEService.getFromTypeAndExtension("", "txt");
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
+
+add_task(async function test_no_handler() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ schemes: {
+ no_handler: {
+ action: "useHelperApp",
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo =
+ gExternalProtocolService.getProtocolHandlerInfo("no_handler");
+ is(handlerInfo.preferredAction, handlerInfo.alwaysAsk);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
+
+add_task(async function test_bad_web_handler1() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ schemes: {
+ bas_web_handler1: {
+ action: "useHelperApp",
+ handlers: [
+ {
+ name: "Name",
+ uriTemplate: "https://www.example.org/?%s",
+ },
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo =
+ gExternalProtocolService.getProtocolHandlerInfo("bad_web_handler1");
+ is(handlerInfo.preferredAction, handlerInfo.alwaysAsk);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
+
+add_task(async function test_bad_web_handler2() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Handlers: {
+ schemes: {
+ bas_web_handler1: {
+ action: "useHelperApp",
+ handlers: [
+ {
+ name: "Name",
+ uriTemplate: "https://www.example.org/",
+ },
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ let handlerInfo =
+ gExternalProtocolService.getProtocolHandlerInfo("bad_web_handler1");
+ is(handlerInfo.preferredAction, handlerInfo.alwaysAsk);
+ is(handlerInfo.alwaysAskBeforeHandling, true);
+ is(handlerInfo.preferredApplicationHandler, null);
+
+ gHandlerService.remove(handlerInfo);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js
new file mode 100644
index 0000000000..8320897341
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_masterpassword.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+);
+
+// Test that once a password is set, you can't unset it
+add_task(async function test_policy_masterpassword_set() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PrimaryPassword: true,
+ },
+ });
+
+ LoginTestUtils.primaryPassword.enable();
+
+ window.openPreferencesTab("panePrivacy");
+ await BrowserTestUtils.browserLoaded(
+ window.preferencesTabType.tab.browser,
+ undefined,
+ url => url.startsWith("about:preferences")
+ );
+ let { contentDocument } = window.preferencesTabType.tab.browser;
+ await TestUtils.waitForCondition(() =>
+ contentDocument.getElementById("useMasterPassword")
+ );
+
+ is(
+ contentDocument.getElementById("useMasterPassword").disabled,
+ true,
+ "Primary Password checkbox should be disabled"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+
+ LoginTestUtils.primaryPassword.disable();
+});
+
+// Test that password can't be removed in changemp.xhtml
+add_task(async function test_policy_nochangemp() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PrimaryPassword: true,
+ },
+ });
+
+ LoginTestUtils.primaryPassword.enable();
+
+ let changeMPWindow = window.openDialog(
+ "chrome://mozapps/content/preferences/changemp.xhtml",
+ "",
+ ""
+ );
+ await BrowserTestUtils.waitForEvent(changeMPWindow, "load");
+
+ is(
+ changeMPWindow.document.getElementById("admin").hidden,
+ true,
+ "Admin message should not be visible because there is a password."
+ );
+
+ changeMPWindow.document.getElementById("oldpw").value =
+ LoginTestUtils.primaryPassword.masterPassword;
+
+ is(
+ changeMPWindow.document.getElementById("changemp").getButton("accept")
+ .disabled,
+ true,
+ "OK button should not be enabled if there is an old password."
+ );
+
+ await BrowserTestUtils.closeWindow(changeMPWindow);
+
+ LoginTestUtils.primaryPassword.disable();
+});
+
+// Test that admin message shows
+add_task(async function test_policy_admin() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PrimaryPassword: true,
+ },
+ });
+
+ let changeMPWindow = window.openDialog(
+ "chrome://mozapps/content/preferences/changemp.xhtml",
+ "",
+ ""
+ );
+ await BrowserTestUtils.waitForEvent(changeMPWindow, "load");
+
+ is(
+ changeMPWindow.document.getElementById("admin").hidden,
+ false,
+ true,
+ "Admin message should not be hidden because there is not a password."
+ );
+
+ await BrowserTestUtils.closeWindow(changeMPWindow);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js
new file mode 100644
index 0000000000..1f74d46754
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/browser_policy_passwordmanager.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_pwmanagerbutton() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ PasswordManagerEnabled: false,
+ },
+ });
+
+ window.openPreferencesTab("panePrivacy");
+ await BrowserTestUtils.browserLoaded(window.preferencesTabType.tab.browser);
+ await new Promise(resolve => setTimeout(resolve));
+
+ is(
+ window.preferencesTabType.tab.browser.contentDocument.getElementById(
+ "showPasswords"
+ ).disabled,
+ true,
+ "showPasswords should be disabled."
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(window.preferencesTabType.tab);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini
new file mode 100644
index 0000000000..73162633db
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ app.update.disabledForTesting=false
+ browser.policies.alternatePath='<test-root>/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json'
+subsuite = thunderbird
+support-files =
+ config_disable_app_update.json
+skip-if = os == 'win' && msix # Updater is disabled in MSIX builds
+
+[browser_policy_disable_app_update.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js
new file mode 100644
index 0000000000..28dcd780d1
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/browser_policy_disable_app_update.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+var updateService = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+);
+
+add_task(async function test_updates_post_policy() {
+ is(
+ Services.policies.isAllowed("appUpdate"),
+ false,
+ "appUpdate should be disabled by policy."
+ );
+
+ is(
+ updateService.canCheckForUpdates,
+ false,
+ "Should not be able to check for updates with DisableAppUpdate enabled."
+ );
+});
+
+add_task(async function test_update_preferences_ui() {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL == "about:preferences") {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ window.openPreferencesTab("paneGeneral", "updateApp");
+ });
+
+ await new Promise(resolve => setTimeout(resolve));
+
+ let setting = prefsDocument.getElementById("updateSettingsContainer");
+ is(
+ setting.hidden,
+ true,
+ "Update choices should be disabled when app update is locked by policy"
+ );
+
+ tabmail.closeTab(prefsTabMode.tabs[0]);
+});
+
+add_task(async function test_update_about_ui() {
+ let aboutDialog = await waitForAboutDialog();
+ let panelId = "policyDisabled";
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ aboutDialog.gAppUpdater.selectedPanel &&
+ aboutDialog.gAppUpdater.selectedPanel.id == panelId,
+ 'Waiting for expected panel ID - expected "' + panelId + '"'
+ );
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ panelId,
+ "The About Dialog panel Id should equal " + panelId
+ );
+
+ // Make sure that we still remain on the "disabled by policy" panel after
+ // `AppUpdater.stop()` is called.
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ panelId,
+ "The About Dialog panel Id should still equal " + panelId
+ );
+
+ aboutDialog.close();
+});
+
+/**
+ * Waits for the About Dialog to load.
+ *
+ * @returns A promise that returns the domWindow for the About Dialog and
+ * resolves when the About Dialog loads.
+ */
+function waitForAboutDialog() {
+ return new Promise(resolve => {
+ var listener = {
+ onOpenWindow: aAppWindow => {
+ Services.wm.removeListener(listener);
+
+ async function aboutDialogOnLoad() {
+ domwindow.removeEventListener("load", aboutDialogOnLoad, true);
+ let chromeURI = "chrome://messenger/content/aboutDialog.xhtml";
+ is(
+ domwindow.document.location.href,
+ chromeURI,
+ "About dialog appeared"
+ );
+ resolve(domwindow);
+ }
+
+ var domwindow = aAppWindow.docShell.domWindow;
+ domwindow.addEventListener("load", aboutDialogOnLoad, true);
+ },
+ onCloseWindow: aAppWindow => {},
+ };
+
+ Services.wm.addListener(listener);
+ openAboutDialog();
+ });
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json
new file mode 100644
index 0000000000..f36622021f
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_app_update/config_disable_app_update.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "DisableAppUpdate": true
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini
new file mode 100644
index 0000000000..b40dff605b
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ browser.policies.alternatePath='<test-root>/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json'
+subsuite = thunderbird
+support-files =
+ config_disable_developer_tools.json
+
+[browser_policy_disable_developer_tools.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js
new file mode 100644
index 0000000000..35ad87ab4d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/browser_policy_disable_developer_tools.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_updates_post_policy() {
+ is(
+ Services.policies.isAllowed("devtools"),
+ false,
+ "devtools should be disabled by policy."
+ );
+
+ is(
+ Services.prefs.getBoolPref("devtools.policy.disabled"),
+ true,
+ "devtools dedicated disabled pref is set to true"
+ );
+
+ Services.prefs.setBoolPref("devtools.policy.disabled", false);
+
+ is(
+ Services.prefs.getBoolPref("devtools.policy.disabled"),
+ true,
+ "devtools dedicated disabled pref can not be updated"
+ );
+
+ await expectErrorPage("about:devtools-toolbox");
+ await expectErrorPage("about:debugging");
+
+ info("Check that devtools menu items are hidden");
+ let devtoolsMenu = window.document.getElementById("devtoolsMenu");
+ ok(devtoolsMenu.hidden, "The Web Developer item of the tools menu is hidden");
+});
+
+const expectErrorPage = async function (url) {
+ let tabmail = document.getElementById("tabmail");
+ let index = tabmail.tabInfo.length;
+ window.openContentTab("about:blank");
+ let tab = tabmail.tabInfo[index];
+ let browser = tab.browser;
+
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url, true);
+ await SpecialPowers.spawn(browser, [url], async function () {
+ ok(
+ content.document.documentURI.startsWith(
+ "about:neterror?e=blockedByPolicy"
+ ),
+ content.document.documentURI +
+ " should start with about:neterror?e=blockedByPolicy"
+ );
+ });
+
+ tabmail.closeTab(tab);
+};
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json
new file mode 100644
index 0000000000..08c393dec6
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/disable_developer_tools/config_disable_developer_tools.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "DisableDeveloperTools": true
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html b/comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html
new file mode 100644
index 0000000000..a54c011968
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/extensionsettings.html
@@ -0,0 +1,23 @@
+
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<script type="text/javascript">
+function installTrigger(url) {
+ InstallTrigger.install({extension: url});
+}
+</script>
+</head>
+<body>
+<p>
+<a id="policytest" href="policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+<p>
+<a id="policytest_installtrigger" onclick="installTrigger(this.href);return false;" href="policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+<p>
+<a id="policytest_otherdomain" href="http://example.org:80/browser/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+</body>
+</html>
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini
new file mode 100644
index 0000000000..01e3e74e22
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+prefs =
+ browser.policies.alternatePath='<test-root>/browser/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json'
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+support-files =
+ disable_hardware_acceleration.json
+
+[browser_policy_hardware_acceleration.js]
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js
new file mode 100644
index 0000000000..59ca2a3631
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/browser_policy_hardware_acceleration.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_policy_hardware_acceleration() {
+ let winUtils = Services.wm.getMostRecentWindow("").windowUtils;
+ is(winUtils.layerManagerType, "Basic", "Hardware acceleration disabled");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json
new file mode 100644
index 0000000000..acbdc0a3f4
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/hardware_acceleration/disable_hardware_acceleration.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "HardwareAcceleration": false
+ }
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/head.js b/comm/mail/components/enterprisepolicies/tests/browser/head.js
new file mode 100644
index 0000000000..b557ea3d22
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/head.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { EnterprisePolicyTesting, PoliciesPrefTracker } =
+ ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+ );
+
+PoliciesPrefTracker.start();
+
+async function setupPolicyEngineWithJson(json, customSchema) {
+ PoliciesPrefTracker.restoreDefaultValues();
+ if (typeof json != "object") {
+ let filePath = getTestFilePath(json ? json : "non-existing-file.json");
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ }
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+}
+
+function checkLockedPref(prefName, prefValue) {
+ EnterprisePolicyTesting.checkPolicyPref(prefName, prefValue, true);
+}
+
+function checkUnlockedPref(prefName, prefValue) {
+ EnterprisePolicyTesting.checkPolicyPref(prefName, prefValue, false);
+}
+
+async function withNewTab(options, taskFn) {
+ let tab = window.openContentTab(options.url);
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ let result = await taskFn(tab.browser);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+ return Promise.resolve(result);
+}
+
+add_setup(async function policies_headjs_startWithCleanSlate() {
+ if (Services.policies.status != Ci.nsIEnterprisePolicies.INACTIVE) {
+ await setupPolicyEngineWithJson("");
+ }
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.INACTIVE,
+ "Engine is inactive at the start of the test"
+ );
+});
+
+registerCleanupFunction(async function policies_headjs_finishWithCleanSlate() {
+ if (Services.policies.status != Ci.nsIEnterprisePolicies.INACTIVE) {
+ await setupPolicyEngineWithJson("");
+ }
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.INACTIVE,
+ "Engine is inactive at the end of the test"
+ );
+
+ EnterprisePolicyTesting.resetRunOnceState();
+ PoliciesPrefTracker.stop();
+});
+
+function waitForAddonInstall(addon_id) {
+ return new Promise(resolve => {
+ let listener = {
+ onInstallEnded(install, addon) {
+ if (addon.id == addon_id) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+function waitForAddonUninstall(addon_id) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener.onUninstalled = addon => {
+ if (addon.id == addon_id) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi
new file mode 100644
index 0000000000..ee2a6289ee
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi
Binary files differ
diff --git a/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi
new file mode 100644
index 0000000000..59d589eba9
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/browser/policytest_v0.2.xpi
Binary files differ
diff --git a/comm/mail/components/enterprisepolicies/tests/moz.build b/comm/mail/components/enterprisepolicies/tests/moz.build
new file mode 100644
index 0000000000..c5014bbc67
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser.ini",
+ "browser/disable_app_update/browser.ini",
+ "browser/disable_developer_tools/browser.ini",
+ "browser/hardware_acceleration/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "xpcshell/xpcshell.ini",
+]
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/head.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/head.js
new file mode 100644
index 0000000000..2fcf00a21b
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/head.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const lazy = {};
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+const { updateAppInfo, getAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PermissionTestUtils.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+});
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+updateAppInfo({
+ name: "XPCShell",
+ ID: "xpcshell@tests.mozilla.org",
+ version: "48",
+ platformVersion: "48",
+});
+
+// This initializes the policy engine for xpcshell tests
+let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService(
+ Ci.nsIObserver
+);
+policies.observe(null, "policies-startup", null);
+
+async function setupPolicyEngineWithJson(json, customSchema) {
+ if (typeof json != "object") {
+ let filePath = do_get_file(json ? json : "non-existing-file.json").path;
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ }
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+}
+
+/**
+ * Loads a new enterprise policy, and re-initialise the search service
+ * with the new policy. Also waits for the search service to write the settings
+ * file to disk.
+ *
+ * @param {object} policy
+ * The enterprise policy to use.
+ * @param {object} customSchema
+ * A custom schema to use to validate the enterprise policy.
+ */
+async function setupPolicyEngineWithJsonWithSearch(json, customSchema) {
+ Services.search.wrappedJSObject.reset();
+ if (typeof json != "object") {
+ let filePath = do_get_file(json ? json : "non-existing-file.json").path;
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ } else {
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+ }
+ let settingsWritten = lazy.SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await Services.search.init();
+ return settingsWritten;
+}
+
+function checkLockedPref(prefName, prefValue) {
+ equal(
+ Preferences.locked(prefName),
+ true,
+ `Pref ${prefName} is correctly locked`
+ );
+ equal(
+ Preferences.get(prefName),
+ prefValue,
+ `Pref ${prefName} has the correct value`
+ );
+}
+
+function checkUnlockedPref(prefName, prefValue) {
+ equal(
+ Preferences.locked(prefName),
+ false,
+ `Pref ${prefName} is correctly unlocked`
+ );
+ equal(
+ Preferences.get(prefName),
+ prefValue,
+ `Pref ${prefName} has the correct value`
+ );
+}
+
+function checkUserPref(prefName, prefValue) {
+ equal(
+ Preferences.get(prefName),
+ prefValue,
+ `Pref ${prefName} has the correct value`
+ );
+}
+
+function checkClearPref(prefName, prefValue) {
+ equal(
+ Services.prefs.prefHasUserValue(prefName),
+ false,
+ `Pref ${prefName} has no user value`
+ );
+}
+
+function checkDefaultPref(prefName, prefValue) {
+ let defaultPrefBranch = Services.prefs.getDefaultBranch("");
+ let prefType = defaultPrefBranch.getPrefType(prefName);
+ notEqual(
+ prefType,
+ Services.prefs.PREF_INVALID,
+ `Pref ${prefName} is set on the default branch`
+ );
+}
+
+function checkUnsetPref(prefName) {
+ let defaultPrefBranch = Services.prefs.getDefaultBranch("");
+ let prefType = defaultPrefBranch.getPrefType(prefName);
+ equal(
+ prefType,
+ Services.prefs.PREF_INVALID,
+ `Pref ${prefName} is not set on the default branch`
+ );
+}
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js
new file mode 100644
index 0000000000..b9dabb758d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_3rdparty.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_setup(async function () {
+ await setupPolicyEngineWithJson({
+ policies: {
+ "3rdparty": {
+ Extensions: {
+ "3rdparty-policy@mozilla.com": {
+ string: "value",
+ },
+ },
+ },
+ },
+ });
+
+ let extensionPolicy = Services.policies.getExtensionPolicy(
+ "3rdparty-policy@mozilla.com"
+ );
+ deepEqual(extensionPolicy, { string: "value" });
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js
new file mode 100644
index 0000000000..01e3810a05
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdatepin.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Note that these tests only ensure that the pin is properly added to the
+ * update URL and to the telemetry. They do not test that the update applied
+ * will be of the correct version. This is because we are not attempting to have
+ * Thunderbird check if the update provided is valid given the pin, we are leaving
+ * it to the update server (Balrog) to find and serve the correct version.
+ */
+
+async function test_update_pin(pinString, pinIsValid = true) {
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppUpdateURL: "https://www.example.com/update.xml",
+ AppUpdatePin: pinString,
+ },
+ });
+ Services.telemetry.clearScalars();
+
+ equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ let policies = Services.policies.getActivePolicies();
+ equal(
+ "AppUpdatePin" in policies,
+ pinIsValid,
+ "AppUpdatePin policy should only be active if the pin was valid."
+ );
+
+ let checker = Cc["@mozilla.org/updates/update-checker;1"].getService(
+ Ci.nsIUpdateChecker
+ );
+ let updateURL = await checker.getUpdateURL(checker.BACKGROUND_CHECK);
+
+ let expected = pinIsValid
+ ? `https://www.example.com/update.xml?pin=${pinString}`
+ : "https://www.example.com/update.xml";
+
+ equal(updateURL, expected, "App Update URL should match expected URL.");
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ if (pinIsValid) {
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "update.version_pin",
+ pinString,
+ "Update pin telemetry should be set"
+ );
+ } else {
+ TelemetryTestUtils.assertScalarUnset(scalars, "update.version_pin");
+ }
+}
+
+add_task(async function test_app_update_pin() {
+ await test_update_pin("102.");
+ await test_update_pin("102.0.");
+ await test_update_pin("102.1.");
+ await test_update_pin("102.1.1", false);
+ await test_update_pin("102.1.1.", false);
+ await test_update_pin("102", false);
+ await test_update_pin("foobar", false);
+ await test_update_pin("-102.1.", false);
+ await test_update_pin("102.-1.", false);
+ await test_update_pin("102a.1.", false);
+ await test_update_pin("102.1a.", false);
+ await test_update_pin("0102.1.", false);
+ // Should not accept version numbers that will never be in Balrog's pinning
+ // table (i.e. versions before 102.0).
+ await test_update_pin("101.1.", false);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js
new file mode 100644
index 0000000000..48d04e1a8d
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_appupdateurl.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_app_update_URL() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ AppUpdateURL: "https://www.example.com/",
+ },
+ });
+
+ equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ let checker = Cc["@mozilla.org/updates/update-checker;1"].getService(
+ Ci.nsIUpdateChecker
+ );
+ let expected = await checker.getUpdateURL(checker.BACKGROUND_CHECK);
+
+ equal("https://www.example.com/", expected, "Correct app update URL");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js
new file mode 100644
index 0000000000..1449e664c2
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_bug1658259.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_bug1658259_1() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLogins: false,
+ OfferToSaveLoginsDefault: true,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", false);
+});
+
+add_task(async function test_bug1658259_2() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLogins: true,
+ OfferToSaveLoginsDefault: false,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", true);
+});
+
+add_task(async function test_bug1658259_3() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLoginsDefault: true,
+ OfferToSaveLogins: false,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", false);
+});
+
+add_task(async function test_bug1658259_4() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ OfferToSaveLoginsDefault: false,
+ OfferToSaveLogins: true,
+ },
+ });
+ checkLockedPref("signon.rememberSignons", true);
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js
new file mode 100644
index 0000000000..17149f787f
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_clear_blocked_cookies.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const HOSTNAME_DOMAIN = "browser_policy_clear_blocked_cookies.com";
+const ORIGIN_DOMAIN = "browser_policy_clear_blocked_cookies.org";
+
+add_setup(async function () {
+ const expiry = Date.now() + 24 * 60 * 60;
+ Services.cookies.add(
+ HOSTNAME_DOMAIN,
+ "/",
+ "secure",
+ "true",
+ true,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ Services.cookies.add(
+ HOSTNAME_DOMAIN,
+ "/",
+ "insecure",
+ "true",
+ false,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ Services.cookies.add(
+ ORIGIN_DOMAIN,
+ "/",
+ "secure",
+ "true",
+ true,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ Services.cookies.add(
+ ORIGIN_DOMAIN,
+ "/",
+ "insecure",
+ "true",
+ false,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ Services.cookies.add(
+ "example.net",
+ "/",
+ "secure",
+ "true",
+ true,
+ false,
+ false,
+ expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ await setupPolicyEngineWithJson({
+ policies: {
+ Cookies: {
+ Block: [`http://${HOSTNAME_DOMAIN}`, `https://${ORIGIN_DOMAIN}:8080`],
+ },
+ },
+ });
+});
+
+function retrieve_all_cookies(host) {
+ const values = [];
+ for (let cookie of Services.cookies.getCookiesFromHost(host, {})) {
+ values.push({
+ host: cookie.host,
+ name: cookie.name,
+ path: cookie.path,
+ });
+ }
+ return values;
+}
+
+add_task(async function test_cookies_for_blocked_sites_cleared() {
+ const cookies = {
+ hostname: retrieve_all_cookies(HOSTNAME_DOMAIN),
+ origin: retrieve_all_cookies(ORIGIN_DOMAIN),
+ keep: retrieve_all_cookies("example.net"),
+ };
+ const expected = {
+ hostname: [],
+ origin: [],
+ keep: [{ host: "example.net", name: "secure", path: "/" }],
+ };
+ equal(
+ JSON.stringify(cookies),
+ JSON.stringify(expected),
+ "All stored cookies for blocked origins should be cleared"
+ );
+});
+
+add_task(function teardown() {
+ for (let host of [HOSTNAME_DOMAIN, ORIGIN_DOMAIN, "example.net"]) {
+ Services.cookies.removeCookiesWithOriginAttributes("{}", host);
+ }
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js
new file mode 100644
index 0000000000..096852612c
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_macosparser_unflatten.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { macOSPoliciesParser } = ChromeUtils.importESModule(
+ "resource://gre/modules/policies/macOSPoliciesParser.sys.mjs"
+);
+
+add_task(async function test_object_unflatten() {
+ // Note: these policies are just examples and they won't actually
+ // run through the policy engine on this test. We're just testing
+ // that the unflattening algorithm produces the correct output.
+ let input = {
+ DisplayBookmarksToolbar: true,
+
+ Homepage__URL: "https://www.mozilla.org",
+ Homepage__Locked: "true",
+ Homepage__Additional__0: "https://extra-homepage-1.example.com",
+ Homepage__Additional__1: "https://extra-homepage-2.example.com",
+
+ WebsiteFilter__Block__0: "*://*.example.org/*",
+ WebsiteFilter__Block__1: "*://*.example.net/*",
+ WebsiteFilter__Exceptions__0: "*://*.example.org/*exception*",
+
+ Permissions__Camera__Allow__0: "https://www.example.com",
+
+ Permissions__Notifications__Allow__0: "https://www.example.com",
+ Permissions__Notifications__Allow__1: "https://www.example.org",
+ Permissions__Notifications__Block__0: "https://www.example.net",
+
+ Permissions__Notifications__BlockNewRequests: true,
+ Permissions__Notifications__Locked: true,
+
+ Bookmarks__0__Title: "Bookmark 1",
+ Bookmarks__0__URL: "https://bookmark1.example.com",
+
+ Bookmarks__1__Title: "Bookmark 2",
+ Bookmarks__1__URL: "https://bookmark2.example.com",
+ Bookmarks__1__Folder: "Folder",
+ };
+
+ let expected = {
+ DisplayBookmarksToolbar: true,
+
+ Homepage: {
+ URL: "https://www.mozilla.org",
+ Locked: "true",
+ Additional: [
+ "https://extra-homepage-1.example.com",
+ "https://extra-homepage-2.example.com",
+ ],
+ },
+
+ WebsiteFilter: {
+ Block: ["*://*.example.org/*", "*://*.example.net/*"],
+ Exceptions: ["*://*.example.org/*exception*"],
+ },
+
+ Permissions: {
+ Camera: {
+ Allow: ["https://www.example.com"],
+ },
+
+ Notifications: {
+ Allow: ["https://www.example.com", "https://www.example.org"],
+ Block: ["https://www.example.net"],
+ BlockNewRequests: true,
+ Locked: true,
+ },
+ },
+
+ Bookmarks: [
+ {
+ Title: "Bookmark 1",
+ URL: "https://bookmark1.example.com",
+ },
+ {
+ Title: "Bookmark 2",
+ URL: "https://bookmark2.example.com",
+ Folder: "Folder",
+ },
+ ],
+ };
+
+ let unflattened = macOSPoliciesParser.unflatten(input);
+
+ deepEqual(unflattened, expected, "Input was unflattened correctly.");
+});
+
+add_task(async function test_array_unflatten() {
+ let input = {
+ Foo__1: 1,
+ Foo__5: 5,
+ Foo__10: 10,
+ Foo__30: 30,
+ Foo__51: 51, // This one should not be included as the limit is 50
+ };
+
+ let unflattened = macOSPoliciesParser.unflatten(input);
+ equal(unflattened.Foo.length, 31, "Array size is correct");
+
+ let expected = {
+ Foo: [, 1, , , , 5], // eslint-disable-line no-sparse-arrays
+ };
+ expected.Foo[10] = 10;
+ expected.Foo[30] = 30;
+
+ deepEqual(unflattened, expected, "Array was unflattened correctly.");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js
new file mode 100644
index 0000000000..be16829867
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_policy_search_engine.js
@@ -0,0 +1,490 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+Services.prefs.setBoolPref("browser.search.log", true);
+SearchTestUtils.init(this);
+
+AddonTestUtils.init(this, false);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "48",
+ "48"
+);
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+ console.log("done init");
+});
+
+add_task(async function test_install_and_set_default() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.notEqual(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Default search engine should not be MozSearch when test starts"
+ );
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "MozSearch",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ Default: "MozSearch",
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the new search engine was properly installed
+ // *and* was properly set as the default.
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Specified search engine should be the default"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_set_default_private() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.notEqual(
+ (await Services.search.getDefaultPrivate()).name,
+ "MozSearch",
+ "Default search engine should not be MozSearch when test starts"
+ );
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "MozSearch",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ DefaultPrivate: "MozSearch",
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the new search engine was properly installed
+ // *and* was properly set as the default.
+ Assert.equal(
+ (await Services.search.getDefaultPrivate()).name,
+ "MozSearch",
+ "Specified search engine should be the default private engine"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+// Same as the last test, but with "PreventInstalls" set to true to make sure
+// it does not prevent search engines from being installed properly
+add_task(async function test_install_and_set_default_prevent_installs() {
+ Assert.notEqual(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Default search engine should not be MozSearch when test starts"
+ );
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "MozSearch",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ Default: "MozSearch",
+ PreventInstalls: true,
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "MozSearch",
+ "Specified search engine should be the default"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_remove() {
+ let iconURL =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
+
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ 'Engine "Foo" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Foo",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ IconURL: iconURL,
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the new search engine was properly installed
+
+ let engine = Services.search.getEngineByName("Foo");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.wrappedJSObject.iconURI.spec,
+ iconURL,
+ "Icon should be present"
+ );
+ Assert.equal(
+ engine.wrappedJSObject.queryCharset,
+ "UTF-8",
+ "Should default to utf-8"
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Remove: ["Foo"],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ // If this passes, it means that the specified engine was properly removed
+ Assert.equal(
+ Services.search.getEngineByName("Foo"),
+ null,
+ "Specified search engine should not be installed"
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_post_method_engine() {
+ Assert.equal(
+ Services.search.getEngineByName("Post"),
+ null,
+ 'Engine "Post" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Post",
+ Method: "POST",
+ PostData: "q={searchTerms}&anotherParam=yes",
+ URLTemplate: "http://example.com/",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("Post");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.wrappedJSObject._urls[0].method,
+ "POST",
+ "Method should be POST"
+ );
+
+ let submission = engine.getSubmission("term", "text/html");
+ Assert.notEqual(submission.postData, null, "Post data should not be null");
+
+ let scriptableInputStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ scriptableInputStream.init(submission.postData);
+ Assert.equal(
+ scriptableInputStream.read(scriptableInputStream.available()),
+ "q=term&anotherParam=yes",
+ "Post data should be present"
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_with_encoding() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.equal(
+ Services.search.getEngineByName("Encoding"),
+ null,
+ 'Engine "Encoding" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Encoding",
+ Encoding: "windows-1252",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("Encoding");
+ Assert.equal(
+ engine.wrappedJSObject.queryCharset,
+ "windows-1252",
+ "Should have correct encoding"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_update() {
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "ToUpdate",
+ URLTemplate: "http://initial.example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("ToUpdate");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.getSubmission("test").uri.spec,
+ "http://initial.example.com/?q=test",
+ "Initial submission URL should be correct."
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "ToUpdate",
+ URLTemplate: "http://update.example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ engine = Services.search.getEngineByName("ToUpdate");
+ Assert.notEqual(engine, null, "Specified search engine should be installed");
+
+ Assert.equal(
+ engine.getSubmission("test").uri.spec,
+ "http://update.example.com/?q=test",
+ "Updated Submission URL should be correct."
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_with_suggest() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.equal(
+ Services.search.getEngineByName("Suggest"),
+ null,
+ 'Engine "Suggest" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Suggest",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ SuggestURLTemplate: "http://suggest.example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("Suggest");
+
+ Assert.equal(
+ engine.getSubmission("test", "application/x-suggestions+json").uri.spec,
+ "http://suggest.example.com/?q=test",
+ "Updated Submission URL should be correct."
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_install_and_restart_keeps_settings() {
+ // Make sure we are starting in an expected state to avoid false positive
+ // test results.
+ Assert.equal(
+ Services.search.getEngineByName("Settings"),
+ null,
+ 'Engine "Settings" should not be present when test starts'
+ );
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Settings",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ let engine = Services.search.getEngineByName("Settings");
+ engine.hidden = true;
+ engine.alias = "settings";
+ await settingsWritten;
+
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Settings",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+
+ engine = Services.search.getEngineByName("Settings");
+
+ Assert.ok(engine.hidden, "Should have kept the engine hidden after restart");
+ Assert.equal(
+ engine.alias,
+ "settings",
+ "Should have kept the engine alias after restart"
+ );
+
+ // Clean up
+ await setupPolicyEngineWithJsonWithSearch({});
+ EnterprisePolicyTesting.resetRunOnceState();
+});
+
+add_task(async function test_reset_default() {
+ await setupPolicyEngineWithJsonWithSearch({
+ policies: {
+ SearchEngines: {
+ Remove: ["DuckDuckGo"],
+ },
+ },
+ });
+ // Get in line, because the Search policy callbacks are async.
+ await TestUtils.waitForTick();
+
+ let engine = Services.search.getEngineByName("DuckDuckGo");
+
+ Assert.equal(
+ engine.hidden,
+ true,
+ "Application specified engine should be hidden."
+ );
+
+ await Services.search.restoreDefaultEngines();
+
+ engine = Services.search.getEngineByName("DuckDuckGo");
+ Assert.equal(
+ engine.hidden,
+ false,
+ "Application specified engine should not be hidden"
+ );
+
+ EnterprisePolicyTesting.resetRunOnceState();
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js
new file mode 100644
index 0000000000..6ad883fe42
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_preferences.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const OLD_PREFERENCES_TESTS = [
+ {
+ policies: {
+ Preferences: {
+ "network.IDN_show_punycode": true,
+ "accessibility.force_disabled": 1,
+ "security.default_personal_cert": "Select Automatically",
+ // "geo.enabled": 1,
+ "extensions.getAddons.showPane": 0,
+ },
+ },
+ lockedPrefs: {
+ "network.IDN_show_punycode": true,
+ "accessibility.force_disabled": 1,
+ "security.default_personal_cert": "Select Automatically",
+ // "geo.enabled": true,
+ "extensions.getAddons.showPane": false,
+ },
+ },
+];
+
+const NEW_PREFERENCES_TESTS = [
+ {
+ policies: {
+ Preferences: {
+ "browser.policies.test.default.boolean": {
+ Value: true,
+ Status: "default",
+ },
+ "browser.policies.test.default.string": {
+ Value: "string",
+ Status: "default",
+ },
+ "browser.policies.test.default.number": {
+ Value: 11,
+ Status: "default",
+ },
+ "browser.policies.test.locked.boolean": {
+ Value: true,
+ Status: "locked",
+ },
+ "browser.policies.test.locked.string": {
+ Value: "string",
+ Status: "locked",
+ },
+ "browser.policies.test.locked.number": {
+ Value: 11,
+ Status: "locked",
+ },
+ "browser.policies.test.user.boolean": {
+ Value: true,
+ Status: "user",
+ },
+ "browser.policies.test.user.string": {
+ Value: "string",
+ Status: "user",
+ },
+ "browser.policies.test.user.number": {
+ Value: 11,
+ Status: "user",
+ },
+ "mail.openMessageBehavior": {
+ Value: 1,
+ Status: "locked",
+ },
+ "mailnews.display.prefer_plaintext": {
+ Value: true,
+ Status: "locked",
+ },
+ "chat.enabled": {
+ Value: false,
+ Status: "locked",
+ },
+ "calendar.agenda.days": {
+ Value: 21,
+ Status: "locked",
+ },
+ },
+ },
+ defaultPrefs: {
+ "browser.policies.test.default.boolean": true,
+ "browser.policies.test.default.string": "string",
+ "browser.policies.test.default.number": 11,
+ },
+ lockedPrefs: {
+ "browser.policies.test.locked.boolean": true,
+ "browser.policies.test.locked.string": "string",
+ "browser.policies.test.locked.number": 11,
+ "mail.openMessageBehavior": 1,
+ "mailnews.display.prefer_plaintext": true,
+ "chat.enabled": false,
+ "calendar.agenda.days": 21,
+ },
+ userPrefs: {
+ "browser.policies.test.user.boolean": true,
+ "browser.policies.test.user.string": "string",
+ "browser.policies.test.user.number": 11,
+ },
+ },
+ {
+ policies: {
+ Preferences: {
+ "browser.policies.test.user.boolean": {
+ Status: "clear",
+ },
+ "browser.policies.test.user.string": {
+ Status: "clear",
+ },
+ "browser.policies.test.user.number": {
+ Status: "clear",
+ },
+ },
+ },
+
+ clearPrefs: {
+ "browser.policies.test.user.boolean": true,
+ "browser.policies.test.user.string": "string",
+ "browser.policies.test.user.number": 11,
+ },
+ },
+];
+
+const BAD_PREFERENCES_TESTS = [
+ {
+ policies: {
+ Preferences: {
+ "not.a.valid.branch": {
+ Value: true,
+ Status: "default",
+ },
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer":
+ {
+ Value: true,
+ Status: "default",
+ },
+ },
+ },
+ defaultPrefs: {
+ "not.a.valid.branch": true,
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer": true,
+ },
+ },
+];
+
+add_task(async function test_old_preferences() {
+ for (let test of OLD_PREFERENCES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let [prefName, prefValue] of Object.entries(test.lockedPrefs || {})) {
+ checkLockedPref(prefName, prefValue);
+ }
+ }
+});
+
+add_task(async function test_new_preferences() {
+ for (let test of NEW_PREFERENCES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let [prefName, prefValue] of Object.entries(test.lockedPrefs || {})) {
+ checkLockedPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(test.defaultPrefs || {})) {
+ checkDefaultPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(test.userPrefs || {})) {
+ checkUserPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(test.clearPrefs || {})) {
+ checkClearPref(prefName, prefValue);
+ }
+ }
+});
+
+add_task(async function test_bad_preferences() {
+ for (let test of BAD_PREFERENCES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let prefName of Object.entries(test.defaultPrefs || {})) {
+ checkUnsetPref(prefName);
+ }
+ }
+});
+
+add_task(async function test_user_default_preference() {
+ Services.prefs
+ .getDefaultBranch("")
+ .setBoolPref("browser.policies.test.override", true);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ "browser.policies.test.override": {
+ Value: true,
+ Status: "user",
+ },
+ },
+ },
+ });
+
+ checkUserPref("browser.policies.test.override", true);
+});
+
+add_task(async function test_security_preference() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ "security.this.should.not.work": {
+ Value: true,
+ Status: "default",
+ },
+ },
+ },
+ });
+
+ checkUnsetPref("security.this.should.not.work");
+});
+
+add_task(async function test_bug_1666836() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ "browser.tabs.warnOnClose": {
+ Value: 0,
+ Status: "default",
+ },
+ },
+ },
+ });
+
+ equal(
+ Preferences.get("browser.tabs.warnOnClose"),
+ false,
+ `browser.tabs.warnOnClose should be false`
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js
new file mode 100644
index 0000000000..ef5ad1e178
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_proxy.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_task(async function test_proxy_modes_and_autoconfig() {
+ // Directly test the proxy Mode and AutoconfigURL parameters through
+ // the API instead of the policy engine, because the test harness
+ // uses these prefs, and changing them interfere with the harness.
+
+ // Checks that every Mode value translates correctly to the expected pref value
+ let { ProxyPolicies, PROXY_TYPES_MAP } = ChromeUtils.importESModule(
+ "resource:///modules/policies/ProxyPolicies.sys.mjs"
+ );
+
+ for (let [mode, expectedValue] of PROXY_TYPES_MAP) {
+ ProxyPolicies.configureProxySettings({ Mode: mode }, (_, value) => {
+ equal(value, expectedValue, "Correct proxy mode");
+ });
+ }
+
+ let autoconfigURL = new URL("data:text/plain,test");
+ ProxyPolicies.configureProxySettings(
+ { AutoConfigURL: autoconfigURL },
+ (_, value) => {
+ equal(value, autoconfigURL.href, "AutoconfigURL correctly set");
+ }
+ );
+});
+
+add_task(async function test_proxy_boolean_settings() {
+ // Tests that both false and true values are correctly set and locked
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ UseProxyForDNS: false,
+ AutoLogin: false,
+ },
+ },
+ });
+
+ checkUnlockedPref("network.proxy.socks_remote_dns", false);
+ checkUnlockedPref("signon.autologin.proxy", false);
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ UseProxyForDNS: true,
+ AutoLogin: true,
+ },
+ },
+ });
+
+ checkUnlockedPref("network.proxy.socks_remote_dns", true);
+ checkUnlockedPref("signon.autologin.proxy", true);
+});
+
+add_task(async function test_proxy_socks_and_passthrough() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ SOCKSVersion: 4,
+ Passthrough: "a, b, c",
+ },
+ },
+ });
+
+ checkUnlockedPref("network.proxy.socks_version", 4);
+ checkUnlockedPref("network.proxy.no_proxies_on", "a, b, c");
+});
+
+add_task(async function test_proxy_addresses() {
+ function checkProxyPref(proxytype, address, port) {
+ checkUnlockedPref(`network.proxy.${proxytype}`, address);
+ checkUnlockedPref(`network.proxy.${proxytype}_port`, port);
+ }
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ HTTPProxy: "http.proxy.example.com:10",
+ SSLProxy: "ssl.proxy.example.com:30",
+ SOCKSProxy: "socks.proxy.example.com:40",
+ },
+ },
+ });
+
+ checkProxyPref("http", "http.proxy.example.com", 10);
+ checkProxyPref("ssl", "ssl.proxy.example.com", 30);
+ checkProxyPref("socks", "socks.proxy.example.com", 40);
+
+ // Do the same, but now use the UseHTTPProxyForAllProtocols option
+ // and check that it takes effect.
+ await setupPolicyEngineWithJson({
+ policies: {
+ Proxy: {
+ HTTPProxy: "http.proxy.example.com:10",
+ // FTP support was removed in bug 1574475
+ // Setting an FTPProxy should result in a warning but should not fail
+ FTPProxy: "ftp.proxy.example.com:20",
+ SSLProxy: "ssl.proxy.example.com:30",
+ SOCKSProxy: "socks.proxy.example.com:40",
+ UseHTTPProxyForAllProtocols: true,
+ },
+ },
+ });
+
+ checkProxyPref("http", "http.proxy.example.com", 10);
+ checkProxyPref("ssl", "http.proxy.example.com", 10);
+ checkProxyPref("socks", "http.proxy.example.com", 10);
+
+ // Make sure the FTPProxy setting did nothing
+ Assert.equal(
+ Preferences.has("network.proxy.ftp"),
+ false,
+ "network.proxy.ftp should not be set"
+ );
+ Assert.equal(
+ Preferences.has("network.proxy.ftp_port"),
+ false,
+ "network.proxy.ftp_port should not be set"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js
new file mode 100644
index 0000000000..6c298cee5a
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_requestedlocales.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const REQ_LOC_CHANGE_EVENT = "intl:requested-locales-changed";
+
+function promiseLocaleChanged(requestedLocale) {
+ return new Promise(resolve => {
+ let localeObserver = {
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case REQ_LOC_CHANGE_EVENT:
+ let reqLocs = Services.locale.requestedLocales;
+ equal(reqLocs[0], requestedLocale);
+ Services.obs.removeObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ resolve();
+ }
+ },
+ };
+ Services.obs.addObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ });
+}
+
+add_task(async function test_requested_locale_array() {
+ let originalLocales = Services.locale.requestedLocales;
+ let localePromise = promiseLocaleChanged("de");
+ await setupPolicyEngineWithJson({
+ policies: {
+ RequestedLocales: ["de"],
+ },
+ });
+ await localePromise;
+ Services.locale.requestedLocales = originalLocales;
+});
+
+add_task(async function test_requested_locale_string() {
+ let originalLocales = Services.locale.requestedLocales;
+ let localePromise = promiseLocaleChanged("fr");
+ await setupPolicyEngineWithJson({
+ policies: {
+ RequestedLocales: "fr",
+ },
+ });
+ await localePromise;
+ Services.locale.requestedLocales = originalLocales;
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js
new file mode 100644
index 0000000000..c8e73b3422
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_runOnce_helper.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let { runOnce } = ChromeUtils.importESModule(
+ "resource:///modules/policies/Policies.sys.mjs"
+);
+
+let runCount = 0;
+function callback() {
+ runCount++;
+}
+
+add_task(async function test_runonce_helper() {
+ runOnce("test_action", callback);
+ equal(runCount, 1, "Callback ran for the first time.");
+
+ runOnce("test_action", callback);
+ equal(runCount, 1, "Callback didn't run again.");
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js
new file mode 100644
index 0000000000..90da242a72
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_simple_pref_policies.js
@@ -0,0 +1,378 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Use this file to add tests to policies that are
+ * simple pref flips.
+ *
+ * It's best to make a test to actually test the feature
+ * instead of the pref flip, but if that feature is well
+ * covered by tests, including that its pref actually works,
+ * it's OK to have the policy test here just to ensure
+ * that the right pref values are set.
+ */
+
+const POLICIES_TESTS = [
+ /*
+ * Example:
+ * {
+ * // Policies to be set at once through the engine
+ * policies: { "DisableFoo": true, "ConfigureBar": 42 },
+ *
+ * // Locked prefs to check
+ * lockedPrefs: { "feature.foo": false },
+ *
+ * // Unlocked prefs to check
+ * unlockedPrefs: { "bar.baz": 42 }
+ * },
+ */
+
+ // POLICY: RememberPasswords
+ {
+ policies: { OfferToSaveLogins: false },
+ lockedPrefs: { "signon.rememberSignons": false },
+ },
+ {
+ policies: { OfferToSaveLogins: true },
+ lockedPrefs: { "signon.rememberSignons": true },
+ },
+
+ // POLICY: DisableSecurityBypass
+ {
+ policies: {
+ DisableSecurityBypass: {
+ InvalidCertificate: true,
+ SafeBrowsing: true,
+ },
+ },
+ lockedPrefs: {
+ "security.certerror.hideAddException": true,
+ "browser.safebrowsing.allowOverride": false,
+ },
+ },
+
+ // POLICY: DisableBuiltinPDFViewer
+ {
+ policies: { DisableBuiltinPDFViewer: true },
+ lockedPrefs: { "pdfjs.disabled": true },
+ },
+
+ // POLICY: Authentication
+ {
+ policies: {
+ Authentication: {
+ SPNEGO: ["a.com", "b.com"],
+ Delegated: ["a.com", "b.com"],
+ NTLM: ["a.com", "b.com"],
+ AllowNonFQDN: {
+ SPNEGO: true,
+ NTLM: true,
+ },
+ AllowProxies: {
+ SPNEGO: false,
+ NTLM: false,
+ },
+ PrivateBrowsing: true,
+ },
+ },
+ lockedPrefs: {
+ "network.negotiate-auth.trusted-uris": "a.com, b.com",
+ "network.negotiate-auth.delegation-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.trusted-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.allow-non-fqdn": true,
+ "network.negotiate-auth.allow-non-fqdn": true,
+ "network.automatic-ntlm-auth.allow-proxies": false,
+ "network.negotiate-auth.allow-proxies": false,
+ "network.auth.private-browsing-sso": true,
+ },
+ },
+
+ // POLICY: Authentication (unlocked)
+ {
+ policies: {
+ Authentication: {
+ SPNEGO: ["a.com", "b.com"],
+ Delegated: ["a.com", "b.com"],
+ NTLM: ["a.com", "b.com"],
+ AllowNonFQDN: {
+ SPNEGO: true,
+ NTLM: true,
+ },
+ AllowProxies: {
+ SPNEGO: false,
+ NTLM: false,
+ },
+ PrivateBrowsing: true,
+ Locked: false,
+ },
+ },
+ unlockedPrefs: {
+ "network.negotiate-auth.trusted-uris": "a.com, b.com",
+ "network.negotiate-auth.delegation-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.trusted-uris": "a.com, b.com",
+ "network.automatic-ntlm-auth.allow-non-fqdn": true,
+ "network.negotiate-auth.allow-non-fqdn": true,
+ "network.automatic-ntlm-auth.allow-proxies": false,
+ "network.negotiate-auth.allow-proxies": false,
+ "network.auth.private-browsing-sso": true,
+ },
+ },
+
+ // POLICY: Certificates (true)
+ {
+ policies: {
+ Certificates: {
+ ImportEnterpriseRoots: true,
+ },
+ },
+ lockedPrefs: {
+ "security.enterprise_roots.enabled": true,
+ },
+ },
+
+ // POLICY: Certificates (false)
+ {
+ policies: {
+ Certificates: {
+ ImportEnterpriseRoots: false,
+ },
+ },
+ lockedPrefs: {
+ "security.enterprise_roots.enabled": false,
+ },
+ },
+
+ // POLICY: InstallAddons.Default (block addon installs)
+ {
+ policies: {
+ InstallAddonsPermission: {
+ Default: false,
+ },
+ },
+ lockedPrefs: {
+ "xpinstall.enabled": false,
+ },
+ },
+
+ // POLICY: DNSOverHTTPS Locked
+ {
+ policies: {
+ DNSOverHTTPS: {
+ Enabled: true,
+ ProviderURL: "http://example.com/provider",
+ ExcludedDomains: ["example.com", "example.org"],
+ Locked: true,
+ },
+ },
+ lockedPrefs: {
+ "network.trr.mode": 2,
+ "network.trr.uri": "http://example.com/provider",
+ "network.trr.excluded-domains": "example.com,example.org",
+ },
+ },
+
+ // POLICY: DNSOverHTTPS Unlocked
+ {
+ policies: {
+ DNSOverHTTPS: {
+ Enabled: false,
+ ProviderURL: "http://example.com/provider",
+ ExcludedDomains: ["example.com", "example.org"],
+ },
+ },
+ unlockedPrefs: {
+ "network.trr.mode": 5,
+ "network.trr.uri": "http://example.com/provider",
+ "network.trr.excluded-domains": "example.com,example.org",
+ },
+ },
+
+ // POLICY: SSLVersionMin/SSLVersionMax (1)
+ {
+ policies: {
+ SSLVersionMin: "tls1",
+ SSLVersionMax: "tls1.1",
+ },
+ lockedPrefs: {
+ "security.tls.version.min": 1,
+ "security.tls.version.max": 2,
+ },
+ },
+
+ // POLICY: SSLVersionMin/SSLVersionMax (2)
+ {
+ policies: {
+ SSLVersionMin: "tls1.2",
+ SSLVersionMax: "tls1.3",
+ },
+ lockedPrefs: {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 4,
+ },
+ },
+
+ // POLICY: CaptivePortal
+ {
+ policies: {
+ CaptivePortal: false,
+ },
+ lockedPrefs: {
+ "network.captive-portal-service.enabled": false,
+ },
+ },
+
+ // POLICY: NetworkPrediction
+ {
+ policies: {
+ NetworkPrediction: false,
+ },
+ lockedPrefs: {
+ "network.dns.disablePrefetch": true,
+ "network.dns.disablePrefetchFromHTTPS": true,
+ },
+ },
+
+ // POLICY: ExtensionUpdate
+ {
+ policies: {
+ ExtensionUpdate: false,
+ },
+ lockedPrefs: {
+ "extensions.update.enabled": false,
+ },
+ },
+
+ // POLICY: OfferToSaveLoginsDefault
+ {
+ policies: {
+ OfferToSaveLoginsDefault: false,
+ },
+ unlockedPrefs: {
+ "signon.rememberSignons": false,
+ },
+ },
+
+ // POLICY: PDFjs
+
+ {
+ policies: {
+ PDFjs: {
+ Enabled: false,
+ EnablePermissions: true,
+ },
+ },
+ lockedPrefs: {
+ "pdfjs.disabled": true,
+ "pdfjs.enablePermissions": true,
+ },
+ },
+
+ // POLICY: DisabledCiphers
+ {
+ policies: {
+ DisabledCiphers: {
+ TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: false,
+ TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: false,
+ TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: false,
+ TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: false,
+ TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: false,
+ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: false,
+ TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: false,
+ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: false,
+ TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: false,
+ TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: false,
+ TLS_DHE_RSA_WITH_AES_128_CBC_SHA: false,
+ TLS_DHE_RSA_WITH_AES_256_CBC_SHA: false,
+ TLS_RSA_WITH_AES_128_GCM_SHA256: false,
+ TLS_RSA_WITH_AES_256_GCM_SHA384: false,
+ TLS_RSA_WITH_AES_128_CBC_SHA: false,
+ TLS_RSA_WITH_AES_256_CBC_SHA: false,
+ TLS_RSA_WITH_3DES_EDE_CBC_SHA: false,
+ },
+ },
+ lockedPrefs: {
+ "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256": true,
+ "security.ssl3.ecdhe_ecdsa_aes_128_gcm_sha256": true,
+ "security.ssl3.ecdhe_ecdsa_chacha20_poly1305_sha256": true,
+ "security.ssl3.ecdhe_rsa_chacha20_poly1305_sha256": true,
+ "security.ssl3.ecdhe_ecdsa_aes_256_gcm_sha384": true,
+ "security.ssl3.ecdhe_rsa_aes_256_gcm_sha384": true,
+ "security.ssl3.ecdhe_rsa_aes_128_sha": true,
+ "security.ssl3.ecdhe_ecdsa_aes_128_sha": true,
+ "security.ssl3.ecdhe_rsa_aes_256_sha": true,
+ "security.ssl3.ecdhe_ecdsa_aes_256_sha": true,
+ "security.ssl3.dhe_rsa_aes_128_sha": true,
+ "security.ssl3.dhe_rsa_aes_256_sha": true,
+ "security.ssl3.rsa_aes_128_gcm_sha256": true,
+ "security.ssl3.rsa_aes_256_gcm_sha384": true,
+ "security.ssl3.rsa_aes_128_sha": true,
+ "security.ssl3.rsa_aes_256_sha": true,
+ "security.ssl3.deprecated.rsa_des_ede3_sha": true,
+ },
+ },
+
+ {
+ policies: {
+ DisabledCiphers: {
+ TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
+ TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
+ TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: true,
+ TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: true,
+ TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
+ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
+ TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: true,
+ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: true,
+ TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: true,
+ TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: true,
+ TLS_DHE_RSA_WITH_AES_128_CBC_SHA: true,
+ TLS_DHE_RSA_WITH_AES_256_CBC_SHA: true,
+ TLS_RSA_WITH_AES_128_GCM_SHA256: true,
+ TLS_RSA_WITH_AES_256_GCM_SHA384: true,
+ TLS_RSA_WITH_AES_128_CBC_SHA: true,
+ TLS_RSA_WITH_AES_256_CBC_SHA: true,
+ TLS_RSA_WITH_3DES_EDE_CBC_SHA: true,
+ },
+ },
+ lockedPrefs: {
+ "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256": false,
+ "security.ssl3.ecdhe_ecdsa_aes_128_gcm_sha256": false,
+ "security.ssl3.ecdhe_ecdsa_chacha20_poly1305_sha256": false,
+ "security.ssl3.ecdhe_rsa_chacha20_poly1305_sha256": false,
+ "security.ssl3.ecdhe_ecdsa_aes_256_gcm_sha384": false,
+ "security.ssl3.ecdhe_rsa_aes_256_gcm_sha384": false,
+ "security.ssl3.ecdhe_rsa_aes_128_sha": false,
+ "security.ssl3.ecdhe_ecdsa_aes_128_sha": false,
+ "security.ssl3.ecdhe_rsa_aes_256_sha": false,
+ "security.ssl3.ecdhe_ecdsa_aes_256_sha": false,
+ "security.ssl3.dhe_rsa_aes_128_sha": false,
+ "security.ssl3.dhe_rsa_aes_256_sha": false,
+ "security.ssl3.rsa_aes_128_gcm_sha256": false,
+ "security.ssl3.rsa_aes_256_gcm_sha384": false,
+ "security.ssl3.rsa_aes_128_sha": false,
+ "security.ssl3.rsa_aes_256_sha": false,
+ "security.ssl3.deprecated.rsa_des_ede3_sha": false,
+ },
+ },
+];
+
+add_task(async function test_policy_simple_prefs() {
+ for (let test of POLICIES_TESTS) {
+ await setupPolicyEngineWithJson({
+ policies: test.policies,
+ });
+
+ info("Checking policy: " + Object.keys(test.policies)[0]);
+
+ for (let [prefName, prefValue] of Object.entries(test.lockedPrefs || {})) {
+ checkLockedPref(prefName, prefValue);
+ }
+
+ for (let [prefName, prefValue] of Object.entries(
+ test.unlockedPrefs || {}
+ )) {
+ checkUnlockedPref(prefName, prefValue);
+ }
+ }
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js
new file mode 100644
index 0000000000..0d246c850c
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/test_sorted_alphabetically.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function checkArrayIsSorted(array, msg) {
+ let sorted = true;
+ let sortedArray = array.slice().sort(function (a, b) {
+ return a.localeCompare(b);
+ });
+
+ for (let i = 0; i < array.length; i++) {
+ if (array[i] != sortedArray[i]) {
+ sorted = false;
+ break;
+ }
+ }
+ ok(sorted, msg);
+}
+
+add_task(async function test_policies_sorted() {
+ let { schema } = ChromeUtils.importESModule(
+ "resource:///modules/policies/schema.sys.mjs"
+ );
+ let { Policies } = ChromeUtils.importESModule(
+ "resource:///modules/policies/Policies.sys.mjs"
+ );
+
+ checkArrayIsSorted(
+ Object.keys(schema.properties),
+ "policies-schema.json is alphabetically sorted."
+ );
+ checkArrayIsSorted(
+ Object.keys(Policies),
+ "Policies.jsm is alphabetically sorted."
+ );
+});
+
+add_task(async function check_naming_conventions() {
+ let { schema } = ChromeUtils.importESModule(
+ "resource:///modules/policies/schema.sys.mjs"
+ );
+ equal(
+ Object.keys(schema.properties).some(key => key.includes("__")),
+ false,
+ "Can't use __ in a policy name as it's used as a delimiter"
+ );
+});
diff --git a/comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini b/comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..ab47cca7bd
--- /dev/null
+++ b/comm/mail/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+firefox-appdir = browser
+head = head.js
+
+[test_3rdparty.js]
+[test_appupdatepin.js]
+[test_appupdateurl.js]
+[test_bug1658259.js]
+[test_clear_blocked_cookies.js]
+[test_macosparser_unflatten.js]
+skip-if = os != 'mac'
+[test_policy_search_engine.js]
+[test_preferences.js]
+[test_proxy.js]
+[test_requestedlocales.js]
+[test_runOnce_helper.js]
+[test_simple_pref_policies.js]
+[test_sorted_alphabetically.js]
diff --git a/comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs b/comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs
new file mode 100644
index 0000000000..26fb1040a6
--- /dev/null
+++ b/comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "makeRange", () => {
+ const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ );
+ // Defined in ext-browsingData.js
+ return ExtensionParent.apiManager.global.makeRange;
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Sanitizer: "resource:///modules/Sanitizer.jsm",
+});
+
+export class BrowsingDataDelegate {
+ // Unused for now
+ constructor(extension) {}
+
+ // This method returns undefined for all data types that are _not_ handled by
+ // this delegate.
+ handleRemoval(dataType, options) {
+ switch (dataType) {
+ case "downloads":
+ return lazy.Sanitizer.items.downloads.clear(lazy.makeRange(options));
+ case "formData":
+ return lazy.Sanitizer.items.formdata.clear(lazy.makeRange(options));
+ case "history":
+ return lazy.Sanitizer.items.history.clear(lazy.makeRange(options));
+
+ default:
+ return undefined;
+ }
+ }
+
+ settings() {
+ const PREF_DOMAIN = "privacy.cpd.";
+ // The following prefs are the only ones in Firefox that match corresponding
+ // values used by Chrome when rerturning settings.
+ const PREF_LIST = ["cache", "cookies", "history", "formdata", "downloads"];
+
+ // since will be the start of what is returned by Sanitizer.getClearRange
+ // divided by 1000 to convert to ms.
+ // If Sanitizer.getClearRange returns undefined that means the range is
+ // currently "Everything", so we should set since to 0.
+ let clearRange = lazy.Sanitizer.getClearRange();
+ let since = clearRange ? clearRange[0] / 1000 : 0;
+ let options = { since };
+
+ let dataToRemove = {};
+ let dataRemovalPermitted = {};
+
+ for (let item of PREF_LIST) {
+ // The property formData needs a different case than the
+ // formdata preference.
+ const name = item === "formdata" ? "formData" : item;
+ dataToRemove[name] = lazy.Preferences.get(`${PREF_DOMAIN}${item}`);
+ // Firefox doesn't have the same concept of dataRemovalPermitted
+ // as Chrome, so it will always be true.
+ dataRemovalPermitted[name] = true;
+ }
+
+ return Promise.resolve({
+ options,
+ dataToRemove,
+ dataRemovalPermitted,
+ });
+ }
+}
diff --git a/comm/mail/components/extensions/ExtensionPopups.sys.mjs b/comm/mail/components/extensions/ExtensionPopups.sys.mjs
new file mode 100644
index 0000000000..15f5b7f4c9
--- /dev/null
+++ b/comm/mail/components/extensions/ExtensionPopups.sys.mjs
@@ -0,0 +1,635 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* This file is a much-modified copy of browser/components/extensions/ExtensionPopups.sys.mjs. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+var { DefaultWeakMap, ExtensionError, promiseEvent } = ExtensionUtils;
+
+const POPUP_LOAD_TIMEOUT_MS = 200;
+
+XPCOMUtils.defineLazyGetter(lazy, "standaloneStylesheets", () => {
+ let stylesheets = [];
+
+ if (AppConstants.platform === "macosx") {
+ stylesheets.push("chrome://browser/content/extension-mac-panel.css");
+ } else if (AppConstants.platform === "win") {
+ stylesheets.push("chrome://browser/content/extension-win-panel.css");
+ } else if (AppConstants.platform === "linux") {
+ stylesheets.push("chrome://browser/content/extension-linux-panel.css");
+ }
+ return stylesheets;
+});
+
+const REMOTE_PANEL_ID = "webextension-remote-preload-panel";
+
+export class BasePopup {
+ constructor(
+ extension,
+ viewNode,
+ popupURL,
+ browserStyle,
+ fixedWidth = false,
+ blockParser = false
+ ) {
+ this.extension = extension;
+ this.popupURL = popupURL;
+ this.viewNode = viewNode;
+ this.browserStyle = browserStyle;
+ this.window = viewNode.ownerGlobal;
+ this.destroyed = false;
+ this.fixedWidth = fixedWidth;
+ this.blockParser = blockParser;
+
+ extension.callOnClose(this);
+
+ this.contentReady = new Promise(resolve => {
+ this._resolveContentReady = resolve;
+ });
+
+ this.window.addEventListener("unload", this);
+ this.viewNode.addEventListener("popuphiding", this);
+ this.panel.addEventListener("popuppositioned", this, {
+ once: true,
+ capture: true,
+ });
+
+ this.browser = null;
+ this.browserLoaded = new Promise((resolve, reject) => {
+ this.browserLoadedDeferred = { resolve, reject };
+ });
+ this.browserReady = this.createBrowser(viewNode, popupURL);
+
+ BasePopup.instances.get(this.window).set(extension, this);
+ }
+
+ static for(extension, window) {
+ return BasePopup.instances.get(window).get(extension);
+ }
+
+ close() {
+ this.closePopup();
+ }
+
+ destroy() {
+ this.extension.forgetOnClose(this);
+
+ this.window.removeEventListener("unload", this);
+
+ this.destroyed = true;
+ this.browserLoadedDeferred.reject(new ExtensionError("Popup destroyed"));
+ // Ignore unhandled rejections if the "attach" method is not called.
+ this.browserLoaded.catch(() => {});
+
+ BasePopup.instances.get(this.window).delete(this.extension);
+
+ return this.browserReady.then(() => {
+ if (this.browser) {
+ this.destroyBrowser(this.browser, true);
+ this.browser.parentNode.remove();
+ }
+ if (this.stack) {
+ this.stack.remove();
+ }
+
+ if (this.viewNode) {
+ this.viewNode.removeEventListener("popuphiding", this);
+ delete this.viewNode.customRectGetter;
+ }
+
+ let { panel } = this;
+ if (panel) {
+ panel.removeEventListener("popuppositioned", this, { capture: true });
+ }
+ if (panel && panel.id !== REMOTE_PANEL_ID) {
+ panel.style.removeProperty("--arrowpanel-background");
+ panel.style.removeProperty("--arrowpanel-border-color");
+ panel.removeAttribute("remote");
+ }
+
+ this.browser = null;
+ this.stack = null;
+ this.viewNode = null;
+ });
+ }
+
+ destroyBrowser(browser, finalize = false) {
+ let mm = browser.messageManager;
+ // If the browser has already been removed from the document, because the
+ // popup was closed externally, there will be no message manager here, so
+ // just replace our receiveMessage method with a stub.
+ if (mm) {
+ mm.removeMessageListener("Extension:BrowserBackgroundChanged", this);
+ mm.removeMessageListener("Extension:BrowserContentLoaded", this);
+ mm.removeMessageListener("Extension:BrowserResized", this);
+ } else if (finalize) {
+ this.receiveMessage = () => {};
+ }
+ browser.removeEventListener("pagetitlechanged", this);
+ browser.removeEventListener("DOMWindowClose", this);
+ }
+
+ get STYLESHEETS() {
+ let sheets = [];
+
+ if (this.browserStyle) {
+ sheets.push(...lazy.ExtensionParent.extensionStylesheets);
+ }
+ if (!this.fixedWidth) {
+ sheets.push(...lazy.standaloneStylesheets);
+ }
+
+ return sheets;
+ }
+
+ get panel() {
+ let panel = this.viewNode;
+ while (panel && panel.localName != "panel") {
+ panel = panel.parentNode;
+ }
+ return panel;
+ }
+
+ receiveMessage({ name, data }) {
+ switch (name) {
+ case "Extension:BrowserBackgroundChanged":
+ this.setBackground(data.background);
+ break;
+
+ case "Extension:BrowserContentLoaded":
+ this.browserLoadedDeferred.resolve();
+ break;
+
+ case "Extension:BrowserResized":
+ this._resolveContentReady();
+ if (this.ignoreResizes) {
+ this.dimensions = data;
+ } else {
+ this.resizeBrowser(data);
+ }
+ break;
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "unload":
+ case "popuphiding":
+ if (!this.destroyed) {
+ this.destroy();
+ }
+ break;
+ case "popuppositioned":
+ if (!this.destroyed) {
+ this.browserLoaded
+ .then(() => {
+ if (this.destroyed) {
+ return;
+ }
+ // Wait the reflow before asking the popup panel to grab the focus, otherwise
+ // `nsFocusManager::SetFocus` may ignore out request because the panel view
+ // visibility is still set to `nsViewVisibility_kHide` (waiting the document
+ // to be fully flushed makes us sure that when the popup panel grabs the focus
+ // nsMenuPopupFrame::LayoutPopup has already been colled and set the frame
+ // visibility to `nsViewVisibility_kShow`).
+ this.browser.ownerGlobal.promiseDocumentFlushed(() => {
+ if (this.destroyed) {
+ return;
+ }
+ this.browser.messageManager.sendAsyncMessage(
+ "Extension:GrabFocus",
+ {}
+ );
+ });
+ })
+ .catch(() => {
+ // If the panel closes too fast an exception is raised here and tests will fail.
+ });
+ }
+ break;
+
+ case "pagetitlechanged":
+ this.viewNode.setAttribute("aria-label", this.browser.contentTitle);
+ break;
+
+ case "DOMWindowClose":
+ this.closePopup();
+ break;
+ }
+ }
+
+ createBrowser(viewNode, popupURL = null) {
+ let document = viewNode.ownerDocument;
+
+ let stack = document.createXULElement("stack");
+ stack.setAttribute("class", "webextension-popup-stack");
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("class", "webextension-popup-browser");
+ browser.setAttribute("webextension-view-type", "popup");
+ browser.setAttribute("tooltip", "aHTMLTooltip");
+ browser.setAttribute("context", "browserContext");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+ browser.setAttribute("constrainpopups", "false");
+ browser.setAttribute("datetimepicker", "DateTimePickerPanel");
+
+ // Ensure the browser will initially load in the same group as other
+ // browsers from the same extension.
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ this.extension.policy.browsingContextGroupId
+ );
+
+ if (this.extension.remote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", this.extension.remoteType);
+ browser.setAttribute("maychangeremoteness", "true");
+ }
+
+ // We only need flex sizing for the sake of the slide-in sub-views of the
+ // main menu panel, so that the browser occupies the full width of the view,
+ // and also takes up any extra height that's available to it.
+ browser.setAttribute("flex", "1");
+ stack.setAttribute("flex", "1");
+
+ // Note: When using noautohide panels, the popup manager will add width and
+ // height attributes to the panel, breaking our resize code, if the browser
+ // starts out smaller than 30px by 10px. This isn't an issue now, but it
+ // will be if and when we popup debugging.
+
+ this.browser = browser;
+ this.stack = stack;
+
+ let readyPromise;
+ if (this.extension.remote) {
+ readyPromise = promiseEvent(browser, "XULFrameLoaderCreated");
+ } else {
+ readyPromise = promiseEvent(browser, "load");
+ }
+
+ stack.appendChild(browser);
+ viewNode.appendChild(stack);
+
+ if (!this.extension.remote) {
+ // FIXME: bug 1494029 - this code used to rely on the browser binding
+ // accessing browser.contentWindow. This is a stopgap to continue doing
+ // that, but we should get rid of it in the long term.
+ browser.contentWindow; // eslint-disable-line no-unused-expressions
+ }
+
+ let setupBrowser = browser => {
+ let mm = browser.messageManager;
+ mm.addMessageListener("Extension:BrowserBackgroundChanged", this);
+ mm.addMessageListener("Extension:BrowserContentLoaded", this);
+ mm.addMessageListener("Extension:BrowserResized", this);
+ browser.addEventListener("pagetitlechanged", this);
+ browser.addEventListener("DOMWindowClose", this);
+
+ lazy.ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ browser
+ );
+ return browser;
+ };
+
+ const initBrowser = () => {
+ setupBrowser(browser);
+ let mm = browser.messageManager;
+
+ mm.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+
+ mm.sendAsyncMessage("Extension:InitBrowser", {
+ allowScriptsToClose: true,
+ blockParser: this.blockParser,
+ fixedWidth: this.fixedWidth,
+ maxWidth: 800,
+ maxHeight: 600,
+ stylesheets: this.STYLESHEETS,
+ });
+ };
+
+ browser.addEventListener("DidChangeBrowserRemoteness", initBrowser); // eslint-disable-line mozilla/balanced-listeners
+
+ if (!popupURL) {
+ // For remote browsers, we can't do any setup until the frame loader is
+ // created. Non-remote browsers get a message manager immediately, so
+ // there's no need to wait for the load event.
+ if (this.extension.remote) {
+ return readyPromise.then(() => setupBrowser(browser));
+ }
+ return setupBrowser(browser);
+ }
+
+ return readyPromise.then(() => {
+ initBrowser();
+ browser.fixupAndLoadURIString(popupURL, {
+ triggeringPrincipal: this.extension.principal,
+ });
+ });
+ }
+
+ unblockParser() {
+ this.browserReady.then(browser => {
+ if (this.destroyed) {
+ return;
+ }
+ this.browser.messageManager.sendAsyncMessage("Extension:UnblockParser");
+ });
+ }
+
+ resizeBrowser({ width, height, detail }) {
+ if (this.fixedWidth) {
+ // Figure out how much extra space we have on the side of the panel
+ // opposite the arrow.
+ let side = this.panel.getAttribute("side") == "top" ? "bottom" : "top";
+ let maxHeight = this.viewHeight + this.extraHeight[side];
+
+ height = Math.min(height, maxHeight);
+ this.browser.style.height = `${height}px`;
+
+ // Used by the panelmultiview code to figure out sizing without reparenting
+ // (which would destroy the browser and break us).
+ this.lastCalculatedInViewHeight = Math.max(height, this.viewHeight);
+ } else {
+ this.browser.style.width = `${width}px`;
+ this.browser.style.minWidth = `${width}px`;
+ this.browser.style.height = `${height}px`;
+ this.browser.style.minHeight = `${height}px`;
+ }
+
+ let event = new this.window.CustomEvent("WebExtPopupResized", { detail });
+ this.browser.dispatchEvent(event);
+ }
+
+ setBackground(background) {
+ // Panels inherit the applied theme (light, dark, etc) and there is a high
+ // likelihood that most extension authors will not have tested with a dark theme.
+ // If they have not set a background-color, we force it to white to ensure visibility
+ // of the extension content. Passing `null` should be treated the same as no argument,
+ // which is why we can't use default parameters here.
+ if (!background) {
+ background = "#fff";
+ }
+ if (this.panel.id != "widget-overflow") {
+ this.panel.style.setProperty("--arrowpanel-background", background);
+ }
+ if (background == "#fff") {
+ // Set a usable default color that work with the default background-color.
+ this.panel.style.setProperty(
+ "--arrowpanel-border-color",
+ "hsla(210,4%,10%,.15)"
+ );
+ }
+ this.background = background;
+ }
+}
+
+export class ViewPopup extends BasePopup {
+ constructor(
+ extension,
+ window,
+ popupURL,
+ browserStyle,
+ fixedWidth,
+ blockParser
+ ) {
+ let document = window.document;
+
+ let createPanel = remote => {
+ let panel = document.createXULElement("panel");
+ panel.setAttribute("type", "arrow");
+ panel.setAttribute("class", "panel-no-padding");
+ if (remote) {
+ panel.setAttribute("remote", "true");
+ }
+ panel.setAttribute("neverhidden", "true");
+
+ document.getElementById("mainPopupSet").appendChild(panel);
+ return panel;
+ };
+
+ // Firefox creates a temporary panel to hold the browser while it pre-loads
+ // its content (starting on mouseover already). This panel will never be shown,
+ // but the browser's docShell will be swapped with the browser in the real
+ // panel when it's ready (in ViewPopup.attach()).
+ // For remote extensions, Firefox shares this temporary panel between all
+ // extensions.
+
+ // NOTE: Thunderbird currently does not pre-load the popup and really uses
+ // the "temporary" panel when displaying the popup to the user.
+ let panel;
+ if (extension.remote) {
+ panel = document.getElementById(REMOTE_PANEL_ID);
+ if (!panel) {
+ panel = createPanel(true);
+ panel.id = REMOTE_PANEL_ID;
+ }
+ } else {
+ panel = createPanel();
+ }
+
+ super(extension, panel, popupURL, browserStyle, fixedWidth, blockParser);
+
+ this.ignoreResizes = true;
+
+ this.attached = false;
+ this.shown = false;
+ this.tempPanel = panel;
+ this.tempBrowser = this.browser;
+
+ this.browser.classList.add("webextension-preload-browser");
+ }
+
+ /**
+ * Attaches the pre-loaded browser to the given view node, and reserves a
+ * promise which resolves when the browser is ready.
+ *
+ * NOTE: Not used by Thunderbird.
+ *
+ * @param {Element} viewNode
+ * The node to attach the browser to.
+ * @returns {Promise<boolean>}
+ * Resolves when the browser is ready. Resolves to `false` if the
+ * browser was destroyed before it was fully loaded, and the popup
+ * should be closed, or `true` otherwise.
+ */
+ async attach(viewNode) {
+ if (this.destroyed) {
+ return false;
+ }
+ this.viewNode.removeEventListener(this.DESTROY_EVENT, this);
+ this.panel.removeEventListener("popuppositioned", this, {
+ once: true,
+ capture: true,
+ });
+
+ this.viewNode = viewNode;
+ this.viewNode.addEventListener(this.DESTROY_EVENT, this);
+ this.viewNode.setAttribute("closemenu", "none");
+
+ this.panel.addEventListener("popuppositioned", this, {
+ once: true,
+ capture: true,
+ });
+ if (this.extension.remote) {
+ this.panel.setAttribute("remote", "true");
+ }
+
+ // Wait until the browser element is fully initialized, and give it at least
+ // a short grace period to finish loading its initial content, if necessary.
+ //
+ // In practice, the browser that was created by the mousdown handler should
+ // nearly always be ready by this point.
+ await Promise.all([
+ this.browserReady,
+ Promise.race([
+ // This promise may be rejected if the popup calls window.close()
+ // before it has fully loaded.
+ this.browserLoaded.catch(() => {}),
+ new Promise(resolve => lazy.setTimeout(resolve, POPUP_LOAD_TIMEOUT_MS)),
+ ]),
+ ]);
+
+ const { panel } = this;
+
+ if (!this.destroyed && !panel) {
+ this.destroy();
+ }
+
+ if (this.destroyed) {
+ this.viewNode.hidePopup();
+ return false;
+ }
+
+ this.attached = true;
+
+ this.setBackground(this.background);
+
+ let flushPromise = this.window.promiseDocumentFlushed(() => {
+ let win = this.window;
+
+ // Calculate the extra height available on the screen above and below the
+ // menu panel. Use that to calculate the how much the sub-view may grow.
+ let popupRect = panel.getBoundingClientRect();
+ let screenBottom = win.screen.availTop + win.screen.availHeight;
+ let popupBottom = win.mozInnerScreenY + popupRect.bottom;
+ let popupTop = win.mozInnerScreenY + popupRect.top;
+
+ // Store the initial height of the view, so that we never resize menu panel
+ // sub-views smaller than the initial height of the menu.
+ this.viewHeight = viewNode.getBoundingClientRect().height;
+
+ this.extraHeight = {
+ bottom: Math.max(0, screenBottom - popupBottom),
+ top: Math.max(0, popupTop - win.screen.availTop),
+ };
+ });
+
+ // Create a new browser in the real popup.
+ let browser = this.browser;
+ await this.createBrowser(this.viewNode);
+
+ this.browser.swapDocShells(browser);
+ this.destroyBrowser(browser);
+
+ await flushPromise;
+
+ // Check if the popup has been destroyed while we were waiting for the
+ // document flush promise to be resolve.
+ if (this.destroyed) {
+ this.closePopup();
+ this.destroy();
+ return false;
+ }
+
+ if (this.dimensions) {
+ if (this.fixedWidth) {
+ delete this.dimensions.width;
+ }
+ this.resizeBrowser(this.dimensions);
+ }
+
+ this.ignoreResizes = false;
+
+ this.viewNode.customRectGetter = () => {
+ return { height: this.lastCalculatedInViewHeight || this.viewHeight };
+ };
+
+ this.removeTempPanel();
+
+ this.shown = true;
+
+ if (this.destroyed) {
+ this.closePopup();
+ this.destroy();
+ return false;
+ }
+
+ let event = new this.window.CustomEvent("WebExtPopupLoaded", {
+ bubbles: true,
+ detail: { extension: this.extension },
+ });
+ this.browser.dispatchEvent(event);
+
+ return true;
+ }
+
+ removeTempPanel() {
+ if (this.tempPanel) {
+ // NOTE: Thunderbird currently does not pre-load the popup into a temporary
+ // panel as Firefox is doing it. We therefore do not have to "save"
+ // the temporary panel for later re-use, but really have to remove it.
+ // See Bug 1451058 for why Firefox uses the following conditional
+ // remove().
+
+ // if (this.tempPanel.id !== REMOTE_PANEL_ID) {
+ this.tempPanel.remove();
+ // }
+ this.tempPanel = null;
+ }
+ if (this.tempBrowser) {
+ this.tempBrowser.parentNode.remove();
+ this.tempBrowser = null;
+ }
+ }
+
+ destroy() {
+ return super.destroy().then(() => {
+ this.removeTempPanel();
+ });
+ }
+
+ closePopup() {
+ this.viewNode.hidePopup();
+ }
+}
+
+/**
+ * A map of active popups for a given browser window.
+ *
+ * WeakMap[window -> WeakMap[Extension -> BasePopup]]
+ */
+BasePopup.instances = new DefaultWeakMap(() => new WeakMap());
diff --git a/comm/mail/components/extensions/ExtensionToolbarButtons.jsm b/comm/mail/components/extensions/ExtensionToolbarButtons.jsm
new file mode 100644
index 0000000000..86e66e06e9
--- /dev/null
+++ b/comm/mail/components/extensions/ExtensionToolbarButtons.jsm
@@ -0,0 +1,949 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "ToolbarButtonAPI",
+ "getIconData",
+ "getCachedAllowedSpaces",
+ "setCachedAllowedSpaces",
+];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ViewPopup: "resource:///modules/ExtensionPopups.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "ExtensionSupport",
+ "resource:///modules/ExtensionSupport.jsm"
+);
+const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+var { EventManager, ExtensionAPIPersistent, makeWidgetId } = ExtensionCommon;
+
+var { IconDetails, StartupCache } = ExtensionParent;
+
+var { DefaultWeakMap, ExtensionError } = ExtensionUtils;
+
+var DEFAULT_ICON = "chrome://messenger/content/extension.svg";
+
+function getCachedAllowedSpaces() {
+ let cache = {};
+ if (
+ Services.xulStore.hasValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "allowedExtSpaces"
+ )
+ ) {
+ let rawCache = Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "allowedExtSpaces"
+ );
+ cache = JSON.parse(rawCache);
+ }
+ return new Map(Object.entries(cache));
+}
+
+function setCachedAllowedSpaces(allowedSpacesMap) {
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "allowedExtSpaces",
+ JSON.stringify(Object.fromEntries(allowedSpacesMap))
+ );
+}
+
+/**
+ * Get icon properties for updating the UI.
+ *
+ * @param {object} icons
+ * Contains the icon information, typically the extension manifest
+ */
+function getIconData(icons, extension) {
+ let baseSize = 16;
+ let { icon, size } = IconDetails.getPreferredIcon(icons, extension, baseSize);
+
+ let legacy = false;
+
+ // If the best available icon size is not divisible by 16, check if we have
+ // an 18px icon to fall back to, and trim off the padding instead.
+ if (size % 16 && typeof icon === "string" && !icon.endsWith(".svg")) {
+ let result = IconDetails.getPreferredIcon(icons, extension, 18);
+
+ if (result.size % 18 == 0) {
+ baseSize = 18;
+ icon = result.icon;
+ legacy = true;
+ }
+ }
+
+ let getIcon = (size, theme) => {
+ let { icon } = IconDetails.getPreferredIcon(icons, extension, size);
+ if (typeof icon === "object") {
+ if (icon[theme] == IconDetails.DEFAULT_ICON) {
+ icon[theme] = DEFAULT_ICON;
+ }
+ return IconDetails.escapeUrl(icon[theme]);
+ }
+ if (icon == IconDetails.DEFAULT_ICON) {
+ return DEFAULT_ICON;
+ }
+ return IconDetails.escapeUrl(icon);
+ };
+
+ let style = [];
+ let getStyle = (name, size) => {
+ style.push([
+ `--webextension-${name}`,
+ `url("${getIcon(size, "default")}")`,
+ ]);
+ style.push([
+ `--webextension-${name}-light`,
+ `url("${getIcon(size, "light")}")`,
+ ]);
+ style.push([
+ `--webextension-${name}-dark`,
+ `url("${getIcon(size, "dark")}")`,
+ ]);
+ };
+
+ getStyle("menupanel-image", 32);
+ getStyle("menupanel-image-2x", 64);
+ getStyle("toolbar-image", baseSize);
+ getStyle("toolbar-image-2x", baseSize * 2);
+
+ let realIcon = getIcon(size, "default");
+
+ return { style, legacy, realIcon };
+}
+
+var ToolbarButtonAPI = class extends ExtensionAPIPersistent {
+ constructor(extension, global) {
+ super(extension);
+ this.global = global;
+ this.tabContext = new this.global.TabContext(target =>
+ this.getContextData(null)
+ );
+ }
+
+ /**
+ * If this action is available in the unified toolbar.
+ *
+ * @type {boolean}
+ */
+ inUnifiedToolbar = false;
+
+ /**
+ * Called when the extension is enabled.
+ *
+ * @param {string} entryName
+ * The name of the property in the extension manifest
+ */
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ this.paint = this.paint.bind(this);
+ this.unpaint = this.unpaint.bind(this);
+
+ if (this.manifest?.type == "menu" && this.manifest.default_popup) {
+ console.warn(
+ `The "default_popup" manifest entry is not supported for action buttons with type "menu".`
+ );
+ }
+
+ this.widgetId = makeWidgetId(extension.id);
+ this.id = `${this.widgetId}-${this.moduleName}-toolbarbutton`;
+ this.eventQueue = [];
+
+ let options = extension.manifest[entryName];
+ this.defaults = {
+ enabled: true,
+ label: options.default_label,
+ title: options.default_title || extension.name,
+ badgeText: "",
+ badgeBackgroundColor: null,
+ popup: options.default_popup || "",
+ type: options.type,
+ };
+ this.globals = Object.create(this.defaults);
+
+ this.browserStyle = options.browser_style;
+
+ this.defaults.icon = await StartupCache.get(
+ extension,
+ [this.manifestName, "default_icon"],
+ () =>
+ IconDetails.normalize(
+ {
+ path: options.default_icon,
+ iconType: this.manifestName,
+ themeIcons: options.theme_icons,
+ },
+ extension
+ )
+ );
+
+ this.iconData = new DefaultWeakMap(icons => getIconData(icons, extension));
+ this.iconData.set(
+ this.defaults.icon,
+ await StartupCache.get(
+ extension,
+ [this.manifestName, "default_icon_data"],
+ () => getIconData(this.defaults.icon, extension)
+ )
+ );
+
+ lazy.ExtensionSupport.registerWindowListener(this.id, {
+ chromeURLs: this.windowURLs,
+ onLoadWindow: window => {
+ this.paint(window);
+ },
+ });
+
+ extension.callOnClose(this);
+ }
+
+ /**
+ * Called when the extension is disabled or removed.
+ */
+ close() {
+ lazy.ExtensionSupport.unregisterWindowListener(this.id);
+ for (let window of lazy.ExtensionSupport.openWindows) {
+ if (this.windowURLs.includes(window.location.href)) {
+ this.unpaint(window);
+ }
+ }
+ }
+
+ /**
+ * Creates a toolbar button.
+ *
+ * @param {Window} window
+ */
+ makeButton(window) {
+ let { document } = window;
+ let button;
+ switch (this.globals.type) {
+ case "menu":
+ {
+ button = document.createXULElement("toolbarbutton");
+ button.setAttribute("type", "menu");
+ button.setAttribute("wantdropmarker", "true");
+ let menupopup = document.createXULElement("menupopup");
+ menupopup.dataset.actionMenu = this.manifestName;
+ menupopup.dataset.extensionId = this.extension.id;
+ button.appendChild(menupopup);
+ }
+ break;
+ case "button":
+ button = document.createXULElement("toolbarbutton");
+ break;
+ }
+ button.id = this.id;
+ button.classList.add("toolbarbutton-1");
+ button.classList.add("webextension-action");
+ button.setAttribute("badged", "true");
+ button.setAttribute("data-extensionid", this.extension.id);
+ button.addEventListener("mousedown", this);
+ this.updateButton(button, this.globals);
+ return button;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ return null;
+ }
+
+ /**
+ * Adds a toolbar button to a customizable toolbar in this window.
+ *
+ * @param {Window} window
+ */
+ customizableToolbarPaint(window) {
+ let windowURL = window.location.href;
+ let { document } = window;
+ if (document.getElementById(this.id)) {
+ return;
+ }
+
+ let toolbox = document.getElementById(this.toolboxId);
+ if (!toolbox) {
+ return;
+ }
+
+ // Get all toolbars which link to or are children of this.toolboxId and check
+ // if the button has been moved to a non-default toolbar.
+ let toolbars = window.document.querySelectorAll(
+ `#${this.toolboxId} toolbar, toolbar[toolboxid="${this.toolboxId}"]`
+ );
+ for (let toolbar of toolbars) {
+ let currentSet = Services.xulStore
+ .getValue(windowURL, toolbar.id, "currentset")
+ .split(",")
+ .filter(Boolean);
+ if (currentSet.includes(this.id)) {
+ this.toolbarId = toolbar.id;
+ break;
+ }
+ }
+
+ let toolbar = document.getElementById(this.toolbarId);
+ let button = this.makeButton(window);
+ if (toolbox.palette) {
+ toolbox.palette.appendChild(button);
+ } else {
+ toolbar.appendChild(button);
+ }
+
+ // Handle the special case where this toolbar does not yet have a currentset
+ // defined.
+ if (!Services.xulStore.hasValue(windowURL, this.toolbarId, "currentset")) {
+ let defaultSet = toolbar
+ .getAttribute("defaultset")
+ .split(",")
+ .filter(Boolean);
+ Services.xulStore.setValue(
+ windowURL,
+ this.toolbarId,
+ "currentset",
+ defaultSet.join(",")
+ );
+ }
+
+ // Add new buttons to currentset: If the extensionset does not include the
+ // button, it is a new one which needs to be added.
+ let extensionSet = Services.xulStore
+ .getValue(windowURL, this.toolbarId, "extensionset")
+ .split(",")
+ .filter(Boolean);
+ if (!extensionSet.includes(this.id)) {
+ extensionSet.push(this.id);
+ Services.xulStore.setValue(
+ windowURL,
+ this.toolbarId,
+ "extensionset",
+ extensionSet.join(",")
+ );
+ let currentSet = Services.xulStore
+ .getValue(windowURL, this.toolbarId, "currentset")
+ .split(",")
+ .filter(Boolean);
+ if (!currentSet.includes(this.id)) {
+ currentSet.push(this.id);
+ Services.xulStore.setValue(
+ windowURL,
+ this.toolbarId,
+ "currentset",
+ currentSet.join(",")
+ );
+ }
+ }
+
+ let currentSet = Services.xulStore.getValue(
+ windowURL,
+ this.toolbarId,
+ "currentset"
+ );
+
+ toolbar.currentSet = currentSet;
+ toolbar.setAttribute("currentset", toolbar.currentSet);
+
+ if (this.extension.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this);
+ }
+ }
+
+ /**
+ * Adds a toolbar button to a non-customizable toolbar in this window.
+ *
+ * @param {Window} window
+ */
+ nonCustomizableToolbarPaint(window) {
+ let { document } = window;
+ let windowURL = window.location.href;
+ if (document.getElementById(this.id)) {
+ return;
+ }
+ let toolbar = document.getElementById(this.toolbarId);
+ let before = this.getNonCustomizableToolbarInsertionPoint(toolbar);
+ let button = this.makeButton(window);
+ let currentSet = Services.xulStore
+ .getValue(windowURL, toolbar.id, "currentset")
+ .split(",")
+ .filter(Boolean);
+ if (!currentSet.includes(this.id)) {
+ currentSet.push(this.id);
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar.id,
+ "currentset",
+ currentSet.join(",")
+ );
+ } else {
+ for (let id of [...currentSet].reverse()) {
+ if (!id.endsWith(`-${this.manifestName}-toolbarbutton`)) {
+ continue;
+ }
+ if (id == this.id) {
+ break;
+ }
+ let element = document.getElementById(id);
+ if (element) {
+ before = element;
+ }
+ }
+ }
+ toolbar.insertBefore(button, before);
+
+ if (this.extension.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this);
+ }
+ }
+
+ /**
+ * Adds a toolbar button to a toolbar in this window.
+ *
+ * @param {Window} window
+ */
+ paint(window) {
+ let toolbar = window.document.getElementById(this.toolbarId);
+ if (toolbar.hasAttribute("customizable")) {
+ return this.customizableToolbarPaint(window);
+ }
+ return this.nonCustomizableToolbarPaint(window);
+ }
+
+ /**
+ * Removes the toolbar button from this window.
+ *
+ * @param {Window} window
+ */
+ unpaint(window) {
+ let { document } = window;
+
+ if (this.extension.hasPermission("menus")) {
+ document.removeEventListener("popupshowing", this);
+ }
+
+ let button = document.getElementById(this.id);
+ if (button) {
+ button.remove();
+ }
+ }
+
+ /**
+ * Return the toolbar button if it is currently visible in the given window.
+ *
+ * @param window
+ * @returns {DOMElement} the toolbar button element, or null
+ */
+ getToolbarButton(window) {
+ let button = window.document.getElementById(this.id);
+ let toolbar = button?.closest("toolbar");
+ return button && !toolbar?.collapsed ? button : null;
+ }
+
+ /**
+ * Triggers this browser action for the given window, with the same effects as
+ * if it were clicked by a user.
+ *
+ * This has no effect if the browser action is disabled for, or not
+ * present in, the given window.
+ *
+ * @param {Window} window
+ * @param {object} options
+ * @param {boolean} options.requirePopupUrl - do not fall back to emitting an
+ * onClickedEvent, if no popupURL is
+ * set and consider this action fail
+ *
+ * @returns {boolean} status if action could be successfully triggered
+ */
+ async triggerAction(window, options = {}) {
+ let button = this.getToolbarButton(window);
+ let { popup: popupURL, enabled } = this.getContextData(
+ this.getTargetFromWindow(window)
+ );
+
+ let success = false;
+ if (button && enabled) {
+ window.focus();
+
+ if (popupURL) {
+ success = true;
+ let popup =
+ lazy.ViewPopup.for(this.extension, window.top) ||
+ this.getPopup(window.top, popupURL);
+ popup.viewNode.openPopup(button, "bottomleft topleft", 0, 0);
+ } else if (!options.requirePopupUrl) {
+ if (!this.lastClickInfo) {
+ this.lastClickInfo = { button: 0, modifiers: [] };
+ }
+ this.emit("click", window.top, this.lastClickInfo);
+ success = true;
+ }
+ }
+
+ delete this.lastClickInfo;
+ return success;
+ }
+
+ /**
+ * Event listener.
+ *
+ * @param {Event} event
+ */
+ handleEvent(event) {
+ let window = event.target.ownerGlobal;
+ switch (event.type) {
+ case "click":
+ case "mousedown":
+ if (event.button == 0) {
+ // Bail out, if this is a menu typed action button or any of its menu entries.
+ if (
+ event.target.tagName == "menu" ||
+ event.target.tagName == "menuitem" ||
+ event.target.getAttribute("type") == "menu"
+ ) {
+ return;
+ }
+
+ this.lastClickInfo = {
+ button: 0,
+ modifiers: this.global.clickModifiersFromEvent(event),
+ };
+ this.triggerAction(window);
+ }
+ break;
+ case "TabSelect":
+ this.updateWindow(window);
+ break;
+ }
+ }
+
+ /**
+ * Returns a potentially pre-loaded popup for the given URL in the given
+ * window. If a matching pre-load popup already exists, returns that.
+ * Otherwise, initializes a new one.
+ *
+ * If a pre-load popup exists which does not match, it is destroyed before a
+ * new one is created.
+ *
+ * @param {Window} window
+ * The browser window in which to create the popup.
+ * @param {string} popupURL
+ * The URL to load into the popup.
+ * @param {boolean} [blockParser = false]
+ * True if the HTML parser should initially be blocked.
+ * @returns {ViewPopup}
+ */
+ getPopup(window, popupURL, blockParser = false) {
+ let popup = new lazy.ViewPopup(
+ this.extension,
+ window,
+ popupURL,
+ this.browserStyle,
+ false,
+ blockParser
+ );
+ popup.ignoreResizes = false;
+ return popup;
+ }
+
+ /**
+ * Update the toolbar button |node| with the tab context data
+ * in |tabData|.
+ *
+ * @param {XULElement} node
+ * XUL toolbarbutton to update
+ * @param {object} tabData
+ * Properties to set
+ * @param {boolean} sync
+ * Whether to perform the update immediately
+ */
+ updateButton(node, tabData, sync = false) {
+ let title = tabData.title || this.extension.name;
+ let label = tabData.label;
+ let callback = () => {
+ node.setAttribute("tooltiptext", title);
+ node.setAttribute("label", label || title);
+ node.setAttribute(
+ "hideWebExtensionLabel",
+ label === "" ? "true" : "false"
+ );
+
+ if (tabData.badgeText) {
+ node.setAttribute("badge", tabData.badgeText);
+ } else {
+ node.removeAttribute("badge");
+ }
+
+ if (tabData.enabled) {
+ node.removeAttribute("disabled");
+ } else {
+ node.setAttribute("disabled", "true");
+ }
+
+ let color = tabData.badgeBackgroundColor;
+ if (color) {
+ color = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${
+ color[3] / 255
+ })`;
+ node.setAttribute("badgeStyle", `background-color: ${color};`);
+ } else {
+ node.removeAttribute("badgeStyle");
+ }
+
+ let { style } = this.iconData.get(tabData.icon);
+
+ for (let [name, value] of style) {
+ node.style.setProperty(name, value);
+ }
+ };
+ if (sync) {
+ callback();
+ } else {
+ node.ownerGlobal.requestAnimationFrame(callback);
+ }
+ }
+
+ /**
+ * Update the toolbar button for a given window.
+ *
+ * @param {ChromeWindow} window
+ * Browser chrome window.
+ */
+ async updateWindow(window) {
+ let button = this.getToolbarButton(window);
+ if (button) {
+ let tabData = this.getContextData(this.getTargetFromWindow(window));
+ this.updateButton(button, tabData);
+ }
+ await new Promise(window.requestAnimationFrame);
+ }
+
+ /**
+ * Update the toolbar button when the extension changes the icon, title, url, etc.
+ * If it only changes a parameter for a single tab, `target` will be that tab.
+ * If it only changes a parameter for a single window, `target` will be that window.
+ * Otherwise `target` will be null.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * Browser tab or browser chrome window, may be null.
+ */
+ async updateOnChange(target) {
+ if (target) {
+ let window = Cu.getGlobalForObject(target);
+ if (target === window) {
+ await this.updateWindow(window);
+ } else {
+ let tabmail = window.document.getElementById("tabmail");
+ if (tabmail && target == tabmail.selectedTab) {
+ await this.updateWindow(window);
+ }
+ }
+ } else {
+ let promises = [];
+ for (let window of lazy.ExtensionSupport.openWindows) {
+ if (this.windowURLs.includes(window.location.href)) {
+ promises.push(this.updateWindow(window));
+ }
+ }
+ await Promise.all(promises);
+ }
+ }
+
+ /**
+ * Gets the active tab of the passed window if the window has tabs, or the
+ * window itself.
+ *
+ * @param {ChromeWindow} window
+ * @returns {XULElement|ChromeWindow}
+ */
+ getTargetFromWindow(window) {
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (!tabmail) {
+ return window.top;
+ }
+
+ if (window == window.top) {
+ return tabmail.currentTabInfo;
+ }
+ if (window.parent != window.top) {
+ window = window.parent;
+ }
+ return tabmail.tabInfo.find(t => t.chromeBrowser?.contentWindow == window);
+ }
+
+ /**
+ * Gets the target object corresponding to the `details` parameter of the various
+ * get* and set* API methods.
+ *
+ * @param {object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @throws if `windowId` is specified, this is not valid in Thunderbird.
+ * @returns {XULElement|ChromeWindow|null}
+ * If a `tabId` was specified, the corresponding XULElement tab.
+ * If a `windowId` was specified, the corresponding ChromeWindow.
+ * Otherwise, `null`.
+ */
+ getTargetFromDetails({ tabId, windowId }) {
+ if (windowId != null) {
+ throw new ExtensionError("windowId is not allowed, use tabId instead.");
+ }
+ if (tabId != null) {
+ return this.global.tabTracker.getTab(tabId);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the data associated with a tab, window, or the global one.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * A XULElement tab, a ChromeWindow, or null for the global data.
+ * @returns {object}
+ * The icon, title, badge, etc. associated with the target.
+ */
+ getContextData(target) {
+ if (target) {
+ return this.tabContext.get(target);
+ }
+ return this.globals;
+ }
+
+ /**
+ * Set a global, window specific or tab specific property.
+ *
+ * @param {object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @param {string} prop
+ * String property to set. Should should be one of "icon", "title", "label",
+ * "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+ * @param {string} value
+ * Value for prop.
+ */
+ async setProperty(details, prop, value) {
+ let target = this.getTargetFromDetails(details);
+ let values = this.getContextData(target);
+ if (value === null) {
+ delete values[prop];
+ } else {
+ values[prop] = value;
+ }
+
+ await this.updateOnChange(target);
+ }
+
+ /**
+ * Retrieve the value of a global, window specific or tab specific property.
+ *
+ * @param {object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @param {string} prop
+ * String property to retrieve. Should should be one of "icon", "title", "label",
+ * "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+ * @returns {string} value
+ * Value of prop.
+ */
+ getProperty(details, prop) {
+ return this.getContextData(this.getTargetFromDetails(details))[prop];
+ }
+
+ PERSISTENT_EVENTS = {
+ onClicked({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+
+ async function listener(_event, window, clickInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+
+ // TODO: We should double-check if the tab is already being closed by the time
+ // the background script got started and we converted the primed listener.
+
+ let win = windowManager.wrapWindow(window);
+ fire.sync(tabManager.convert(win.activeTab.nativeTab), clickInfo);
+ }
+ this.on("click", listener);
+ return {
+ unregister: () => {
+ this.off("click", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ /**
+ * WebExtension API.
+ *
+ * @param {object} context
+ */
+ getAPI(context) {
+ let { extension } = context;
+
+ let action = this;
+
+ return {
+ [this.manifestName]: {
+ onClicked: new EventManager({
+ context,
+ module: this.moduleName,
+ event: "onClicked",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+
+ async enable(tabId) {
+ await action.setProperty({ tabId }, "enabled", true);
+ },
+
+ async disable(tabId) {
+ await action.setProperty({ tabId }, "enabled", false);
+ },
+
+ isEnabled(details) {
+ return action.getProperty(details, "enabled");
+ },
+
+ async setTitle(details) {
+ await action.setProperty(details, "title", details.title);
+ },
+
+ getTitle(details) {
+ return action.getProperty(details, "title");
+ },
+
+ async setLabel(details) {
+ await action.setProperty(details, "label", details.label);
+ },
+
+ getLabel(details) {
+ return action.getProperty(details, "label");
+ },
+
+ async setIcon(details) {
+ details.iconType = this.manifestName;
+
+ let icon = IconDetails.normalize(details, extension, context);
+ if (!Object.keys(icon).length) {
+ icon = null;
+ }
+ await action.setProperty(details, "icon", icon);
+ },
+
+ async setBadgeText(details) {
+ await action.setProperty(details, "badgeText", details.text);
+ },
+
+ getBadgeText(details) {
+ return action.getProperty(details, "badgeText");
+ },
+
+ async setPopup(details) {
+ if (this.manifest?.type == "menu") {
+ console.warn(
+ `Popups are not supported for action buttons with type "menu".`
+ );
+ }
+
+ // Note: Chrome resolves arguments to setIcon relative to the calling
+ // context, but resolves arguments to setPopup relative to the extension
+ // root.
+ // For internal consistency, we currently resolve both relative to the
+ // calling context.
+ let url = details.popup && context.uri.resolve(details.popup);
+ if (url && !context.checkLoadURL(url)) {
+ return Promise.reject({ message: `Access denied for URL ${url}` });
+ }
+ await action.setProperty(details, "popup", url);
+ return Promise.resolve(null);
+ },
+
+ getPopup(details) {
+ if (this.manifest?.type == "menu") {
+ console.warn(
+ `Popups are not supported for action buttons with type "menu".`
+ );
+ }
+
+ return action.getProperty(details, "popup");
+ },
+
+ async setBadgeBackgroundColor(details) {
+ let color = details.color;
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(
+ `Invalid badge background color: "${color}"`
+ );
+ }
+ color = col && [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ await action.setProperty(details, "badgeBackgroundColor", color);
+ },
+
+ getBadgeBackgroundColor(details, callback) {
+ let color = action.getProperty(details, "badgeBackgroundColor");
+ return color || [0xd9, 0, 0, 255];
+ },
+
+ openPopup(options) {
+ if (this.manifest?.type == "menu") {
+ console.warn(
+ `Popups are not supported for action buttons with type "menu".`
+ );
+ return false;
+ }
+
+ let window;
+ if (options?.windowId) {
+ window = action.global.windowTracker.getWindow(
+ options.windowId,
+ context
+ );
+ if (!window) {
+ return Promise.reject({
+ message: `Invalid window ID: ${options.windowId}`,
+ });
+ }
+ } else {
+ window = Services.wm.getMostRecentWindow("");
+ }
+
+ // When triggering the action here, we consider a missing popupUrl as a failure and will not
+ // cause an onClickedEvent.
+ return action.triggerAction(window, { requirePopupUrl: true });
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/MailExtensionShortcuts.jsm b/comm/mail/components/extensions/MailExtensionShortcuts.jsm
new file mode 100644
index 0000000000..f3c4d8eef7
--- /dev/null
+++ b/comm/mail/components/extensions/MailExtensionShortcuts.jsm
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const EXPORTED_SYMBOLS = ["MailExtensionShortcuts"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { ExtensionShortcuts } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionShortcuts.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "browserActionFor", () => {
+ return lazy.ExtensionParent.apiManager.global.browserActionFor;
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "composeActionFor", () => {
+ return lazy.ExtensionParent.apiManager.global.composeActionFor;
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "messageDisplayActionFor", () => {
+ return lazy.ExtensionParent.apiManager.global.messageDisplayActionFor;
+});
+
+const EXECUTE_ACTION = "_execute_action";
+const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
+const EXECUTE_MSG_DISPLAY_ACTION = "_execute_message_display_action";
+const EXECUTE_COMPOSE_ACTION = "_execute_compose_action";
+
+class MailExtensionShortcuts extends ExtensionShortcuts {
+ /**
+ * Builds a XUL Key element and attaches an onCommand listener which
+ * emits a command event with the provided name when fired.
+ *
+ * @param {Document} doc The XUL document.
+ * @param {string} name The name of the command.
+ * @param {string} shortcut The shortcut provided in the manifest.
+ * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
+ *
+ * @returns {Document} The newly created Key element.
+ */
+ buildKey(doc, name, shortcut) {
+ let keyElement = this.buildKeyFromShortcut(doc, name, shortcut);
+
+ // We need to have the attribute "oncommand" for the "command" listener to fire,
+ // and it is currently ignored when set to the empty string.
+ keyElement.setAttribute("oncommand", "//");
+
+ /* eslint-disable mozilla/balanced-listeners */
+ // We remove all references to the key elements when the extension is shutdown,
+ // therefore the listeners for these elements will be garbage collected.
+ keyElement.addEventListener("command", event => {
+ let action;
+ if (
+ name == EXECUTE_BROWSER_ACTION &&
+ this.extension.manifestVersion < 3
+ ) {
+ action = lazy.browserActionFor(this.extension);
+ } else if (name == EXECUTE_ACTION && this.extension.manifestVersion > 2) {
+ action = lazy.browserActionFor(this.extension);
+ } else if (name == EXECUTE_COMPOSE_ACTION) {
+ action = lazy.composeActionFor(this.extension);
+ } else if (name == EXECUTE_MSG_DISPLAY_ACTION) {
+ action = lazy.messageDisplayActionFor(this.extension);
+ } else {
+ this.extension.tabManager.addActiveTabPermission();
+ this.onCommand(name);
+ return;
+ }
+ if (action) {
+ let win = event.target.ownerGlobal;
+ action.triggerAction(win);
+ }
+ });
+ /* eslint-enable mozilla/balanced-listeners */
+
+ return keyElement;
+ }
+}
diff --git a/comm/mail/components/extensions/child/.eslintrc.js b/comm/mail/components/extensions/child/.eslintrc.js
new file mode 100644
index 0000000000..970cd0874e
--- /dev/null
+++ b/comm/mail/components/extensions/child/.eslintrc.js
@@ -0,0 +1,15 @@
+"use strict";
+
+module.exports = {
+ globals: {
+ // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
+ // From toolkit/components/extensions/.eslintrc.js.
+ ExtensionAPI: true,
+ ExtensionCommon: true,
+ extensions: true,
+ ExtensionUtils: true,
+
+ // From toolkit/components/extensions/child/.eslintrc.js.
+ EventManager: true,
+ },
+};
diff --git a/comm/mail/components/extensions/child/ext-extensionScripts.js b/comm/mail/components/extensions/child/ext-extensionScripts.js
new file mode 100644
index 0000000000..5d5f364c3e
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-extensionScripts.js
@@ -0,0 +1,83 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ExtensionPageContextChild} context
+ * The extension context which has registered the script.
+ * @param {string} scriptId
+ * An unique id that represents the registered script
+ * (generated and used internally to identify it across the different processes).
+ */
+class ExtensionScriptChild {
+ constructor(type, context, scriptId) {
+ this.type = type;
+ this.context = context;
+ this.scriptId = scriptId;
+ this.unregistered = false;
+ }
+
+ async unregister() {
+ if (this.unregistered) {
+ throw new ExtensionError("script already unregistered");
+ }
+
+ this.unregistered = true;
+
+ await this.context.childManager.callParentAsyncFunction(
+ "extensionScripts.unregister",
+ [this.scriptId]
+ );
+
+ this.context = null;
+ }
+
+ api() {
+ const { context } = this;
+
+ return {
+ unregister: () => {
+ return context.wrapPromise(this.unregister());
+ },
+ };
+ }
+}
+
+this.extensionScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ let api = {
+ register(options) {
+ return context.cloneScope.Promise.resolve().then(async () => {
+ const scriptId = await context.childManager.callParentAsyncFunction(
+ "extensionScripts.register",
+ [this.type, options]
+ );
+
+ const registeredScript = new ExtensionScriptChild(
+ this.type,
+ context,
+ scriptId
+ );
+
+ return Cu.cloneInto(registeredScript.api(), context.cloneScope, {
+ cloneFunctions: true,
+ });
+ });
+ },
+ };
+
+ return {
+ composeScripts: { type: "compose", ...api },
+ messageDisplayScripts: { type: "messageDisplay", ...api },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/child/ext-mail.js b/comm/mail/components/extensions/child/ext-mail.js
new file mode 100644
index 0000000000..4c85692f91
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-mail.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+extensions.registerModules({
+ extensionScripts: {
+ url: "chrome://messenger/content/child/ext-extensionScripts.js",
+ scopes: ["addon_child"],
+ paths: [["composeScripts"], ["messageDisplayScripts"]],
+ },
+ identity: {
+ url: "chrome://extensions/content/child/ext-identity.js",
+ scopes: ["addon_child"],
+ paths: [["identity"]],
+ },
+ menus: {
+ url: "chrome://messenger/content/child/ext-menus.js",
+ scopes: ["addon_child"],
+ paths: [["menus"]],
+ },
+ tabs: {
+ url: "chrome://messenger/content/child/ext-tabs.js",
+ scopes: ["addon_child"],
+ paths: [["tabs"]],
+ },
+});
diff --git a/comm/mail/components/extensions/child/ext-menus.js b/comm/mail/components/extensions/child/ext-menus.js
new file mode 100644
index 0000000000..a8dab40b15
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-menus.js
@@ -0,0 +1,290 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { withHandlingUserInput } = ExtensionCommon;
+
+var { ExtensionError } = ExtensionUtils;
+
+// If id is not specified for an item we use an integer.
+// This ID need only be unique within a single addon. Since all addon code that
+// can use this API runs in the same process, this local variable suffices.
+var gNextMenuItemID = 0;
+
+// Map[Extension -> Map[string or id, ContextMenusClickPropHandler]]
+var gPropHandlers = new Map();
+
+// The menus API supports an "onclick" attribute in the create/update
+// methods to register a callback. This class manages these onclick properties.
+class ContextMenusClickPropHandler {
+ constructor(context) {
+ this.context = context;
+ // Map[string or integer -> callback]
+ this.onclickMap = new Map();
+ this.dispatchEvent = this.dispatchEvent.bind(this);
+ }
+
+ // A listener on menus.onClicked that forwards the event to the only
+ // listener, if any.
+ dispatchEvent(info, tab) {
+ let onclick = this.onclickMap.get(info.menuItemId);
+ if (onclick) {
+ // No need for runSafe or anything because we are already being run inside
+ // an event handler -- the event is just being forwarded to the actual
+ // handler.
+ withHandlingUserInput(this.context.contentWindow, () =>
+ onclick(info, tab)
+ );
+ }
+ }
+
+ // Sets the `onclick` handler for the given menu item.
+ // The `onclick` function MUST be owned by `this.context`.
+ setListener(id, onclick) {
+ if (this.onclickMap.size === 0) {
+ this.context.childManager
+ .getParentEvent("menus.onClicked")
+ .addListener(this.dispatchEvent);
+ this.context.callOnClose(this);
+ }
+ this.onclickMap.set(id, onclick);
+
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ if (!propHandlerMap) {
+ propHandlerMap = new Map();
+ } else {
+ // If the current callback was created in a different context, remove it
+ // from the other context.
+ let propHandler = propHandlerMap.get(id);
+ if (propHandler && propHandler !== this) {
+ propHandler.unsetListener(id);
+ }
+ }
+ propHandlerMap.set(id, this);
+ gPropHandlers.set(this.context.extension, propHandlerMap);
+ }
+
+ // Deletes the `onclick` handler for the given menu item.
+ // The `onclick` function MUST be owned by `this.context`.
+ unsetListener(id) {
+ if (!this.onclickMap.delete(id)) {
+ return;
+ }
+ if (this.onclickMap.size === 0) {
+ this.context.childManager
+ .getParentEvent("menus.onClicked")
+ .removeListener(this.dispatchEvent);
+ this.context.forgetOnClose(this);
+ }
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ propHandlerMap.delete(id);
+ if (propHandlerMap.size === 0) {
+ gPropHandlers.delete(this.context.extension);
+ }
+ }
+
+ // Deletes the `onclick` handler for the given menu item, if any, regardless
+ // of the context where it was created.
+ unsetListenerFromAnyContext(id) {
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ let propHandler = propHandlerMap && propHandlerMap.get(id);
+ if (propHandler) {
+ propHandler.unsetListener(id);
+ }
+ }
+
+ // Remove all `onclick` handlers of the extension.
+ deleteAllListenersFromExtension() {
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ if (propHandlerMap) {
+ for (let [id, propHandler] of propHandlerMap) {
+ propHandler.unsetListener(id);
+ }
+ }
+ }
+
+ // Removes all `onclick` handlers from this context.
+ close() {
+ for (let id of this.onclickMap.keys()) {
+ this.unsetListener(id);
+ }
+ }
+}
+
+this.menus = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ let onClickedProp = new ContextMenusClickPropHandler(context);
+ let pendingMenuEvent;
+
+ return {
+ menus: {
+ create(createProperties, callback) {
+ let caller = context.getCaller();
+
+ if (extension.persistentBackground && createProperties.id === null) {
+ createProperties.id = ++gNextMenuItemID;
+ }
+ let { onclick } = createProperties;
+ if (onclick && !context.extension.persistentBackground) {
+ throw new ExtensionError(
+ `Property "onclick" cannot be used in menus.create, replace with an "onClicked" event listener.`
+ );
+ }
+ delete createProperties.onclick;
+ context.childManager
+ .callParentAsyncFunction("menus.create", [createProperties])
+ .then(() => {
+ if (onclick) {
+ onClickedProp.setListener(createProperties.id, onclick);
+ }
+ if (callback) {
+ context.runSafeWithoutClone(callback);
+ }
+ })
+ .catch(error => {
+ context.withLastError(error, caller, () => {
+ if (callback) {
+ context.runSafeWithoutClone(callback);
+ }
+ });
+ });
+ return createProperties.id;
+ },
+
+ update(id, updateProperties) {
+ let { onclick } = updateProperties;
+ if (onclick && !context.extension.persistentBackground) {
+ throw new ExtensionError(
+ `Property "onclick" cannot be used in menus.update, replace with an "onClicked" event listener.`
+ );
+ }
+ delete updateProperties.onclick;
+ return context.childManager
+ .callParentAsyncFunction("menus.update", [id, updateProperties])
+ .then(() => {
+ if (onclick) {
+ onClickedProp.setListener(id, onclick);
+ } else if (onclick === null) {
+ onClickedProp.unsetListenerFromAnyContext(id);
+ }
+ // else onclick is not set so it should not be changed.
+ });
+ },
+
+ remove(id) {
+ onClickedProp.unsetListenerFromAnyContext(id);
+ return context.childManager.callParentAsyncFunction("menus.remove", [
+ id,
+ ]);
+ },
+
+ removeAll() {
+ onClickedProp.deleteAllListenersFromExtension();
+
+ return context.childManager.callParentAsyncFunction(
+ "menus.removeAll",
+ []
+ );
+ },
+
+ overrideContext(contextOptions) {
+ let checkValidArg = (contextType, propKey) => {
+ if (contextOptions.context !== contextType) {
+ if (contextOptions[propKey]) {
+ throw new ExtensionError(
+ `Property "${propKey}" can only be used with context "${contextType}"`
+ );
+ }
+ return false;
+ }
+ if (contextOptions.showDefaults) {
+ throw new ExtensionError(
+ `Property "showDefaults" cannot be used with context "${contextType}"`
+ );
+ }
+ if (!contextOptions[propKey]) {
+ throw new ExtensionError(
+ `Property "${propKey}" is required for context "${contextType}"`
+ );
+ }
+ return true;
+ };
+ if (checkValidArg("tab", "tabId")) {
+ if (!context.extension.hasPermission("tabs")) {
+ throw new ExtensionError(
+ `The "tab" context requires the "tabs" permission.`
+ );
+ }
+ }
+ if (checkValidArg("bookmark", "bookmarkId")) {
+ if (!context.extension.hasPermission("bookmarks")) {
+ throw new ExtensionError(
+ `The "bookmark" context requires the "bookmarks" permission.`
+ );
+ }
+ }
+
+ let webExtContextData = {
+ extensionId: context.extension.id,
+ showDefaults: contextOptions.showDefaults,
+ overrideContext: contextOptions.context,
+ bookmarkId: contextOptions.bookmarkId,
+ tabId: contextOptions.tabId,
+ };
+
+ if (pendingMenuEvent) {
+ // overrideContext is called more than once during the same event.
+ pendingMenuEvent.webExtContextData = webExtContextData;
+ return;
+ }
+ pendingMenuEvent = {
+ webExtContextData,
+ observe(subject, topic, data) {
+ pendingMenuEvent = null;
+ Services.obs.removeObserver(this, "on-prepare-contextmenu");
+ subject = subject.wrappedJSObject;
+ if (context.principal.subsumes(subject.principal)) {
+ subject.setWebExtContextData(this.webExtContextData);
+ }
+ },
+ run() {
+ // "on-prepare-contextmenu" is expected to be observed before the
+ // end of the "contextmenu" event dispatch. This task is queued
+ // in case that does not happen, e.g. when the menu is not shown.
+ // ... or if the method was not called during a contextmenu event.
+ if (pendingMenuEvent === this) {
+ pendingMenuEvent = null;
+ Services.obs.removeObserver(this, "on-prepare-contextmenu");
+ }
+ },
+ };
+ Services.obs.addObserver(pendingMenuEvent, "on-prepare-contextmenu");
+ Services.tm.dispatchToMainThread(pendingMenuEvent);
+ },
+
+ onClicked: new EventManager({
+ context,
+ name: "menus.onClicked",
+ register: fire => {
+ let listener = (info, tab) => {
+ withHandlingUserInput(context.contentWindow, () =>
+ fire.sync(info, tab)
+ );
+ };
+
+ let event = context.childManager.getParentEvent("menus.onClicked");
+ event.addListener(listener);
+ return () => {
+ event.removeListener(listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/child/ext-tabs.js b/comm/mail/components/extensions/child/ext-tabs.js
new file mode 100644
index 0000000000..173c2b5f63
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-tabs.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.tabs = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ tabs: {
+ connect(tabId, options) {
+ let { frameId = null, name = "" } = options || {};
+ return context.messenger.connect({ name, tabId, frameId });
+ },
+
+ sendMessage(tabId, message, options, callback) {
+ let arg = { tabId, frameId: options?.frameId, message, callback };
+ return context.messenger.sendRuntimeMessage(arg);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/ext-mail.json b/comm/mail/components/extensions/ext-mail.json
new file mode 100644
index 0000000000..fe02227612
--- /dev/null
+++ b/comm/mail/components/extensions/ext-mail.json
@@ -0,0 +1,171 @@
+{
+ "accounts": {
+ "url": "chrome://messenger/content/parent/ext-accounts.js",
+ "schema": "chrome://messenger/content/schemas/accounts.json",
+ "scopes": ["addon_parent"],
+ "paths": [["accounts"]]
+ },
+ "addressBook": {
+ "url": "chrome://messenger/content/parent/ext-addressBook.js",
+ "schema": "chrome://messenger/content/schemas/addressBook.json",
+ "scopes": ["addon_parent"],
+ "paths": [["addressBooks"], ["contacts"], ["mailingLists"]]
+ },
+ "browserAction": {
+ "url": "chrome://messenger/content/parent/ext-browserAction.js",
+ "schema": "chrome://messenger/content/schemas/browserAction.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["action", "browser_action"],
+ "events": ["update", "uninstall"],
+ "paths": [["action"], ["browserAction"]]
+ },
+ "browsingData": {
+ "url": "chrome://extensions/content/parent/ext-browsingData.js",
+ "schema": "chrome://extensions/content/schemas/browsing_data.json",
+ "scopes": ["addon_parent"],
+ "paths": [["browsingData"]]
+ },
+ "chrome_settings_overrides": {
+ "url": "chrome://messenger/content/parent/ext-chrome-settings-overrides.js",
+ "scopes": [],
+ "events": ["update", "uninstall"],
+ "schema": "chrome://messenger/content/schemas/chrome_settings_overrides.json",
+ "manifest": ["chrome_settings_overrides"]
+ },
+ "cloudFile": {
+ "url": "chrome://messenger/content/parent/ext-cloudFile.js",
+ "schema": "chrome://messenger/content/schemas/cloudFile.json",
+ "scopes": ["addon_parent", "content_parent"],
+ "manifest": ["cloud_file"],
+ "paths": [["cloudFile"]]
+ },
+ "commands": {
+ "url": "chrome://messenger/content/parent/ext-commands.js",
+ "schema": "chrome://messenger/content/schemas/commands.json",
+ "scopes": ["addon_parent"],
+ "events": ["uninstall"],
+ "manifest": ["commands"],
+ "paths": [["commands"]]
+ },
+ "compose": {
+ "url": "chrome://messenger/content/parent/ext-compose.js",
+ "schema": "chrome://messenger/content/schemas/compose.json",
+ "scopes": ["addon_parent"],
+ "paths": [["compose"]]
+ },
+ "composeAction": {
+ "url": "chrome://messenger/content/parent/ext-composeAction.js",
+ "schema": "chrome://messenger/content/schemas/composeAction.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["compose_action"],
+ "events": ["uninstall"],
+ "paths": [["composeAction"]]
+ },
+ "extensionScripts": {
+ "url": "chrome://messenger/content/parent/ext-extensionScripts.js",
+ "schema": "chrome://messenger/content/schemas/extensionScripts.json",
+ "scopes": ["addon_parent"],
+ "paths": [["extensionScripts"]]
+ },
+ "folders": {
+ "url": "chrome://messenger/content/parent/ext-folders.js",
+ "schema": "chrome://messenger/content/schemas/folders.json",
+ "scopes": ["addon_parent"],
+ "paths": [["folders"]]
+ },
+ "geckoProfiler": {
+ "url": "chrome://extensions/content/parent/ext-geckoProfiler.js",
+ "schema": "chrome://extensions/content/schemas/geckoProfiler.json",
+ "scopes": ["addon_parent"],
+ "paths": [["geckoProfiler"]]
+ },
+ "identities": {
+ "url": "chrome://messenger/content/parent/ext-identities.js",
+ "schema": "chrome://messenger/content/schemas/identities.json",
+ "scopes": ["addon_parent"],
+ "paths": [["identities"]]
+ },
+ "identity": {
+ "url": "chrome://extensions/content/parent/ext-identity.js",
+ "schema": "chrome://extensions/content/schemas/identity.json",
+ "scopes": ["addon_parent"],
+ "paths": [["identity"]]
+ },
+ "mailTabs": {
+ "url": "chrome://messenger/content/parent/ext-mailTabs.js",
+ "schema": "chrome://messenger/content/schemas/mailTabs.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["mailTabs"],
+ "paths": [["mailTabs"]]
+ },
+ "menusChild": {
+ "schema": "chrome://messenger/content/schemas/menus_child.json",
+ "scopes": ["addon_child", "content_child", "devtools_child"]
+ },
+ "menus": {
+ "url": "chrome://messenger/content/parent/ext-menus.js",
+ "schema": "chrome://messenger/content/schemas/menus.json",
+ "scopes": ["addon_parent"],
+ "events": ["startup"],
+ "permissions": ["menus"],
+ "paths": [["menus"]]
+ },
+ "messageDisplay": {
+ "url": "chrome://messenger/content/parent/ext-messageDisplay.js",
+ "schema": "chrome://messenger/content/schemas/messageDisplay.json",
+ "scopes": ["addon_parent"],
+ "paths": [["messageDisplay"]]
+ },
+ "messageDisplayAction": {
+ "url": "chrome://messenger/content/parent/ext-messageDisplayAction.js",
+ "schema": "chrome://messenger/content/schemas/messageDisplayAction.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["message_display_action"],
+ "events": ["uninstall"],
+ "paths": [["messageDisplayAction"]]
+ },
+ "messages": {
+ "url": "chrome://messenger/content/parent/ext-messages.js",
+ "schema": "chrome://messenger/content/schemas/messages.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["messages"],
+ "paths": [["messages"]]
+ },
+ "pkcs11": {
+ "url": "chrome://messenger/content/parent/ext-pkcs11.js",
+ "schema": "chrome://messenger/content/schemas/pkcs11.json",
+ "scopes": ["addon_parent"],
+ "paths": [["pkcs11"]]
+ },
+ "sessions": {
+ "url": "chrome://messenger/content/parent/ext-sessions.js",
+ "schema": "chrome://messenger/content/schemas/sessions.json",
+ "scopes": ["addon_parent"],
+ "events": ["uninstall"],
+ "paths": [["sessions"]]
+ },
+ "spaces": {
+ "url": "chrome://messenger/content/parent/ext-spaces.js",
+ "schema": "chrome://messenger/content/schemas/spaces.json",
+ "scopes": ["addon_parent"],
+ "paths": [["spaces"]]
+ },
+ "spacesToolbar": {
+ "url": "chrome://messenger/content/parent/ext-spacesToolbar.js",
+ "schema": "chrome://messenger/content/schemas/spacesToolbar.json",
+ "scopes": ["addon_parent"],
+ "paths": [["spacesToolbar"]]
+ },
+ "tabs": {
+ "url": "chrome://messenger/content/parent/ext-tabs.js",
+ "schema": "chrome://messenger/content/schemas/tabs.json",
+ "scopes": ["addon_parent"],
+ "paths": [["tabs"]]
+ },
+ "windows": {
+ "url": "chrome://messenger/content/parent/ext-windows.js",
+ "schema": "chrome://messenger/content/schemas/windows.json",
+ "scopes": ["addon_parent"],
+ "paths": [["windows"]]
+ }
+}
diff --git a/comm/mail/components/extensions/extension.svg b/comm/mail/components/extensions/extension.svg
new file mode 100644
index 0000000000..a164552538
--- /dev/null
+++ b/comm/mail/components/extensions/extension.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="64" height="64" viewBox="0 0 64 64">
+ <defs>
+ <style>
+ .style-puzzle-piece {
+ fill: url('#gradient-linear-puzzle-piece');
+ }
+ </style>
+ <linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
+ <stop offset="0%" stop-color="#66cc52" stop-opacity="1"/>
+ <stop offset="100%" stop-color="#60bf4c" stop-opacity="1"/>
+ </linearGradient>
+ </defs>
+ <path class="style-puzzle-piece" d="M42,62c2.2,0,4-1.8,4-4l0-14.2c0,0,0.4-3.7,2.8-3.7c2.4,0,2.2,3.9,6.7,3.9c2.3,0,6.2-1.2,6.2-8.2 c0-7-3.9-7.9-6.2-7.9c-4.5,0-4.3,3.7-6.7,3.7c-2.4,0-2.8-3.8-2.8-3.8V22c0-2.2-1.8-4-4-4H31.5c0,0-3.4-0.6-3.4-3 c0-2.4,3.8-2.6,3.8-7.1c0-2.3-1.3-5.9-8.3-5.9s-8,3.6-8,5.9c0,4.5,3.4,4.7,3.4,7.1c0,2.4-3.4,3-3.4,3H6c-2.2,0-4,1.8-4,4l0,7.8 c0,0-0.4,6,4.4,6c3.1,0,3.2-4.1,7.3-4.1c2,0,4,1.9,4,6c0,4.2-2,6.3-4,6.3c-4,0-4.2-4.1-7.3-4.1c-4.8,0-4.4,5.8-4.4,5.8L2,58 c0,2.2,1.8,4,4,4H19c0,0,6.3,0.4,6.3-4.4c0-3.1-4-3.6-4-7.7c0-2,2.2-4.5,6.4-4.5c4.2,0,6.6,2.5,6.6,4.5c0,4-3.9,4.6-3.9,7.7 c0,4.9,6.3,4.4,6.3,4.4H42z"/>
+</svg>
diff --git a/comm/mail/components/extensions/extensionPopup.js b/comm/mail/components/extensions/extensionPopup.js
new file mode 100644
index 0000000000..ac0431e2ce
--- /dev/null
+++ b/comm/mail/components/extensions/extensionPopup.js
@@ -0,0 +1,557 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { BrowserUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/BrowserUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+var gContextMenu;
+
+/* globals reporterListener */
+
+/**
+ * @implements {nsICommandController}
+ */
+var contentController = {
+ commands: {
+ cmd_reload: {
+ isEnabled() {
+ return !contentProgress.busy;
+ },
+ doCommand() {
+ document.getElementById("requestFrame").reload();
+ },
+ },
+ cmd_stop: {
+ isEnabled() {
+ return contentProgress.busy;
+ },
+ doCommand() {
+ document.getElementById("requestFrame").stop();
+ },
+ },
+ "Browser:Back": {
+ isEnabled() {
+ return gBrowser.canGoBack;
+ },
+ doCommand() {
+ gBrowser.goBack();
+ },
+ },
+ "Browser:Forward": {
+ isEnabled() {
+ return gBrowser.canGoForward;
+ },
+ doCommand() {
+ gBrowser.goForward();
+ },
+ },
+ },
+
+ supportsCommand(command) {
+ return command in this.commands;
+ },
+ isCommandEnabled(command) {
+ if (!this.supportsCommand(command)) {
+ return false;
+ }
+ let cmd = this.commands[command];
+ return cmd.isEnabled();
+ },
+ doCommand(command) {
+ if (!this.supportsCommand(command)) {
+ return;
+ }
+ let cmd = this.commands[command];
+ if (!cmd.isEnabled()) {
+ return;
+ }
+ cmd.doCommand();
+ },
+ onEvent(event) {},
+};
+
+/**
+ * @implements {nsIBrowserDOMWindow}
+ */
+class nsBrowserAccess {
+ QueryInterface = ChromeUtils.generateQI(["nsIBrowserDOMWindow"]);
+
+ _openURIInNewTab(
+ aURI,
+ aReferrerInfo,
+ aIsExternal,
+ aOpenWindowInfo = null,
+ aTriggeringPrincipal = null,
+ aCsp = null,
+ aSkipLoad = false,
+ aMessageManagerGroup = null
+ ) {
+ // This is a popup which must not have more than one tab, so open the new tab
+ // in the most recent mail window.
+ let win = Services.wm.getMostRecentWindow("mail:3pane", true);
+
+ if (!win) {
+ // We couldn't find a suitable window, a new one needs to be opened.
+ return null;
+ }
+
+ let loadInBackground = Services.prefs.getBoolPref(
+ "browser.tabs.loadDivertedInBackground"
+ );
+
+ let tabmail = win.document.getElementById("tabmail");
+ let newTab = tabmail.openTab("contentTab", {
+ background: loadInBackground,
+ csp: aCsp,
+ linkHandler: aMessageManagerGroup,
+ openWindowInfo: aOpenWindowInfo,
+ referrerInfo: aReferrerInfo,
+ skipLoad: aSkipLoad,
+ triggeringPrincipal: aTriggeringPrincipal,
+ url: aURI ? aURI.spec : "about:blank",
+ });
+
+ win.focus();
+
+ return newTab.browser;
+ }
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ // Passing a null-URI to only create the content window,
+ // and pass true for aSkipLoad to prevent loading of
+ // about:blank
+ return this.getContentWindowOrOpenURIInFrame(
+ null,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ true
+ );
+ }
+
+ openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ openURIInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad
+ ) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ aSkipLoad
+ ) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return PrintUtils.handleStaticCloneCreatedForPrint(
+ aParams.openWindowInfo
+ );
+ }
+
+ if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) {
+ Services.console.logStringMessage(
+ "Error: openURIInFrame can only open in new tabs or print"
+ );
+ return null;
+ }
+
+ let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ return this._openURIInNewTab(
+ aURI,
+ aParams.referrerInfo,
+ isExternal,
+ aParams.openWindowInfo,
+ aParams.triggeringPrincipal,
+ aParams.csp,
+ aSkipLoad,
+ aParams.openerBrowser?.getAttribute("messagemanagergroup")
+ );
+ }
+
+ canClose() {
+ return true;
+ }
+
+ get tabCount() {
+ return 1;
+ }
+}
+
+function loadRequestedUrl() {
+ let browser = document.getElementById("requestFrame");
+ browser.addProgressListener(reporterListener, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.addEventListener(
+ "DOMWindowClose",
+ () => {
+ if (browser.getAttribute("allowscriptstoclose") == "true") {
+ window.close();
+ }
+ },
+ true
+ );
+ browser.addEventListener(
+ "pagetitlechanged",
+ () => gBrowser.updateTitlebar(),
+ true
+ );
+
+ // This window does double duty. If window.arguments[0] is a string, it's
+ // probably being called by browser.identity.launchWebAuthFlowInParent.
+
+ // Otherwise, it's probably being called by browser.windows.create, with an
+ // array of URLs to open in tabs. We'll only attempt to open the first,
+ // which is consistent with Firefox behaviour.
+
+ if (typeof window.arguments[0] == "string") {
+ MailE10SUtils.loadURI(browser, window.arguments[0]);
+ } else {
+ if (window.arguments[1].wrappedJSObject.allowScriptsToClose) {
+ browser.setAttribute("allowscriptstoclose", "true");
+ }
+ let tabParams = window.arguments[1].wrappedJSObject.tabs[0].tabParams;
+ if (tabParams.userContextId) {
+ browser.setAttribute("usercontextid", tabParams.userContextId);
+ // The usercontextid is only read on frame creation, so recreate it.
+ browser.replaceWith(browser);
+ }
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+ MailE10SUtils.loadURI(browser, tabParams.url);
+ }
+}
+
+// Fake it 'til you make it.
+var gBrowser = {
+ get canGoBack() {
+ return this.selectedBrowser.canGoBack;
+ },
+
+ get canGoForward() {
+ return this.selectedBrowser.canGoForward;
+ },
+
+ goForward(requireUserInteraction) {
+ return this.selectedBrowser.goForward(requireUserInteraction);
+ },
+
+ goBack(requireUserInteraction) {
+ return this.selectedBrowser.goBack(requireUserInteraction);
+ },
+
+ get selectedBrowser() {
+ return document.getElementById("requestFrame");
+ },
+ _getAndMaybeCreateDateTimePickerPanel() {
+ return this.selectedBrowser.dateTimePicker;
+ },
+ get webNavigation() {
+ return this.selectedBrowser.webNavigation;
+ },
+ async updateTitlebar() {
+ let docTitle =
+ browser.browsingContext?.currentWindowGlobal?.documentTitle?.trim() || "";
+ if (!docTitle) {
+ // If the document title is blank, use the default title.
+ docTitle = await document.l10n.formatValue(
+ "extension-popup-default-title"
+ );
+ } else {
+ // Let l10n handle the addition of separator and modifier.
+ docTitle = await document.l10n.formatValue("extension-popup-title", {
+ title: docTitle,
+ });
+ }
+
+ // Add preface, if defined.
+ let docElement = document.documentElement;
+ if (docElement.hasAttribute("titlepreface")) {
+ docTitle = docElement.getAttribute("titlepreface") + docTitle;
+ }
+
+ document.title = docTitle;
+ },
+ getTabForBrowser(browser) {
+ return null;
+ },
+};
+
+this.__defineGetter__("browser", getBrowser);
+
+function getBrowser() {
+ return gBrowser.selectedBrowser;
+}
+
+var gBrowserInit = {
+ onDOMContentLoaded() {
+ // This needs setting up before we create the first remote browser.
+ window.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow;
+
+ window.tryToClose = () => {
+ if (window.onclose()) {
+ window.close();
+ }
+ };
+
+ window.onclose = event => {
+ let { permitUnload } = gBrowser.selectedBrowser.permitUnload();
+ return permitUnload;
+ };
+
+ window.browserDOMWindow = new nsBrowserAccess();
+
+ let initiallyFocusedElement = document.commandDispatcher.focusedElement;
+ let promise = gBrowser.selectedBrowser.isRemoteBrowser
+ ? PromiseUtils.defer().promise
+ : Promise.resolve();
+
+ contentProgress.addListener({
+ onStateChange(browser, webProgress, request, stateFlags, statusCode) {
+ if (!webProgress.isTopLevel) {
+ return;
+ }
+
+ let status;
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ status = "loading";
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ status = "complete";
+ }
+ } else if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ statusCode == Cr.NS_BINDING_ABORTED
+ ) {
+ status = "complete";
+ }
+
+ contentProgress.busy = status == "loading";
+ },
+ });
+ contentProgress.addProgressListenerToBrowser(gBrowser.selectedBrowser);
+
+ top.controllers.appendController(contentController);
+
+ promise.then(() => {
+ // If focus didn't move while we were waiting, we're okay to move to
+ // the browser.
+ if (
+ document.commandDispatcher.focusedElement == initiallyFocusedElement
+ ) {
+ gBrowser.selectedBrowser.focus();
+ }
+ loadRequestedUrl();
+ });
+ },
+
+ isAdoptingTab() {
+ // Required for compatibility with toolkit's ext-webNavigation.js
+ return false;
+ },
+};
+
+/**
+ * @implements {nsIXULBrowserWindow}
+ */
+var XULBrowserWindow = {
+ // Used in mailWindows to show the link in the status bar, but popup windows
+ // do not have one. Do nothing here.
+ setOverLink(url, anchorElt) {},
+
+ // Called before links are navigated to to allow us to retarget them if needed.
+ onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) {
+ return originalTarget;
+ },
+
+ // Called by BrowserParent::RecvShowTooltip.
+ showTooltip(xDevPix, yDevPix, tooltip, direction, browser) {
+ if (
+ Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession()
+ ) {
+ return;
+ }
+
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.label = tooltip;
+ elt.style.direction = direction;
+ elt.openPopupAtScreen(
+ xDevPix / window.devicePixelRatio,
+ yDevPix / window.devicePixelRatio,
+ false,
+ null
+ );
+ },
+
+ // Called by BrowserParent::RecvHideTooltip.
+ hideTooltip() {
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.hidePopup();
+ },
+
+ getTabCount() {
+ // Popup windows have a single tab.
+ return 1;
+ },
+};
+
+/**
+ * Combines all nsIWebProgress notifications from all content browsers in this
+ * window and reports them to the registered listeners.
+ *
+ * @see WindowTracker (ext-mail.js)
+ * @see StatusListener, WindowTrackerBase (ext-tabs-base.js)
+ */
+var contentProgress = {
+ _listeners: new Set(),
+ busy: false,
+
+ addListener(listener) {
+ this._listeners.add(listener);
+ },
+
+ removeListener(listener) {
+ this._listeners.delete(listener);
+ },
+
+ callListeners(method, args) {
+ for (let listener of this._listeners.values()) {
+ if (method in listener) {
+ try {
+ listener[method](...args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ },
+
+ /**
+ * Ensure that `browser` has a ProgressListener attached to it.
+ *
+ * @param {Browser} browser
+ */
+ addProgressListenerToBrowser(browser) {
+ if (browser?.webProgress && !browser._progressListener) {
+ browser._progressListener = new contentProgress.ProgressListener(browser);
+ browser.webProgress.addProgressListener(
+ browser._progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+ },
+
+ // @implements {nsIWebProgressListener}
+ // @implements {nsIWebProgressListener2}
+ ProgressListener: class {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]);
+
+ constructor(browser) {
+ this.browser = browser;
+ }
+
+ callListeners(method, args) {
+ args.unshift(this.browser);
+ contentProgress.callListeners(method, args);
+ }
+
+ onProgressChange(...args) {
+ this.callListeners("onProgressChange", args);
+ }
+
+ onProgressChange64(...args) {
+ this.callListeners("onProgressChange64", args);
+ }
+
+ onLocationChange(...args) {
+ this.callListeners("onLocationChange", args);
+ }
+
+ onStateChange(...args) {
+ this.callListeners("onStateChange", args);
+ }
+
+ onStatusChange(...args) {
+ this.callListeners("onStatusChange", args);
+ }
+
+ onSecurityChange(...args) {
+ this.callListeners("onSecurityChange", args);
+ }
+
+ onContentBlockingEvent(...args) {
+ this.callListeners("onContentBlockingEvent", args);
+ }
+
+ onRefreshAttempted(...args) {
+ return this.callListeners("onRefreshAttempted", args);
+ }
+ },
+};
+
+// The listener of DOMContentLoaded must be set on window, rather than
+// document, because the window can go away before the event is fired.
+// In that case, we don't want to initialize anything, otherwise we
+// may be leaking things because they will never be destroyed after.
+window.addEventListener(
+ "DOMContentLoaded",
+ gBrowserInit.onDOMContentLoaded.bind(gBrowserInit),
+ { once: true }
+);
diff --git a/comm/mail/components/extensions/extensionPopup.xhtml b/comm/mail/components/extensions/extensionPopup.xhtml
new file mode 100644
index 0000000000..f12ca3e182
--- /dev/null
+++ b/comm/mail/components/extensions/extensionPopup.xhtml
@@ -0,0 +1,92 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, you can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/skin/popup.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/browserRequest.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/extensionPopup.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+ %brandDTD;
+ <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd">
+ %messengerDTD;
+]>
+<html id="browserRequest" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="mail:extensionPopup"
+ width="800" height="500"
+ scrolling="false">
+<head>
+ <title data-l10n-id="extension-popup-default-title"></title>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="messenger/extensions/popup.ftl"/>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserRequest.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/extensionPopup.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <popupset id="mainPopupSet">
+ <tooltip id="aHTMLTooltip" page="true"/>
+#include ../../base/content/widgets/browserPopups.inc.xhtml
+ </popupset>
+
+ <commandset>
+ <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/>
+ <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/>
+ <command id="cmd_close" oncommand="window.tryToClose()"/>
+ <command id="cmd_reload" oncommand="goDoCommand('cmd_reload');"/>
+ <command id="cmd_stop" oncommand="goDoCommand('cmd_stop');"/>
+ <command id="Browser:Back" oncommand="goDoCommand('Browser:Back');"/>
+ <command id="Browser:Forward" oncommand="goDoCommand('Browser:Forward');"/>
+ </commandset>
+
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <keyset id="popupKeys">
+ <key id="key_close" data-l10n-id="close-shortcut" command="cmd_close" modifiers="accel" reserved="true"/>
+ </keyset>
+
+ <keyset id="browserKeys">
+ #ifdef XP_MACOSX
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="gBrowser.goBack()" modifiers="accel"/>
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="gBrowser.goForward()" modifiers="accel"/>
+ #else
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="gBrowser.goBack()" modifiers="alt" />
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="gBrowser.goForward()" modifiers="alt" />
+ #endif
+ </keyset>
+
+ <!-- Use the same styling and semantics as content tabs. -->
+ <html:div id="header" class="contentTabAddress">
+ <html:img id="security-icon" class="contentTabSecurity" />
+ <html:input id="headerMessage" class="contentTabUrlInput themeableSearchBox"
+ readonly="readonly" />
+ </html:div>
+ <stack flex="1">
+ <browser id="requestFrame"
+ type="content"
+ src="about:blank"
+ flex="1"
+ tooltip="aHTMLTooltip"
+ autocompletepopup="PopupAutoComplete"
+ context="browserContext"
+ messagemanagergroup="single-site"/>
+ </stack>
+</html:body>
+</html>
diff --git a/comm/mail/components/extensions/extensions-mail.manifest b/comm/mail/components/extensions/extensions-mail.manifest
new file mode 100644
index 0000000000..314ab8f31b
--- /dev/null
+++ b/comm/mail/components/extensions/extensions-mail.manifest
@@ -0,0 +1,4 @@
+category webextension-modules mail chrome://messenger/content/ext-mail.json
+
+category webextension-scripts c-mail chrome://messenger/content/parent/ext-mail.js
+category webextension-scripts-addon mail chrome://messenger/content/child/ext-mail.js
diff --git a/comm/mail/components/extensions/jar.mn b/comm/mail/components/extensions/jar.mn
new file mode 100644
index 0000000000..defc845af2
--- /dev/null
+++ b/comm/mail/components/extensions/jar.mn
@@ -0,0 +1,68 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/ext-mail.json (ext-mail.json)
+ content/messenger/extension.svg (extension.svg)
+ content/messenger/extensionPopup.js (extensionPopup.js)
+* content/messenger/extensionPopup.xhtml (extensionPopup.xhtml)
+ content/messenger/processScript.js (processScript.js)
+
+ content/messenger/child/ext-extensionScripts.js (child/ext-extensionScripts.js)
+ content/messenger/child/ext-mail.js (child/ext-mail.js)
+ content/messenger/child/ext-menus.js (child/ext-menus.js)
+ content/messenger/child/ext-tabs.js (child/ext-tabs.js)
+
+ content/messenger/parent/ext-accounts.js (parent/ext-accounts.js)
+ content/messenger/parent/ext-addressBook.js (parent/ext-addressBook.js)
+ content/messenger/parent/ext-browserAction.js (parent/ext-browserAction.js)
+ content/messenger/parent/ext-chrome-settings-overrides.js (parent/ext-chrome-settings-overrides.js)
+ content/messenger/parent/ext-cloudFile.js (parent/ext-cloudFile.js)
+ content/messenger/parent/ext-commands.js (parent/ext-commands.js)
+ content/messenger/parent/ext-compose.js (parent/ext-compose.js)
+ content/messenger/parent/ext-composeAction.js (parent/ext-composeAction.js)
+ content/messenger/parent/ext-extensionScripts.js (parent/ext-extensionScripts.js)
+ content/messenger/parent/ext-folders.js (parent/ext-folders.js)
+ content/messenger/parent/ext-identities.js (parent/ext-identities.js)
+ content/messenger/parent/ext-mail.js (parent/ext-mail.js)
+ content/messenger/parent/ext-mailTabs.js (parent/ext-mailTabs.js)
+ content/messenger/parent/ext-menus.js (parent/ext-menus.js)
+ content/messenger/parent/ext-messageDisplay.js (parent/ext-messageDisplay.js)
+ content/messenger/parent/ext-messageDisplayAction.js (parent/ext-messageDisplayAction.js)
+ content/messenger/parent/ext-messages.js (parent/ext-messages.js)
+ content/messenger/parent/ext-pkcs11.js (/browser/components/extensions/parent/ext-pkcs11.js)
+ content/messenger/parent/ext-sessions.js (parent/ext-sessions.js)
+ content/messenger/parent/ext-spaces.js (parent/ext-spaces.js)
+ content/messenger/parent/ext-spacesToolbar.js (parent/ext-spacesToolbar.js)
+ content/messenger/parent/ext-tabs.js (parent/ext-tabs.js)
+ content/messenger/parent/ext-theme.js (parent/ext-theme.js)
+ content/messenger/parent/ext-windows.js (parent/ext-windows.js)
+
+ content/messenger/schemas/accounts.json (schemas/accounts.json)
+ content/messenger/schemas/addressBook.json (schemas/addressBook.json)
+ content/messenger/schemas/browserAction.json (schemas/browserAction.json)
+ content/messenger/schemas/chrome_settings_overrides.json (schemas/chrome_settings_overrides.json)
+ content/messenger/schemas/cloudFile.json (schemas/cloudFile.json)
+ content/messenger/schemas/commands.json (schemas/commands.json)
+ content/messenger/schemas/compose.json (schemas/compose.json)
+ content/messenger/schemas/composeAction.json (schemas/composeAction.json)
+ content/messenger/schemas/extensionScripts.json (schemas/extensionScripts.json)
+ content/messenger/schemas/folders.json (schemas/folders.json)
+ content/messenger/schemas/identities.json (schemas/identities.json)
+ content/messenger/schemas/mailTabs.json (schemas/mailTabs.json)
+ content/messenger/schemas/menus.json (schemas/menus.json)
+ content/messenger/schemas/menus_child.json (schemas/menus_child.json)
+ content/messenger/schemas/messageDisplay.json (schemas/messageDisplay.json)
+ content/messenger/schemas/messageDisplayAction.json (schemas/messageDisplayAction.json)
+ content/messenger/schemas/messages.json (schemas/messages.json)
+ content/messenger/schemas/pkcs11.json (/browser/components/extensions/schemas/pkcs11.json)
+ content/messenger/schemas/sessions.json (schemas/sessions.json)
+ content/messenger/schemas/spaces.json (schemas/spaces.json)
+ content/messenger/schemas/spacesToolbar.json (schemas/spacesToolbar.json)
+ content/messenger/schemas/tabs.json (schemas/tabs.json)
+ content/messenger/schemas/theme.json (schemas/theme.json)
+ content/messenger/schemas/windows.json (schemas/windows.json)
+
+% override chrome://extensions/content/schemas/theme.json chrome://messenger/content/schemas/theme.json
+% override chrome://extensions/content/parent/ext-theme.js chrome://messenger/content/parent/ext-theme.js
diff --git a/comm/mail/components/extensions/moz.build b/comm/mail/components/extensions/moz.build
new file mode 100644
index 0000000000..7ee56cdfe5
--- /dev/null
+++ b/comm/mail/components/extensions/moz.build
@@ -0,0 +1,27 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_COMPONENTS += [
+ "extensions-mail.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "ExtensionBrowsingData.sys.mjs",
+ "ExtensionPopups.sys.mjs",
+ "ExtensionToolbarButtons.jsm",
+ "MailExtensionShortcuts.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+TESTING_JS_MODULES += [
+ "test/AppUiTestDelegate.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/xpcshell/xpcshell-imap.ini",
+ "test/xpcshell/xpcshell-local.ini",
+ "test/xpcshell/xpcshell-nntp.ini",
+]
diff --git a/comm/mail/components/extensions/parent/.eslintrc.js b/comm/mail/components/extensions/parent/.eslintrc.js
new file mode 100644
index 0000000000..73279358eb
--- /dev/null
+++ b/comm/mail/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,81 @@
+"use strict";
+
+module.exports = {
+ globals: {
+ // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
+ // From toolkit/components/extensions/.eslintrc.js.
+ ExtensionAPI: true,
+ ExtensionAPIPersistent: true,
+ ExtensionCommon: true,
+ ExtensionUtils: true,
+ extensions: true,
+ global: true,
+ Services: true,
+
+ // From toolkit/components/extensions/parent/.eslintrc.js.
+ CONTAINER_STORE: true,
+ DEFAULT_STORE: true,
+ EventEmitter: true,
+ EventManager: true,
+ InputEventManager: true,
+ PRIVATE_STORE: true,
+ TabBase: true,
+ TabManagerBase: true,
+ TabTrackerBase: true,
+ WindowBase: true,
+ WindowManagerBase: true,
+ WindowTrackerBase: true,
+ getContainerForCookieStoreId: true,
+ getUserContextIdForCookieStoreId: true,
+ getCookieStoreIdForOriginAttributes: true,
+ getCookieStoreIdForContainer: true,
+ getCookieStoreIdForTab: true,
+ isContainerCookieStoreId: true,
+ isDefaultCookieStoreId: true,
+ isPrivateCookieStoreId: true,
+ isValidCookieStoreId: true,
+
+ // These are defined in ext-mail.js.
+ ADDRESS_BOOK_WINDOW_URI: true,
+ COMPOSE_WINDOW_URI: true,
+ MAIN_WINDOW_URI: true,
+ MESSAGE_WINDOW_URI: true,
+ MESSAGE_PROTOCOLS: true,
+ NOTIFICATION_COLLAPSE_TIME: true,
+ ExtensionError: true,
+ Tab: true,
+ TabmailTab: true,
+ Window: true,
+ TabmailWindow: true,
+ clickModifiersFromEvent: true,
+ convertFolder: true,
+ convertAccount: true,
+ traverseSubfolders: true,
+ convertMailIdentity: true,
+ convertMessage: true,
+ folderPathToURI: true,
+ folderURIToPath: true,
+ getNormalWindowReady: true,
+ getRealFileForFile: true,
+ getTabBrowser: true,
+ getTabTabmail: true,
+ getTabWindow: true,
+ messageListTracker: true,
+ messageTracker: true,
+ nsDummyMsgHeader: true,
+ spaceTracker: true,
+ tabGetSender: true,
+ tabTracker: true,
+ windowTracker: true,
+
+ // ext-browserAction.js
+ browserActionFor: true,
+ },
+ rules: {
+ // From toolkit/components/extensions/.eslintrc.js.
+ // Disable reject-importGlobalProperties because we don't want to include
+ // these in the sandbox directly as that would potentially mean the
+ // imported properties would be instantiated up-front rather than lazily.
+ "mozilla/reject-importGlobalProperties": "off",
+ },
+};
diff --git a/comm/mail/components/extensions/parent/ext-accounts.js b/comm/mail/components/extensions/parent/ext-accounts.js
new file mode 100644
index 0000000000..2388f896c7
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-accounts.js
@@ -0,0 +1,283 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * @implements {nsIObserver}
+ * @implements {nsIMsgFolderListener}
+ */
+var accountsTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.monitoredAccounts = new Map();
+
+ // Keep track of accounts data monitored for changes.
+ for (let nativeAccount of MailServices.accounts.accounts) {
+ this.monitoredAccounts.set(
+ nativeAccount.key,
+ this.getMonitoredProperties(nativeAccount)
+ );
+ }
+ }
+
+ getMonitoredProperties(nativeAccount) {
+ return {
+ name: nativeAccount.incomingServer.prettyName,
+ defaultIdentityKey: nativeAccount.defaultIdentity?.key,
+ };
+ }
+
+ getChangedMonitoredProperty(nativeAccount, propertyName) {
+ if (!nativeAccount || !this.monitoredAccounts.has(nativeAccount.key)) {
+ return false;
+ }
+ let values = this.monitoredAccounts.get(nativeAccount.key);
+ let propertyValue =
+ this.getMonitoredProperties(nativeAccount)[propertyName];
+ if (propertyValue && values[propertyName] != propertyValue) {
+ values[propertyName] = propertyValue;
+ this.monitoredAccounts.set(nativeAccount.key, values);
+ return propertyValue;
+ }
+ return false;
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ // nsIMsgFolderListener
+ MailServices.mfn.addListener(this, MailServices.mfn.folderAdded);
+ Services.prefs.addObserver("mail.server.", this);
+ Services.prefs.addObserver("mail.account.", this);
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ MailServices.mfn.removeListener(this);
+ Services.prefs.removeObserver("mail.server.", this);
+ Services.prefs.removeObserver("mail.account.", this);
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ }
+ }
+
+ // nsIMsgFolderListener
+ folderAdded(folder) {
+ // If the account of this folder is unknown, it is new and this is the
+ // initial root folder after the account has been created.
+ let server = folder.server;
+ let nativeAccount = MailServices.accounts.FindAccountForServer(server);
+ if (nativeAccount && !this.monitoredAccounts.has(nativeAccount.key)) {
+ this.monitoredAccounts.set(
+ nativeAccount.key,
+ this.getMonitoredProperties(nativeAccount)
+ );
+ let account = convertAccount(nativeAccount, false);
+ this.emit("account-added", nativeAccount.key, account);
+ }
+ }
+
+ // nsIObserver
+ _notifications = ["message-account-removed"];
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ {
+ let [, type, key, property] = data.split(".");
+
+ if (type == "server" && property == "name") {
+ let server;
+ try {
+ server = MailServices.accounts.getIncomingServer(key);
+ } catch (ex) {
+ // Fails for servers being removed.
+ return;
+ }
+ let nativeAccount =
+ MailServices.accounts.FindAccountForServer(server);
+
+ let name = this.getChangedMonitoredProperty(nativeAccount, "name");
+ if (name) {
+ this.emit("account-updated", nativeAccount.key, {
+ id: nativeAccount.key,
+ name,
+ });
+ }
+ }
+
+ if (type == "account" && property == "identities") {
+ let nativeAccount = MailServices.accounts.getAccount(key);
+
+ let defaultIdentityKey = this.getChangedMonitoredProperty(
+ nativeAccount,
+ "defaultIdentityKey"
+ );
+ if (defaultIdentityKey) {
+ this.emit("account-updated", nativeAccount.key, {
+ id: nativeAccount.key,
+ defaultIdentity: convertMailIdentity(
+ nativeAccount,
+ nativeAccount.defaultIdentity
+ ),
+ });
+ }
+ }
+ }
+ break;
+
+ case "message-account-removed":
+ if (this.monitoredAccounts.has(data)) {
+ this.monitoredAccounts.delete(data);
+ this.emit("account-removed", data);
+ }
+ break;
+ }
+ }
+})();
+
+this.accounts = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(_event, key, account) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, account);
+ }
+ accountsTracker.on("account-added", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ async function listener(_event, key, changedValues) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, changedValues);
+ }
+ accountsTracker.on("account-updated", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(_event, key) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key);
+ }
+ accountsTracker.on("account-removed", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ accountsTracker.incrementListeners();
+ }
+
+ onShutdown() {
+ accountsTracker.decrementListeners();
+ }
+
+ getAPI(context) {
+ return {
+ accounts: {
+ async list(includeFolders) {
+ let accounts = [];
+ for (let account of MailServices.accounts.accounts) {
+ account = convertAccount(account, includeFolders);
+ if (account) {
+ accounts.push(account);
+ }
+ }
+ return accounts;
+ },
+ async get(accountId, includeFolders) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertAccount(account, includeFolders);
+ },
+ async getDefault(includeFolders) {
+ let account = MailServices.accounts.defaultAccount;
+ return convertAccount(account, includeFolders);
+ },
+ async getDefaultIdentity(accountId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertMailIdentity(account, account?.defaultIdentity);
+ },
+ async setDefaultIdentity(accountId, identityId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ account.defaultIdentity = identity;
+ return;
+ }
+ }
+ throw new ExtensionError(
+ `Identity ${identityId} not found for ${accountId}`
+ );
+ },
+ onCreated: new EventManager({
+ context,
+ module: "accounts",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "accounts",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "accounts",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-addressBook.js b/comm/mail/components/extensions/parent/ext-addressBook.js
new file mode 100644
index 0000000000..14b0ce8cd0
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-addressBook.js
@@ -0,0 +1,1587 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { AddrBookDirectory } = ChromeUtils.import(
+ "resource:///modules/AddrBookDirectory.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "File", "FileReader"]);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ newUID: "resource:///modules/AddrBookUtils.jsm",
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+ VCardUtils: "resource:///modules/VCardUtils.jsm",
+});
+
+// nsIAbCard.idl contains a list of properties that Thunderbird uses. Extensions are not
+// restricted to using only these properties, but the following properties cannot
+// be modified by an extension.
+const hiddenProperties = [
+ "DbRowID",
+ "LowercasePrimaryEmail",
+ "LastModifiedDate",
+ "PopularityIndex",
+ "RecordKey",
+ "UID",
+ "_etag",
+ "_href",
+ "_vCard",
+ "vCard",
+ "PhotoName",
+ "PhotoURL",
+ "PhotoType",
+];
+
+/**
+ * Reads a DOM File and returns a Promise for its dataUrl.
+ *
+ * @param {File} file
+ * @returns {string}
+ */
+function getDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = function () {
+ resolve(reader.result);
+ };
+ reader.onerror = function (error) {
+ reject(new ExtensionError(error));
+ };
+ });
+}
+
+/**
+ * Returns the image type of the given contentType string, or throws if the
+ * contentType is not an image type supported by the address book.
+ *
+ * @param {string} contentType - The contentType of a photo.
+ * @returns {string} - Either "png" or "jpeg". Throws otherwise.
+ */
+function getImageType(contentType) {
+ let typeParts = contentType.toLowerCase().split("/");
+ if (typeParts[0] != "image" || !["jpeg", "png"].includes(typeParts[1])) {
+ throw new ExtensionError(`Unsupported image format: ${contentType}`);
+ }
+ return typeParts[1];
+}
+
+/**
+ * Adds a PHOTO VCardPropertyEntry for the given photo file.
+ *
+ * @param {VCardProperties} vCardProperties
+ * @param {File} photoFile
+ * @returns {VCardPropertyEntry}
+ */
+async function addVCardPhotoEntry(vCardProperties, photoFile) {
+ let dataUrl = await getDataUrl(photoFile);
+ if (vCardProperties.getFirstValue("version") == "4.0") {
+ vCardProperties.addEntry(
+ new VCardPropertyEntry("photo", {}, "url", dataUrl)
+ );
+ } else {
+ // If vCard version is not 4.0, default to 3.0.
+ vCardProperties.addEntry(
+ new VCardPropertyEntry(
+ "photo",
+ { encoding: "B", type: getImageType(photoFile.type).toUpperCase() },
+ "binary",
+ dataUrl.substring(dataUrl.indexOf(",") + 1)
+ )
+ );
+ }
+}
+
+/**
+ * Returns a DOM File object for the contact photo of the given contact.
+ *
+ * @param {string} id - The id of the contact
+ * @returns {File} The photo of the contact, or null.
+ */
+async function getPhotoFile(id) {
+ let { item } = addressBookCache.findContactById(id);
+ let photoUrl = item.photoURL;
+ if (!photoUrl) {
+ return null;
+ }
+
+ try {
+ if (photoUrl.startsWith("file://")) {
+ let realFile = Services.io
+ .newURI(photoUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ let file = await File.createFromNsIFile(realFile);
+ let type = getImageType(file.type);
+ // Clone the File object to be able to give it the correct name, matching
+ // the dataUrl/webUrl code path below.
+ return new File([file], `${id}.${type}`, { type: `image/${type}` });
+ }
+
+ // Retrieve dataUrls or webUrls.
+ let result = await fetch(photoUrl);
+ let type = getImageType(result.headers.get("content-type"));
+ let blob = await result.blob();
+ return new File([blob], `${id}.${type}`, { type: `image/${type}` });
+ } catch (ex) {
+ console.error(`Failed to read photo information for ${id}: ` + ex);
+ }
+
+ return null;
+}
+
+/**
+ * Sets the provided file as the primary photo of the given contact.
+ *
+ * @param {string} id - The id of the contact
+ * @param {File} file - The new photo
+ */
+async function setPhotoFile(id, file) {
+ let node = addressBookCache.findContactById(id);
+ let vCardProperties = vCardPropertiesFromCard(node.item);
+
+ try {
+ let type = getImageType(file.type);
+
+ // If the contact already has a photoUrl, replace it with the same url type.
+ // Otherwise save the photo as a local file, except for CardDAV contacts.
+ let photoUrl = node.item.photoURL;
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ let useFile = photoUrl
+ ? photoUrl.startsWith("file://")
+ : parentNode.item.dirType != Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE;
+
+ if (useFile) {
+ let oldPhotoFile;
+ if (photoUrl) {
+ try {
+ oldPhotoFile = Services.io
+ .newURI(photoUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } catch (ex) {
+ console.error(`Ignoring invalid photoUrl ${photoUrl}: ` + ex);
+ }
+ }
+ let pathPhotoFile = await IOUtils.createUniqueFile(
+ PathUtils.join(PathUtils.profileDir, "Photos"),
+ `${id}.${type}`,
+ 0o600
+ );
+
+ if (file.mozFullPath) {
+ // The file object was created by selecting a real file through a file
+ // picker and is directly linked to a local file. Do a low level copy.
+ await IOUtils.copy(file.mozFullPath, pathPhotoFile);
+ } else {
+ // The file object is a data blob. Dump it into a real file.
+ let buffer = await file.arrayBuffer();
+ await IOUtils.write(pathPhotoFile, new Uint8Array(buffer));
+ }
+
+ // Set the PhotoName.
+ node.item.setProperty("PhotoName", PathUtils.filename(pathPhotoFile));
+
+ // Delete the old photo file.
+ if (oldPhotoFile?.exists()) {
+ try {
+ await IOUtils.remove(oldPhotoFile.path);
+ } catch (ex) {
+ console.error(`Failed to delete old photo file for ${id}: ` + ex);
+ }
+ }
+ } else {
+ // Follow the UI and replace the entire entry.
+ vCardProperties.clearValues("photo");
+ await addVCardPhotoEntry(vCardProperties, file);
+ }
+ parentNode.item.modifyCard(node.item);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to read new photo information for ${id}: ` + ex
+ );
+ }
+}
+
+/**
+ * Gets the VCardProperties of the given card either directly or by reconstructing
+ * from a set of flat standard properties.
+ *
+ * @param {nsIAbCard/AddrBookCard} card
+ * @returns {VCardProperties}
+ */
+function vCardPropertiesFromCard(card) {
+ if (card.supportsVCard) {
+ return card.vCardProperties;
+ }
+ return VCardProperties.fromPropertyMap(
+ new Map(Array.from(card.properties, p => [p.name, p.value]))
+ );
+}
+
+/**
+ * Creates a new AddrBookCard from a set of flat standard properties.
+ *
+ * @param {ContactProperties} properties - a key/value properties object
+ * @param {string} uid - optional UID for the card
+ * @returns {AddrBookCard}
+ */
+function flatPropertiesToAbCard(properties, uid) {
+ // Do not use VCardUtils.propertyMapToVCard().
+ let vCard = VCardProperties.fromPropertyMap(
+ new Map(Object.entries(properties))
+ ).toVCard();
+ return VCardUtils.vCardToAbCard(vCard, uid);
+}
+
+/**
+ * Checks if the given property is a custom contact property, which can be exposed
+ * to WebExtensions.
+ *
+ * @param {string} name - property name
+ * @returns {boolean}
+ */
+function isCustomProperty(name) {
+ return (
+ !hiddenProperties.includes(name) &&
+ !BANISHED_PROPERTIES.includes(name) &&
+ name.match(/^\w+$/)
+ );
+}
+
+/**
+ * Adds the provided originalProperties to the card, adjusted by the changes
+ * given in updateProperties. All banished properties are skipped and the updated
+ * properties must be valid according to isCustomProperty().
+ *
+ * @param {AddrBookCard} card - a card to receive the provided properties
+ * @param {ContactProperties} updateProperties - a key/value object with properties
+ * to update the provided originalProperties
+ * @param {nsIProperties} originalProperties - properties to be cloned onto
+ * the provided card
+ */
+function addProperties(card, updateProperties, originalProperties) {
+ let updates = Object.entries(updateProperties).filter(e =>
+ isCustomProperty(e[0])
+ );
+ let mergedProperties = originalProperties
+ ? new Map([
+ ...Array.from(originalProperties, p => [p.name, p.value]),
+ ...updates,
+ ])
+ : new Map(updates);
+
+ for (let [name, value] of mergedProperties) {
+ if (
+ !BANISHED_PROPERTIES.includes(name) &&
+ value != "" &&
+ value != null &&
+ value != undefined
+ ) {
+ card.setProperty(name, value);
+ }
+ }
+}
+
+/**
+ * Address book that supports finding cards only for a search (like LDAP).
+ *
+ * @implements {nsIAbDirectory}
+ */
+class ExtSearchBook extends AddrBookDirectory {
+ constructor(fire, context, args = {}) {
+ super();
+ this.fire = fire;
+ this._readOnly = true;
+ this._isSecure = Boolean(args.isSecure);
+ this._dirName = String(args.addressBookName ?? context.extension.name);
+ this._fileName = "";
+ this._uid = String(args.id ?? newUID());
+ this._uri = "searchaddr://" + this.UID;
+ this.lastModifiedDate = 0;
+ this.isMailList = false;
+ this.listNickName = "";
+ this.description = "";
+ this._dirPrefId = "";
+ }
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get lists() {
+ return new Map();
+ }
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get cards() {
+ return new Map();
+ }
+ // nsIAbDirectory
+ get isRemote() {
+ return true;
+ }
+ get isSecure() {
+ return this._isSecure;
+ }
+ getCardFromProperty(aProperty, aValue, aCaseSensitive) {
+ return null;
+ }
+ getCardsFromProperty(aProperty, aValue, aCaseSensitive) {
+ return [];
+ }
+ get dirType() {
+ return Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE;
+ }
+ get position() {
+ return 0;
+ }
+ get childCardCount() {
+ return 0;
+ }
+ useForAutocomplete(aIdentityKey) {
+ // AddrBookDirectory defaults to true
+ return false;
+ }
+ get supportsMailingLists() {
+ return false;
+ }
+ setLocalizedStringValue(aName, aValue) {}
+ async search(aQuery, aSearchString, aListener) {
+ try {
+ if (this.fire.wakeup) {
+ await this.fire.wakeup();
+ }
+ let { results, isCompleteResult } = await this.fire.async(
+ await addressBookCache.convert(
+ addressBookCache.addressBooks.get(this.UID)
+ ),
+ aSearchString,
+ aQuery
+ );
+ for (let resultData of results) {
+ let card;
+ // A specified vCard is winning over any individual standard property.
+ if (resultData.vCard) {
+ try {
+ card = VCardUtils.vCardToAbCard(resultData.vCard);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${resultData.vCard}.`
+ );
+ }
+ } else {
+ card = flatPropertiesToAbCard(resultData);
+ }
+ // Add custom properties to the property bag.
+ addProperties(card, resultData);
+ card.directoryUID = this.UID;
+ aListener.onSearchFoundCard(card);
+ }
+ aListener.onSearchFinished(Cr.NS_OK, isCompleteResult, null, "");
+ } catch (ex) {
+ aListener.onSearchFinished(
+ ex.result || Cr.NS_ERROR_FAILURE,
+ true,
+ null,
+ ""
+ );
+ }
+ }
+}
+
+/**
+ * Cache of items in the address book "tree".
+ *
+ * @implements {nsIObserver}
+ */
+var addressBookCache = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.flush();
+ }
+ _makeContactNode(contact, parent) {
+ contact.QueryInterface(Ci.nsIAbCard);
+ return {
+ id: contact.UID,
+ parentId: parent.UID,
+ type: "contact",
+ item: contact,
+ };
+ }
+ _makeDirectoryNode(directory, parent = null) {
+ directory.QueryInterface(Ci.nsIAbDirectory);
+ let node = {
+ id: directory.UID,
+ type: directory.isMailList ? "mailingList" : "addressBook",
+ item: directory,
+ };
+ if (parent) {
+ node.parentId = parent.UID;
+ }
+ return node;
+ }
+ _populateListContacts(mailingList) {
+ mailingList.contacts = new Map();
+ for (let contact of mailingList.item.childCards) {
+ let newNode = this._makeContactNode(contact, mailingList.item);
+ mailingList.contacts.set(newNode.id, newNode);
+ }
+ }
+ getListContacts(mailingList) {
+ if (!mailingList.contacts) {
+ this._populateListContacts(mailingList);
+ }
+ return [...mailingList.contacts.values()];
+ }
+ _populateContacts(addressBook) {
+ addressBook.contacts = new Map();
+ for (let contact of addressBook.item.childCards) {
+ if (!contact.isMailList) {
+ let newNode = this._makeContactNode(contact, addressBook.item);
+ this._contacts.set(newNode.id, newNode);
+ addressBook.contacts.set(newNode.id, newNode);
+ }
+ }
+ }
+ getContacts(addressBook) {
+ if (!addressBook.contacts) {
+ this._populateContacts(addressBook);
+ }
+ return [...addressBook.contacts.values()];
+ }
+ _populateMailingLists(parent) {
+ parent.mailingLists = new Map();
+ for (let mailingList of parent.item.childNodes) {
+ let newNode = this._makeDirectoryNode(mailingList, parent.item);
+ this._mailingLists.set(newNode.id, newNode);
+ parent.mailingLists.set(newNode.id, newNode);
+ }
+ }
+ getMailingLists(parent) {
+ if (!parent.mailingLists) {
+ this._populateMailingLists(parent);
+ }
+ return [...parent.mailingLists.values()];
+ }
+ get addressBooks() {
+ if (!this._addressBooks) {
+ this._addressBooks = new Map();
+ for (let tld of MailServices.ab.directories) {
+ this._addressBooks.set(tld.UID, this._makeDirectoryNode(tld));
+ }
+ }
+ return this._addressBooks;
+ }
+ flush() {
+ this._contacts = new Map();
+ this._mailingLists = new Map();
+ this._addressBooks = null;
+ }
+ findAddressBookById(id) {
+ let addressBook = this.addressBooks.get(id);
+ if (addressBook) {
+ return addressBook;
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `addressBook with id=${id} could not be found.`
+ );
+ }
+ findMailingListById(id) {
+ if (this._mailingLists.has(id)) {
+ return this._mailingLists.get(id);
+ }
+ for (let addressBook of this.addressBooks.values()) {
+ if (!addressBook.mailingLists) {
+ this._populateMailingLists(addressBook);
+ if (addressBook.mailingLists.has(id)) {
+ return addressBook.mailingLists.get(id);
+ }
+ }
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `mailingList with id=${id} could not be found.`
+ );
+ }
+ findContactById(id, bookHint) {
+ if (this._contacts.has(id)) {
+ return this._contacts.get(id);
+ }
+ if (bookHint && !bookHint.contacts) {
+ this._populateContacts(bookHint);
+ if (bookHint.contacts.has(id)) {
+ return bookHint.contacts.get(id);
+ }
+ }
+ for (let addressBook of this.addressBooks.values()) {
+ if (!addressBook.contacts) {
+ this._populateContacts(addressBook);
+ if (addressBook.contacts.has(id)) {
+ return addressBook.contacts.get(id);
+ }
+ }
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `contact with id=${id} could not be found.`
+ );
+ }
+ async convert(node, complete) {
+ if (node === null) {
+ return node;
+ }
+ if (Array.isArray(node)) {
+ let cards = await Promise.allSettled(
+ node.map(i => this.convert(i, complete))
+ );
+ return cards.filter(card => card.value).map(card => card.value);
+ }
+
+ let copy = {};
+ for (let key of ["id", "parentId", "type"]) {
+ if (key in node) {
+ copy[key] = node[key];
+ }
+ }
+
+ if (complete) {
+ if (node.type == "addressBook") {
+ copy.mailingLists = await this.convert(
+ this.getMailingLists(node),
+ true
+ );
+ copy.contacts = await this.convert(this.getContacts(node), true);
+ }
+ if (node.type == "mailingList") {
+ copy.contacts = await this.convert(this.getListContacts(node), true);
+ }
+ }
+
+ switch (node.type) {
+ case "addressBook":
+ copy.name = node.item.dirName;
+ copy.readOnly = node.item.readOnly;
+ copy.remote = node.item.isRemote;
+ break;
+ case "contact": {
+ // Clone the vCardProperties of this contact, so we can manipulate them
+ // for the WebExtension, but do not actually change the stored data.
+ let vCardProperties = vCardPropertiesFromCard(node.item).clone();
+ copy.properties = {};
+
+ // Build a flat property list from vCardProperties.
+ for (let [name, value] of vCardProperties.toPropertyMap()) {
+ copy.properties[name] = "" + value;
+ }
+
+ // Return all other exposed properties stored in the nodes property bag.
+ for (let property of Array.from(node.item.properties).filter(e =>
+ isCustomProperty(e.name)
+ )) {
+ copy.properties[property.name] = "" + property.value;
+ }
+
+ // If this card has no photo vCard entry, but a local photo, add it to its vCard: Thunderbird
+ // does not store photos of local address books in the internal _vCard property, to reduce
+ // the amount of data stored in its database.
+ let photoName = node.item.getProperty("PhotoName", "");
+ let vCardPhoto = vCardProperties.getFirstValue("photo");
+ if (!vCardPhoto && photoName) {
+ try {
+ let realPhotoFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ realPhotoFile.append("Photos");
+ realPhotoFile.append(photoName);
+ let photoFile = await File.createFromNsIFile(realPhotoFile);
+ await addVCardPhotoEntry(vCardProperties, photoFile);
+ } catch (ex) {
+ console.error(
+ `Failed to read photo information for ${node.id}: ` + ex
+ );
+ }
+ }
+
+ // Add the vCard.
+ copy.properties.vCard = vCardProperties.toVCard();
+
+ let parentNode;
+ try {
+ parentNode = this.findAddressBookById(node.parentId);
+ } catch (ex) {
+ // Parent might be a mailing list.
+ parentNode = this.findMailingListById(node.parentId);
+ }
+ copy.readOnly = parentNode.item.readOnly;
+ copy.remote = parentNode.item.isRemote;
+ break;
+ }
+ case "mailingList":
+ copy.name = node.item.dirName;
+ copy.nickName = node.item.listNickName;
+ copy.description = node.item.description;
+ let parentNode = this.findAddressBookById(node.parentId);
+ copy.readOnly = parentNode.item.readOnly;
+ copy.remote = parentNode.item.isRemote;
+ break;
+ }
+
+ return copy;
+ }
+
+ // nsIObserver
+ _notifications = [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-contact-created",
+ "addrbook-contact-properties-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-added",
+ "addrbook-list-member-removed",
+ ];
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "addrbook-directory-created": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let newNode = this._makeDirectoryNode(subject);
+ if (this._addressBooks) {
+ this._addressBooks.set(newNode.id, newNode);
+ }
+
+ this.emit("address-book-created", newNode);
+ break;
+ }
+ case "addrbook-directory-updated": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ this.emit("address-book-updated", this._makeDirectoryNode(subject));
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let uid = subject.UID;
+ if (this._addressBooks?.has(uid)) {
+ let parentNode = this._addressBooks.get(uid);
+ if (parentNode.contacts) {
+ for (let id of parentNode.contacts.keys()) {
+ this._contacts.delete(id);
+ }
+ }
+ if (parentNode.mailingLists) {
+ for (let id of parentNode.mailingLists.keys()) {
+ this._mailingLists.delete(id);
+ }
+ }
+ this._addressBooks.delete(uid);
+ }
+
+ this.emit("address-book-deleted", uid);
+ break;
+ }
+ case "addrbook-contact-created": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parent = MailServices.ab.getDirectoryFromUID(data);
+ let newNode = this._makeContactNode(subject, parent);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.set(newNode.id, newNode);
+ }
+ this._contacts.set(newNode.id, newNode);
+ }
+
+ this.emit("contact-created", newNode);
+ break;
+ }
+ case "addrbook-contact-properties-updated": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parentUID = subject.directoryUID;
+ let parent = MailServices.ab.getDirectoryFromUID(parentUID);
+ let newNode = this._makeContactNode(subject, parent);
+ if (this._addressBooks?.has(parentUID)) {
+ let parentNode = this._addressBooks.get(parentUID);
+ if (parentNode.contacts) {
+ parentNode.contacts.set(newNode.id, newNode);
+ this._contacts.set(newNode.id, newNode);
+ }
+ if (parentNode.mailingLists) {
+ for (let mailingList of parentNode.mailingLists.values()) {
+ if (
+ mailingList.contacts &&
+ mailingList.contacts.has(newNode.id)
+ ) {
+ mailingList.contacts.get(newNode.id).item = subject;
+ }
+ }
+ }
+ }
+
+ this.emit("contact-updated", newNode, JSON.parse(data));
+ break;
+ }
+ case "addrbook-contact-deleted": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let uid = subject.UID;
+ this._contacts.delete(uid);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.delete(uid);
+ }
+ }
+
+ this.emit("contact-deleted", data, uid);
+ break;
+ }
+ case "addrbook-list-created": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let parent = MailServices.ab.getDirectoryFromUID(data);
+ let newNode = this._makeDirectoryNode(subject, parent);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.mailingLists) {
+ parentNode.mailingLists.set(newNode.id, newNode);
+ }
+ this._mailingLists.set(newNode.id, newNode);
+ }
+
+ this.emit("mailing-list-created", newNode);
+ break;
+ }
+ case "addrbook-list-updated": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let listNode = this.findMailingListById(subject.UID);
+ listNode.item = subject;
+
+ this.emit("mailing-list-updated", listNode);
+ break;
+ }
+ case "addrbook-list-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let uid = subject.UID;
+ this._mailingLists.delete(uid);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.mailingLists) {
+ parentNode.mailingLists.delete(uid);
+ }
+ }
+
+ this.emit("mailing-list-deleted", data, uid);
+ break;
+ }
+ case "addrbook-list-member-added": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parentNode = this.findMailingListById(data);
+ let newNode = this._makeContactNode(subject, parentNode.item);
+ if (
+ this._mailingLists.has(data) &&
+ this._mailingLists.get(data).contacts
+ ) {
+ this._mailingLists.get(data).contacts.set(newNode.id, newNode);
+ }
+ this.emit("mailing-list-member-added", newNode);
+ break;
+ }
+ case "addrbook-list-member-removed": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let uid = subject.UID;
+ if (this._mailingLists.has(data)) {
+ let parentNode = this._mailingLists.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.delete(uid);
+ }
+ }
+
+ this.emit("mailing-list-member-removed", data, uid);
+ break;
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+
+ this.flush();
+ }
+ }
+})();
+
+this.addressBook = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ // addressBooks.*
+ onAddressBookCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("address-book-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAddressBookUpdated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("address-book-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAddressBookDeleted({ context, fire }) {
+ let listener = async (event, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(itemUID);
+ };
+ addressBookCache.on("address-book-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ // contacts.*
+ onContactCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("contact-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onContactUpdated({ context, fire }) {
+ let listener = async (event, node, changes) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let filteredChanges = {};
+ // Find changes in flat properties stored in the vCard.
+ if (changes.hasOwnProperty("_vCard")) {
+ let oldVCardProperties = VCardProperties.fromVCard(
+ changes._vCard.oldValue
+ ).toPropertyMap();
+ let newVCardProperties = VCardProperties.fromVCard(
+ changes._vCard.newValue
+ ).toPropertyMap();
+ for (let [name, value] of oldVCardProperties) {
+ if (newVCardProperties.get(name) != value) {
+ filteredChanges[name] = {
+ oldValue: value,
+ newValue: newVCardProperties.get(name) ?? null,
+ };
+ }
+ }
+ for (let [name, value] of newVCardProperties) {
+ if (
+ !filteredChanges.hasOwnProperty(name) &&
+ oldVCardProperties.get(name) != value
+ ) {
+ filteredChanges[name] = {
+ oldValue: oldVCardProperties.get(name) ?? null,
+ newValue: value,
+ };
+ }
+ }
+ }
+ for (let [name, value] of Object.entries(changes)) {
+ if (!filteredChanges.hasOwnProperty(name) && isCustomProperty(name)) {
+ filteredChanges[name] = value;
+ }
+ }
+ fire.sync(await addressBookCache.convert(node), filteredChanges);
+ };
+ addressBookCache.on("contact-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onContactDeleted({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("contact-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ // mailingLists.*
+ onMailingListCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMailingListUpdated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMailingListDeleted({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("mailing-list-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMemberAdded({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-member-added", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-member-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMemberRemoved({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("mailing-list-member-removed", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-member-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ addressBookCache.incrementListeners();
+ }
+
+ onShutdown() {
+ addressBookCache.decrementListeners();
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ addressBooks: {
+ async openUI() {
+ let messengerWindow = windowTracker.topNormalWindow;
+ let abWindow = await messengerWindow.toAddressBook();
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ let abTab = messengerWindow.document
+ .getElementById("tabmail")
+ .tabInfo.find(t => t.mode.name == "addressBookTab");
+ return tabManager.convert(abTab);
+ },
+ async closeUI() {
+ for (let win of Services.wm.getEnumerator("mail:3pane")) {
+ let tabmail = win.document.getElementById("tabmail");
+ for (let tab of tabmail.tabInfo.slice()) {
+ if (tab.browser?.currentURI.spec == "about:addressbook") {
+ tabmail.closeTab(tab);
+ }
+ }
+ }
+ },
+
+ list(complete = false) {
+ return addressBookCache.convert(
+ [...addressBookCache.addressBooks.values()],
+ complete
+ );
+ },
+ get(id, complete = false) {
+ return addressBookCache.convert(
+ addressBookCache.findAddressBookById(id),
+ complete
+ );
+ },
+ create({ name }) {
+ let dirName = MailServices.ab.newAddressBook(
+ name,
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let directory = MailServices.ab.getDirectoryFromId(dirName);
+ return directory.UID;
+ },
+ update(id, { name }) {
+ let node = addressBookCache.findAddressBookById(id);
+ node.item.dirName = name;
+ },
+ async delete(id) {
+ let node = addressBookCache.findAddressBookById(id);
+ let deletePromise = new Promise(resolve => {
+ let listener = () => {
+ addressBookCache.off("address-book-deleted", listener);
+ resolve();
+ };
+ addressBookCache.on("address-book-deleted", listener);
+ });
+ MailServices.ab.deleteAddressBook(node.item.URI);
+ await deletePromise;
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookDeleted",
+ extensionApi: this,
+ }).api(),
+
+ provider: {
+ onSearchRequest: new EventManager({
+ context,
+ name: "addressBooks.provider.onSearchRequest",
+ register: (fire, args) => {
+ if (addressBookCache.addressBooks.has(args.id)) {
+ throw new ExtensionUtils.ExtensionError(
+ `addressBook with id=${args.id} already exists.`
+ );
+ }
+ let dir = new ExtSearchBook(fire, context, args);
+ dir.init();
+ MailServices.ab.addAddressBook(dir);
+ return () => {
+ MailServices.ab.deleteAddressBook(dir.URI);
+ };
+ },
+ }).api(),
+ },
+ },
+ contacts: {
+ list(parentId) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ return addressBookCache.convert(
+ addressBookCache.getContacts(parentNode),
+ false
+ );
+ },
+ async quickSearch(parentId, queryInfo) {
+ const { getSearchTokens, getModelQuery, generateQueryURI } =
+ ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+
+ let searchString;
+ if (typeof queryInfo == "string") {
+ searchString = queryInfo;
+ queryInfo = {
+ includeRemote: true,
+ includeLocal: true,
+ includeReadOnly: true,
+ includeReadWrite: true,
+ };
+ } else {
+ searchString = queryInfo.searchString;
+ }
+
+ let searchWords = getSearchTokens(searchString);
+ if (searchWords.length == 0) {
+ return [];
+ }
+ let searchFormat = getModelQuery(
+ "mail.addr_book.quicksearchquery.format"
+ );
+ let searchQuery = generateQueryURI(searchFormat, searchWords);
+
+ let booksToSearch;
+ if (parentId == null) {
+ booksToSearch = [...addressBookCache.addressBooks.values()];
+ } else {
+ booksToSearch = [addressBookCache.findAddressBookById(parentId)];
+ }
+
+ let results = [];
+ let promises = [];
+ for (let book of booksToSearch) {
+ if (
+ (book.item.isRemote && !queryInfo.includeRemote) ||
+ (!book.item.isRemote && !queryInfo.includeLocal) ||
+ (book.item.readOnly && !queryInfo.includeReadOnly) ||
+ (!book.item.readOnly && !queryInfo.includeReadWrite)
+ ) {
+ continue;
+ }
+ promises.push(
+ new Promise(resolve => {
+ book.item.search(searchQuery, searchString, {
+ onSearchFinished(status, complete, secInfo, location) {
+ resolve();
+ },
+ onSearchFoundCard(contact) {
+ if (contact.isMailList) {
+ return;
+ }
+ results.push(
+ addressBookCache._makeContactNode(contact, book.item)
+ );
+ },
+ });
+ })
+ );
+ }
+ await Promise.all(promises);
+
+ return addressBookCache.convert(results, false);
+ },
+ get(id) {
+ return addressBookCache.convert(
+ addressBookCache.findContactById(id),
+ false
+ );
+ },
+ async getPhoto(id) {
+ return getPhotoFile(id);
+ },
+ async setPhoto(id, file) {
+ return setPhotoFile(id, file);
+ },
+ create(parentId, id, createData) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot create a contact in a read-only address book"
+ );
+ }
+
+ let card;
+ // A specified vCard is winning over any individual standard property.
+ if (createData.vCard) {
+ try {
+ card = VCardUtils.vCardToAbCard(createData.vCard, id);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${createData.vCard}.`
+ );
+ }
+ } else {
+ card = flatPropertiesToAbCard(createData, id);
+ }
+ // Add custom properties to the property bag.
+ addProperties(card, createData);
+
+ // Check if the new card has an enforced UID.
+ if (card.vCardProperties.getFirstValue("uid")) {
+ let duplicateExists = false;
+ try {
+ // Second argument is only a hint, all address books are checked.
+ addressBookCache.findContactById(card.UID, parentId);
+ duplicateExists = true;
+ } catch (ex) {
+ // Do nothing. We want this to throw because no contact was found.
+ }
+ if (duplicateExists) {
+ throw new ExtensionError(`Duplicate contact id: ${card.UID}`);
+ }
+ }
+
+ let newCard = parentNode.item.addCard(card);
+ return newCard.UID;
+ },
+ update(id, updateData) {
+ let node = addressBookCache.findContactById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot modify a contact in a read-only address book"
+ );
+ }
+
+ // A specified vCard is winning over any individual standard property.
+ // While a vCard is replacing the entire contact, specified standard
+ // properties only update single entries (setting a value to null
+ // clears it / promotes the next value of the same kind).
+ let card;
+ if (updateData.vCard) {
+ let vCardUID;
+ try {
+ card = new AddrBookCard();
+ card.UID = node.item.UID;
+ card.setProperty(
+ "_vCard",
+ VCardUtils.translateVCard21(updateData.vCard)
+ );
+ vCardUID = card.vCardProperties.getFirstValue("uid");
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${updateData.vCard}.`
+ );
+ }
+ if (vCardUID && vCardUID != node.item.UID) {
+ throw new ExtensionError(
+ `The card's UID ${node.item.UID} may not be changed: ${updateData.vCard}.`
+ );
+ }
+ } else {
+ // Get the current vCardProperties, build a propertyMap and create
+ // vCardParsed which allows to identify all currently exposed entries
+ // based on the typeName used in VCardUtils.jsm (e.g. adr.work).
+ let vCardProperties = vCardPropertiesFromCard(node.item);
+ let vCardParsed = VCardUtils._parse(vCardProperties.entries);
+ let propertyMap = vCardProperties.toPropertyMap();
+
+ // Save the old exposed state.
+ let oldProperties = VCardProperties.fromPropertyMap(propertyMap);
+ let oldParsed = VCardUtils._parse(oldProperties.entries);
+ // Update the propertyMap.
+ for (let [name, value] of Object.entries(updateData)) {
+ propertyMap.set(name, value);
+ }
+ // Save the new exposed state.
+ let newProperties = VCardProperties.fromPropertyMap(propertyMap);
+ let newParsed = VCardUtils._parse(newProperties.entries);
+
+ // Evaluate the differences and update the still existing entries,
+ // mark removed items for deletion.
+ let deleteLog = [];
+ for (let typeName of oldParsed.keys()) {
+ if (typeName == "version") {
+ continue;
+ }
+ for (let idx = 0; idx < oldParsed.get(typeName).length; idx++) {
+ if (
+ newParsed.has(typeName) &&
+ idx < newParsed.get(typeName).length
+ ) {
+ let originalIndex = vCardParsed.get(typeName)[idx].index;
+ let newEntryIndex = newParsed.get(typeName)[idx].index;
+ vCardProperties.entries[originalIndex] =
+ newProperties.entries[newEntryIndex];
+ // Mark this item as handled.
+ newParsed.get(typeName)[idx] = null;
+ } else {
+ deleteLog.push(vCardParsed.get(typeName)[idx].index);
+ }
+ }
+ }
+
+ // Remove entries which have been marked for deletion.
+ for (let deleteIndex of deleteLog.sort((a, b) => a < b)) {
+ vCardProperties.entries.splice(deleteIndex, 1);
+ }
+
+ // Add new entries.
+ for (let typeName of newParsed.keys()) {
+ if (typeName == "version") {
+ continue;
+ }
+ for (let newEntry of newParsed.get(typeName)) {
+ if (newEntry) {
+ vCardProperties.addEntry(
+ newProperties.entries[newEntry.index]
+ );
+ }
+ }
+ }
+
+ // Create a new card with the original UID from the updated vCardProperties.
+ card = VCardUtils.vCardToAbCard(
+ vCardProperties.toVCard(),
+ node.item.UID
+ );
+ }
+
+ // Clone original properties and update custom properties.
+ addProperties(card, updateData, node.item.properties);
+
+ parentNode.item.modifyCard(card);
+ },
+ delete(id) {
+ let node = addressBookCache.findContactById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot delete a contact in a read-only address book"
+ );
+ }
+
+ parentNode.item.deleteCards([node.item]);
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ mailingLists: {
+ list(parentId) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ return addressBookCache.convert(
+ addressBookCache.getMailingLists(parentNode),
+ false
+ );
+ },
+ get(id) {
+ return addressBookCache.convert(
+ addressBookCache.findMailingListById(id),
+ false
+ );
+ },
+ create(parentId, { name, nickName, description }) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot create a mailing list in a read-only address book"
+ );
+ }
+ let mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = name;
+ mailList.listNickName = nickName === null ? "" : nickName;
+ mailList.description = description === null ? "" : description;
+
+ let newMailList = parentNode.item.addMailList(mailList);
+ return newMailList.UID;
+ },
+ update(id, { name, nickName, description }) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot modify a mailing list in a read-only address book"
+ );
+ }
+ node.item.dirName = name;
+ node.item.listNickName = nickName === null ? "" : nickName;
+ node.item.description = description === null ? "" : description;
+ node.item.editMailListToDatabase(null);
+ },
+ delete(id) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot delete a mailing list in a read-only address book"
+ );
+ }
+ parentNode.item.deleteDirectory(node.item);
+ },
+
+ listMembers(id) {
+ let node = addressBookCache.findMailingListById(id);
+ return addressBookCache.convert(
+ addressBookCache.getListContacts(node),
+ false
+ );
+ },
+ addMember(id, contactId) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot add to a mailing list in a read-only address book"
+ );
+ }
+ let contactNode = addressBookCache.findContactById(contactId);
+ node.item.addCard(contactNode.item);
+ },
+ removeMember(id, contactId) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot remove from a mailing list in a read-only address book"
+ );
+ }
+ let contactNode = addressBookCache.findContactById(contactId);
+
+ node.item.deleteCards([contactNode.item]);
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListDeleted",
+ extensionApi: this,
+ }).api(),
+ onMemberAdded: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMemberAdded",
+ extensionApi: this,
+ }).api(),
+ onMemberRemoved: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMemberRemoved",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-browserAction.js b/comm/mail/components/extensions/parent/ext-browserAction.js
new file mode 100644
index 0000000000..de07f9e3a2
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,329 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ storeState: "resource:///modules/CustomizationState.mjs",
+ getState: "resource:///modules/CustomizationState.mjs",
+ registerExtension: "resource:///modules/CustomizableItems.sys.mjs",
+ unregisterExtension: "resource:///modules/CustomizableItems.sys.mjs",
+ EXTENSION_PREFIX: "resource:///modules/CustomizableItems.sys.mjs",
+});
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ToolbarButtonAPI: "resource:///modules/ExtensionToolbarButtons.jsm",
+ getCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+ setCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+});
+
+var { makeWidgetId } = ExtensionCommon;
+
+const browserActionMap = new WeakMap();
+
+this.browserAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return browserActionMap.get(extension);
+ }
+
+ /**
+ * A browser_action can be placed in the unified toolbar of the main window and
+ * in the XUL toolbar of the message window. We conditionally bypass XUL toolbar
+ * behavior by using the following custom method implementations.
+ */
+
+ paint(window) {
+ // Ignore XUL toolbar paint requests for the main window.
+ if (window.location.href != MAIN_WINDOW_URI) {
+ super.paint(window);
+ }
+ }
+
+ unpaint(window) {
+ // Ignore XUL toolbar unpaint requests for the main window.
+ if (window.location.href != MAIN_WINDOW_URI) {
+ super.unpaint(window);
+ }
+ }
+
+ /**
+ * Return the toolbar button if it is currently visible in the given window.
+ *
+ * @param window
+ * @returns {DOMElement} the toolbar button element, or null
+ */
+ getToolbarButton(window) {
+ // Return the visible button from the unified toolbar, if this is the main window.
+ if (window.location.href == MAIN_WINDOW_URI) {
+ let buttonItem = window.document.querySelector(
+ `#unifiedToolbarContent [item-id="ext-${this.extension.id}"]`
+ );
+ return (
+ buttonItem &&
+ !buttonItem.hidden &&
+ window.document.querySelector(
+ `#unifiedToolbarContent [extension="${this.extension.id}"]`
+ )
+ );
+ }
+ return super.getToolbarButton(window);
+ }
+
+ updateButton(button, tabData) {
+ if (button.applyTabData) {
+ // This is an extension-action-button customElement and therefore a button
+ // in the unified toolbar and needs special handling.
+ button.applyTabData(tabData);
+ } else {
+ super.updateButton(button, tabData);
+ }
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ browserActionMap.set(this.extension, this);
+
+ // Check if a browser_action was added to the unified toolbar.
+ if (this.windowURLs.includes(MAIN_WINDOW_URI)) {
+ await registerExtension(this.extension.id, this.allowedSpaces);
+ const currentToolbarState = getState();
+ const unifiedToolbarButtonId = `${EXTENSION_PREFIX}${this.extension.id}`;
+
+ // Load the cached allowed spaces. Make sure there are no awaited promises
+ // before storing the updated allowed spaces, as it could have been changed
+ // elsewhere.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ let priorAllowedSpaces = cachedAllowedSpaces.get(this.extension.id);
+
+ // If the extension has set allowedSpaces to an empty array, the button needs
+ // to be added to all available spaces.
+ let allowedSpaces =
+ this.allowedSpaces.length == 0
+ ? [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default",
+ ]
+ : this.allowedSpaces;
+
+ // Manually add the button to all customized spaces, where it has not been
+ // allowed in the prior version of this add-on (if any). This automatically
+ // covers the install and the update case, including staged updates.
+ // Spaces which have not been customized will receive the button from
+ // getDefaultItemIdsForSpace() in CustomizableItems.sys.mjs.
+ let missingSpacesInState = allowedSpaces.filter(
+ space =>
+ (!priorAllowedSpaces || !priorAllowedSpaces.includes(space)) &&
+ space !== "default" &&
+ currentToolbarState.hasOwnProperty(space) &&
+ !currentToolbarState[space].includes(unifiedToolbarButtonId)
+ );
+ for (const space of missingSpacesInState) {
+ currentToolbarState[space].push(unifiedToolbarButtonId);
+ }
+
+ // Manually remove button from all customized spaces, if it is no longer
+ // allowed. This will remove its stored customized positioning information.
+ // If a space becomes allowed again later, the button will be added to the
+ // end of the space and not at its former customized location.
+ let invalidSpacesInState = [];
+ if (priorAllowedSpaces) {
+ invalidSpacesInState = priorAllowedSpaces.filter(
+ space =>
+ space !== "default" &&
+ !allowedSpaces.includes(space) &&
+ currentToolbarState.hasOwnProperty(space) &&
+ currentToolbarState[space].includes(unifiedToolbarButtonId)
+ );
+ for (const space of invalidSpacesInState) {
+ currentToolbarState[space] = currentToolbarState[space].filter(
+ id => id != unifiedToolbarButtonId
+ );
+ }
+ }
+
+ // Update the cached values for the allowed spaces.
+ cachedAllowedSpaces.set(this.extension.id, allowedSpaces);
+ setCachedAllowedSpaces(cachedAllowedSpaces);
+
+ if (missingSpacesInState.length || invalidSpacesInState.length) {
+ storeState(currentToolbarState);
+ } else {
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+ }
+ }
+ }
+
+ close() {
+ super.close();
+ browserActionMap.delete(this.extension);
+ windowTracker.removeListener("TabSelect", this);
+ // Unregister the extension from the unified toolbar.
+ if (this.windowURLs.includes(MAIN_WINDOW_URI)) {
+ unregisterExtension(this.extension.id);
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+ }
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name =
+ extension.manifestVersion < 3 ? "browser_action" : "action";
+ this.manifestName =
+ extension.manifestVersion < 3 ? "browserAction" : "action";
+ this.manifest = extension.manifest[this.manifest_name];
+ // browserAction was renamed to action in MV3, but its module name is
+ // still "browserAction" because that is the name used in ext-mail.json,
+ // independently from the manifest version.
+ this.moduleName = "browserAction";
+
+ this.windowURLs = [];
+ if (this.manifest.default_windows.includes("normal")) {
+ this.windowURLs.push(MAIN_WINDOW_URI);
+ }
+ if (this.manifest.default_windows.includes("messageDisplay")) {
+ this.windowURLs.push(MESSAGE_WINDOW_URI);
+ }
+
+ this.toolboxId = "mail-toolbox";
+ this.toolbarId = "mail-bar3";
+
+ this.allowedSpaces =
+ this.extension.manifest[this.manifest_name].allowed_spaces;
+
+ windowTracker.addListener("TabSelect", this);
+ }
+
+ static onUpdate(extensionId, manifest) {
+ // These manifest entries can exist and be null.
+ if (!manifest.browser_action && !manifest.action) {
+ this.#removeFromUnifiedToolbar(extensionId);
+ }
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-browserAction-toolbarbutton`;
+
+ // Check all possible XUL toolbars and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let toolbars = ["mail-bar3", "toolbar-menubar"];
+ for (let toolbar of toolbars) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(MESSAGE_WINDOW_URI, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ MESSAGE_WINDOW_URI,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+
+ this.#removeFromUnifiedToolbar(extensionId);
+ }
+
+ static #removeFromUnifiedToolbar(extensionId) {
+ const currentToolbarState = getState();
+ const unifiedToolbarButtonId = `${EXTENSION_PREFIX}${extensionId}`;
+ let modifiedState = false;
+ for (const space of Object.keys(currentToolbarState)) {
+ if (currentToolbarState[space].includes(unifiedToolbarButtonId)) {
+ currentToolbarState[space].splice(
+ currentToolbarState[space].indexOf(unifiedToolbarButtonId),
+ 1
+ );
+ modifiedState = true;
+ }
+ }
+ if (modifiedState) {
+ storeState(currentToolbarState);
+ }
+
+ // Update cachedAllowedSpaces for the unified toolbar.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ if (cachedAllowedSpaces.has(extensionId)) {
+ cachedAllowedSpaces.delete(extensionId);
+ setCachedAllowedSpaces(cachedAllowedSpaces);
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ // This needs to work in normal window and message window.
+ let tab = tabTracker.activeTab;
+ let browser = tab.linkedBrowser || tab.getBrowser?.();
+
+ const trigger = menu.triggerNode;
+ const node =
+ window.document.getElementById(this.id) ||
+ (this.windowURLs.includes(MAIN_WINDOW_URI) &&
+ window.document.querySelector(
+ `#unifiedToolbarContent [item-id="${EXTENSION_PREFIX}${this.extension.id}"]`
+ ));
+ const contexts = [
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ "unifiedToolbarMenu",
+ ];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ const action =
+ this.extension.manifestVersion < 3 ? "onBrowserAction" : "onAction";
+ global.actionContextMenu({
+ tab,
+ pageUrl: browser?.currentURI?.spec,
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == this.manifestName &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ const action =
+ this.extension.manifestVersion < 3
+ ? "inBrowserActionMenu"
+ : "inActionMenu";
+ global.actionContextMenu({
+ tab,
+ pageUrl: browser?.currentURI?.spec,
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+};
+
+global.browserActionFor = this.browserAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js b/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js
new file mode 100644
index 0000000000..9f3d624b76
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -0,0 +1,365 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global searchInitialized */
+
+// Copy of browser/components/extensions/parent/ext-chrome-settings-overrides.js
+// minus HomePage.jsm (+ dependent ExtensionControlledPopup.sys.mjs and
+// ExtensionPermissions.jsm usage).
+
+"use strict";
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+const DEFAULT_SEARCH_STORE_TYPE = "default_search";
+const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
+const ENGINE_ADDED_SETTING_NAME = "engineAdded";
+
+// When an extension starts up, a search engine may asynchronously be
+// registered, without blocking the startup. When an extension is
+// uninstalled, we need to wait for this registration to finish
+// before running the uninstallation handler.
+// Map[extension id -> Promise]
+var pendingSearchSetupTasks = new Map();
+
+this.chrome_settings_overrides = class extends ExtensionAPI {
+ static async processDefaultSearchSetting(action, id) {
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ id
+ );
+ if (!item) {
+ return;
+ }
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ item = ExtensionSettingsStore[action](
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ if (item && control == "controlled_by_this_extension") {
+ try {
+ let engine = Services.search.getEngineByName(
+ item.value || item.initialValue
+ );
+ if (engine) {
+ await Services.search.setDefault(
+ engine,
+ action == "enable"
+ ? Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ : Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ static async removeEngine(id) {
+ try {
+ await Services.search.removeWebExtensionEngine(id);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ static removeSearchSettings(id) {
+ return Promise.all([
+ this.processDefaultSearchSetting("removeSetting", id),
+ this.removeEngine(id),
+ ]);
+ }
+
+ static async onUninstall(id) {
+ let searchStartupPromise = pendingSearchSetupTasks.get(id);
+ if (searchStartupPromise) {
+ await searchStartupPromise.catch(console.error);
+ }
+ // Note: We do not have to deal with homepage here as it is managed by
+ // the ExtensionPreferencesManager.
+ return Promise.all([this.removeSearchSettings(id)]);
+ }
+
+ static async onUpdate(id, manifest) {
+ let search_provider = manifest?.chrome_settings_overrides?.search_provider;
+
+ if (!search_provider) {
+ // Remove setting and engine from search if necessary.
+ this.removeSearchSettings(id);
+ } else if (!search_provider.is_default) {
+ // Remove the setting, but keep the engine in search.
+ chrome_settings_overrides.processDefaultSearchSetting(
+ "removeSetting",
+ id
+ );
+ }
+ }
+
+ static async onDisable(id) {
+ await chrome_settings_overrides.processDefaultSearchSetting("disable", id);
+ await chrome_settings_overrides.removeEngine(id);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+ if (manifest.chrome_settings_overrides.search_provider) {
+ // Registering a search engine can potentially take a long while,
+ // or not complete at all (when searchInitialized is never resolved),
+ // so we are deliberately not awaiting the returned promise here.
+ let searchStartupPromise =
+ this.processSearchProviderManifestEntry().finally(() => {
+ if (
+ pendingSearchSetupTasks.get(extension.id) === searchStartupPromise
+ ) {
+ pendingSearchSetupTasks.delete(extension.id);
+ // This is primarily for tests so that we know when an extension
+ // has finished initialising.
+ ExtensionParent.apiManager.emit("searchEngineProcessed", extension);
+ }
+ });
+
+ // Save the promise so we can await at onUninstall.
+ pendingSearchSetupTasks.set(extension.id, searchStartupPromise);
+ }
+ }
+
+ async ensureSetting(engineName, disable = false) {
+ let { extension } = this;
+ // Ensure the addon always has a setting
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ extension.id
+ );
+ if (!item) {
+ let defaultEngine = await Services.search.getDefault();
+ item = await ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ engineName,
+ () => defaultEngine.name
+ );
+ // If there was no setting, we're fixing old behavior in this api.
+ // A lack of a setting would mean it was disabled before, disable it now.
+ disable =
+ disable ||
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ );
+ }
+
+ // Ensure the item is disabled (either if exists and is not default or if it does not
+ // exist yet).
+ if (disable) {
+ item = await ExtensionSettingsStore.disable(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ return item;
+ }
+
+ async promptDefaultSearch(engineName) {
+ let { extension } = this;
+ // Don't ask if it is already the current engine
+ let engine = Services.search.getEngineByName(engineName);
+ let defaultEngine = await Services.search.getDefault();
+ if (defaultEngine.name == engine.name) {
+ return;
+ }
+ // Ensures the setting exists and is disabled. If the
+ // user somehow bypasses the prompt, we do not want this
+ // setting enabled for this extension.
+ await this.ensureSetting(engineName, true);
+
+ let subject = {
+ wrappedJSObject: {
+ // This is a hack because we don't have the browser of
+ // the actual install. This means the popup might show
+ // in a different window. Will be addressed in a followup bug.
+ // As well, we still notify if no topWindow exists to support
+ // testing from xpcshell.
+ browser: windowTracker.topWindow?.gBrowser.selectedBrowser,
+ id: extension.id,
+ name: extension.name,
+ icon: extension.iconURL,
+ currentEngine: defaultEngine.name,
+ newEngine: engineName,
+ async respond(allow) {
+ if (allow) {
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ }
+ // For testing
+ Services.obs.notifyObservers(
+ null,
+ "webextension-defaultsearch-prompt-response"
+ );
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt");
+ }
+
+ async processSearchProviderManifestEntry() {
+ let { extension } = this;
+ let { manifest } = extension;
+ let searchProvider = manifest.chrome_settings_overrides.search_provider;
+
+ // If we're not being requested to be set as default, then all we need
+ // to do is to add the engine to the service. The search service can cope
+ // with receiving added engines before it is initialised, so we don't have
+ // to wait for it. Search Service will also prevent overriding a builtin
+ // engine appropriately.
+ if (!searchProvider.is_default) {
+ await this.addSearchEngine();
+ return;
+ }
+
+ await searchInitialized;
+ if (!this.extension) {
+ console.error(
+ `Extension shut down before search provider was registered`
+ );
+ return;
+ }
+
+ let engineName = searchProvider.name.trim();
+ let result = await Services.search.maybeSetAndOverrideDefault(extension);
+ // This will only be set to true when the specified engine is an app-provided
+ // engine, or when it is an allowed add-on defined in the list stored in
+ // SearchDefaultOverrideAllowlistHandler.
+ if (result.canChangeToAppProvided) {
+ await this.setDefault(engineName, true);
+ }
+ if (!result.canInstallEngine) {
+ // This extension is overriding an app-provided one, so we don't
+ // add its engine as well.
+ return;
+ }
+ await this.addSearchEngine();
+ if (extension.startupReason === "ADDON_INSTALL") {
+ await this.promptDefaultSearch(engineName);
+ } else {
+ // Needs to be called every time to handle reenabling.
+ await this.setDefault(engineName);
+ }
+ }
+
+ async setDefault(engineName, skipEnablePrompt = false) {
+ let { extension } = this;
+ if (extension.startupReason === "ADDON_INSTALL") {
+ // We should only get here if an extension is setting an app-provided
+ // engine to default and we are ignoring the addons other engine settings.
+ // In this case we do not show the prompt to the user.
+ let item = await this.ensureSetting(engineName);
+ await Services.search.setDefault(
+ Services.search.getEngineByName(item.value),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ )
+ ) {
+ // We would be called for every extension being enabled, we should verify
+ // that it has control and only then set it as default
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+
+ // Check for an inconsistency between the value returned by getLevelOfcontrol
+ // and the current engine actually set.
+ if (
+ control === "controlled_by_this_extension" &&
+ Services.search.defaultEngine.name !== engineName
+ ) {
+ // Check for and fix any inconsistency between the extensions settings storage
+ // and the current engine actually set. If settings claims the extension is default
+ // but the search service claims otherwise, select what the search service claims
+ // (See Bug 1767550).
+ const allSettings = ExtensionSettingsStore.getAllSettings(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ for (const setting of allSettings) {
+ if (setting.value !== Services.search.defaultEngine.name) {
+ await ExtensionSettingsStore.disable(
+ setting.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ }
+ control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+
+ if (control === "controlled_by_this_extension") {
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (control === "controllable_by_this_extension") {
+ if (skipEnablePrompt) {
+ // For overriding app-provided engines, we don't prompt, so set
+ // the default straight away.
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (extension.startupReason == "ADDON_ENABLE") {
+ // This extension has precedence, but is not in control. Ask the user.
+ await this.promptDefaultSearch(engineName);
+ }
+ }
+ }
+ }
+
+ async addSearchEngine() {
+ let { extension } = this;
+ try {
+ await Services.search.addEnginesFromExtension(extension);
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ return true;
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-cloudFile.js b/comm/mail/components/extensions/parent/ext-cloudFile.js
new file mode 100644
index 0000000000..74193d8d14
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-cloudFile.js
@@ -0,0 +1,804 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "FileReader"]);
+
+async function promiseFileRead(nsifile) {
+ let blob = await File.createFromNsIFile(nsifile);
+
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("loadend", event => {
+ if (event.target.error) {
+ reject(event.target.error);
+ } else {
+ resolve(event.target.result);
+ }
+ });
+
+ reader.readAsArrayBuffer(blob);
+ });
+}
+
+class CloudFileAccount {
+ constructor(accountKey, extension) {
+ this.accountKey = accountKey;
+ this.extension = extension;
+ this._configured = false;
+ this.lastError = "";
+ this.managementURL = this.extension.manifest.cloud_file.management_url;
+ this.reuseUploads = this.extension.manifest.cloud_file.reuse_uploads;
+ this.browserStyle = this.extension.manifest.cloud_file.browser_style;
+ this.quota = {
+ uploadSizeLimit: -1,
+ spaceRemaining: -1,
+ spaceUsed: -1,
+ };
+
+ this._nextId = 1;
+ this._uploads = new Map();
+ }
+
+ get type() {
+ return `ext-${this.extension.id}`;
+ }
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ this.extension.manifest.cloud_file.name
+ );
+ }
+ get iconURL() {
+ if (this.extension.manifest.icons) {
+ let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+ this.extension.manifest.icons,
+ this.extension,
+ 32
+ );
+ return this.extension.baseURI.resolve(icon);
+ }
+ return "chrome://messenger/content/extension.svg";
+ }
+ get fileUploadSizeLimit() {
+ return this.quota.uploadSizeLimit;
+ }
+ get remainingFileSpace() {
+ return this.quota.spaceRemaining;
+ }
+ get fileSpaceUsed() {
+ return this.quota.spaceUsed;
+ }
+ get configured() {
+ return this._configured;
+ }
+ set configured(value) {
+ value = !!value;
+ if (value != this._configured) {
+ this._configured = value;
+ cloudFileAccounts.emit("accountConfigured", this);
+ }
+ }
+ get createNewAccountUrl() {
+ return this.extension.manifest.cloud_file.new_account_url;
+ }
+
+ /**
+ * @typedef CloudFileDate
+ * @property {integer} timestamp - milliseconds since epoch
+ * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat
+ */
+
+ /**
+ * @typedef CloudFileUpload
+ * // Values used in the WebExtension CloudFile type.
+ * @property {string} id - uploadId of the file
+ * @property {string} name - name of the file
+ * @property {string} url - url of the uploaded file
+ * // Properties of the local file.
+ * @property {string} path - path of the local file
+ * @property {string} size - size of the local file
+ * // Template information.
+ * @property {string} serviceName - name of the upload service provider
+ * @property {string} serviceIcon - icon of the upload service provider
+ * @property {string} serviceUrl - web interface of the upload service provider
+ * @property {boolean} downloadPasswordProtected - link is password protected
+ * @property {integer} downloadLimit - download limit of the link
+ * @property {CloudFileDate} downloadExpiryDate - expiry date of the link
+ * // Usage tracking.
+ * @property {boolean} immutable - if the cloud file url may be changed
+ */
+
+ /**
+ * Marks the specified upload as immutable.
+ *
+ * @param {integer} id - id of the upload
+ */
+ markAsImmutable(id) {
+ if (this._uploads.has(id)) {
+ let upload = this._uploads.get(id);
+ upload.immutable = true;
+ this._uploads.set(id, upload);
+ }
+ }
+
+ /**
+ * Returns a new upload entry, based on the provided file and data.
+ *
+ * @param {nsIFile} file
+ * @param {CloudFileUpload} data
+ * @returns {CloudFileUpload}
+ */
+ newUploadForFile(file, data = {}) {
+ let id = this._nextId++;
+ let upload = {
+ // Values used in the WebExtension CloudFile type.
+ id,
+ name: data.name ?? file.leafName,
+ url: data.url ?? null,
+ // Properties of the local file.
+ path: file.path,
+ size: file.exists() ? file.fileSize : data.size || 0,
+ // Template information.
+ serviceName: data.serviceName ?? this.displayName,
+ serviceIcon: data.serviceIcon ?? this.iconURL,
+ serviceUrl: data.serviceUrl ?? "",
+ downloadPasswordProtected: data.downloadPasswordProtected ?? false,
+ downloadLimit: data.downloadLimit ?? 0,
+ downloadExpiryDate: data.downloadExpiryDate ?? null,
+ // Usage tracking.
+ immutable: data.immutable ?? false,
+ };
+
+ this._uploads.set(id, upload);
+ return upload;
+ }
+
+ /**
+ * Initiate a WebExtension cloudFile upload by preparing a CloudFile object &
+ * and triggering an onFileUpload event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {nsIFile} file File to be uploaded.
+ * @param {string} [name] Name of the file after it has been uploaded. Defaults
+ * to the original filename of the uploaded file.
+ * @param {CloudFileUpload} relatedCloudFileUpload Information about an already
+ * uploaded file this upload is related to, e.g. renaming a repeatedly used
+ * cloud file or updating the content of a cloud file.
+ * @returns {CloudFileUpload} Information about the uploaded file.
+ */
+ async uploadFile(window, file, name = file.leafName, relatedCloudFileUpload) {
+ let data = await File.createFromNsIFile(file);
+
+ if (
+ this.remainingFileSpace != -1 &&
+ file.fileSize > this.remainingFileSpace
+ ) {
+ throw Components.Exception(
+ `Quota error: Can't upload file. Only ${this.remainingFileSpace}KB left of quota.`,
+ cloudFileAccounts.constants.uploadWouldExceedQuota
+ );
+ }
+
+ if (
+ this.fileUploadSizeLimit != -1 &&
+ file.fileSize > this.fileUploadSizeLimit
+ ) {
+ throw Components.Exception(
+ `Upload error: File size is ${file.fileSize}KB and exceeds the file size limit of ${this.fileUploadSizeLimit}KB`,
+ cloudFileAccounts.constants.uploadExceedsFileLimit
+ );
+ }
+
+ let upload = this.newUploadForFile(file, { name });
+ let id = upload.id;
+ let relatedFileInfo;
+ if (relatedCloudFileUpload) {
+ relatedFileInfo = {
+ id: relatedCloudFileUpload.id,
+ name: relatedCloudFileUpload.name,
+ url: relatedCloudFileUpload.url,
+ templateInfo: relatedCloudFileUpload.templateInfo,
+ dataChanged: relatedCloudFileUpload.path != upload.path,
+ };
+ }
+
+ let results;
+ try {
+ results = await this.extension.emit(
+ "uploadFile",
+ this,
+ { id, name, data },
+ window,
+ relatedFileInfo
+ );
+ } catch (ex) {
+ this._uploads.delete(id);
+ if (ex.result == 0x80530014) {
+ // NS_ERROR_DOM_ABORT_ERR
+ throw Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ );
+ } else {
+ throw Components.Exception(
+ `Upload error: ${ex.message}`,
+ cloudFileAccounts.constants.uploadErr
+ );
+ }
+ }
+
+ if (
+ results &&
+ results.length > 0 &&
+ results[0] &&
+ (results[0].aborted || results[0].url || results[0].error)
+ ) {
+ if (results[0].error) {
+ this._uploads.delete(id);
+ if (typeof results[0].error == "boolean") {
+ throw Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ );
+ } else {
+ throw Components.Exception(
+ results[0].error,
+ cloudFileAccounts.constants.uploadErrWithCustomMessage
+ );
+ }
+ }
+
+ if (results[0].aborted) {
+ this._uploads.delete(id);
+ throw Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ );
+ }
+
+ if (results[0].templateInfo) {
+ upload.templateInfo = results[0].templateInfo;
+
+ if (results[0].templateInfo.service_name) {
+ upload.serviceName = results[0].templateInfo.service_name;
+ }
+ if (results[0].templateInfo.service_icon) {
+ upload.serviceIcon = this.extension.baseURI.resolve(
+ results[0].templateInfo.service_icon
+ );
+ }
+ if (results[0].templateInfo.service_url) {
+ upload.serviceUrl = results[0].templateInfo.service_url;
+ }
+ if (results[0].templateInfo.download_password_protected) {
+ upload.downloadPasswordProtected =
+ results[0].templateInfo.download_password_protected;
+ }
+ if (results[0].templateInfo.download_limit) {
+ upload.downloadLimit = results[0].templateInfo.download_limit;
+ }
+ if (results[0].templateInfo.download_expiry_date) {
+ // Event return value types are not checked by the WebExtension framework,
+ // manual verification is required.
+ if (
+ results[0].templateInfo.download_expiry_date.timestamp &&
+ Number.isInteger(
+ results[0].templateInfo.download_expiry_date.timestamp
+ )
+ ) {
+ upload.downloadExpiryDate =
+ results[0].templateInfo.download_expiry_date;
+ } else {
+ console.warn(
+ "Invalid CloudFileTemplateInfo.download_expiry_date object, the timestamp property is required and it must be of type integer."
+ );
+ }
+ }
+ }
+
+ upload.url = results[0].url;
+
+ return { ...upload };
+ }
+
+ this._uploads.delete(id);
+ throw Components.Exception(
+ `Upload error: Missing cloudFile.onFileUpload listener for ${this.extension.id} (or it is not returning url or aborted)`,
+ cloudFileAccounts.constants.uploadErr
+ );
+ }
+
+ /**
+ * Checks if the url of the given upload has been used already.
+ *
+ * @param {CloudFileUpload} cloudFileUpload
+ */
+ isReusedUpload(cloudFileUpload) {
+ if (!cloudFileUpload) {
+ return false;
+ }
+
+ // Find matching url in known uploads and check if it is immutable.
+ let isImmutableUrl = url => {
+ return [...this._uploads.values()].some(u => u.immutable && u.url == url);
+ };
+
+ // Check all open windows if the url is used elsewhere.
+ let isDuplicateUrl = url => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ if (composeWindows.length == 0) {
+ return false;
+ }
+ let countsPerWindow = composeWindows.map(window => {
+ let bucket = window.document.getElementById("attachmentBucket");
+ if (!bucket) {
+ return 0;
+ }
+ return [...bucket.childNodes].filter(
+ node => node.attachment.contentLocation == url
+ ).length;
+ });
+
+ return countsPerWindow.reduce((prev, curr) => prev + curr) > 1;
+ };
+
+ return (
+ isImmutableUrl(cloudFileUpload.url) || isDuplicateUrl(cloudFileUpload.url)
+ );
+ }
+
+ /**
+ * Initiate a WebExtension cloudFile rename by triggering an onFileRename event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {Integer} uploadId Id of the uploaded file.
+ * @param {string} newName The requested new name of the file.
+ * @returns {CloudFileUpload} Information about the renamed file.
+ */
+ async renameFile(window, uploadId, newName) {
+ if (!this._uploads.has(uploadId)) {
+ throw Components.Exception(
+ "Rename error.",
+ cloudFileAccounts.constants.renameErr
+ );
+ }
+
+ let upload = this._uploads.get(uploadId);
+ let results;
+ try {
+ results = await this.extension.emit(
+ "renameFile",
+ this,
+ uploadId,
+ newName,
+ window
+ );
+ } catch (ex) {
+ throw Components.Exception(
+ `Rename error: ${ex.message}`,
+ cloudFileAccounts.constants.renameErr
+ );
+ }
+
+ if (!results || results.length == 0) {
+ throw Components.Exception(
+ `Rename error: Missing cloudFile.onFileRename listener for ${this.extension.id}`,
+ cloudFileAccounts.constants.renameNotSupported
+ );
+ }
+
+ if (results[0]) {
+ if (results[0].error) {
+ if (typeof results[0].error == "boolean") {
+ throw Components.Exception(
+ "Rename error.",
+ cloudFileAccounts.constants.renameErr
+ );
+ } else {
+ throw Components.Exception(
+ results[0].error,
+ cloudFileAccounts.constants.renameErrWithCustomMessage
+ );
+ }
+ }
+
+ if (results[0].url) {
+ upload.url = results[0].url;
+ }
+ }
+
+ upload.name = newName;
+ return upload;
+ }
+
+ urlForFile(uploadId) {
+ return this._uploads.get(uploadId).url;
+ }
+
+ /**
+ * Cancel a WebExtension cloudFile upload by triggering an onFileUploadAbort
+ * event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {nsIFile} file File to be uploaded.
+ */
+ async cancelFileUpload(window, file) {
+ let path = file.path;
+ let uploadId = -1;
+ for (let upload of this._uploads.values()) {
+ if (!upload.url && upload.path == path) {
+ uploadId = upload.id;
+ break;
+ }
+ }
+
+ if (uploadId == -1) {
+ console.error(`No upload in progress for file ${file.path}`);
+ return false;
+ }
+
+ let result = await this.extension.emit(
+ "uploadAbort",
+ this,
+ uploadId,
+ window
+ );
+ if (result && result.length > 0) {
+ return true;
+ }
+
+ console.error(
+ `Missing cloudFile.onFileUploadAbort listener for ${this.extension.id}`
+ );
+ return false;
+ }
+
+ getPreviousUploads() {
+ return [...this._uploads.values()].map(u => {
+ return { ...u };
+ });
+ }
+
+ /**
+ * Delete a WebExtension cloudFile upload by triggering an onFileDeleted event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {Integer} uploadId Id of the uploaded file.
+ */
+ async deleteFile(window, uploadId) {
+ if (!this.extension.emitter.has("deleteFile")) {
+ throw Components.Exception(
+ `Delete error: Missing cloudFile.onFileDeleted listener for ${this.extension.id}`,
+ cloudFileAccounts.constants.deleteErr
+ );
+ }
+
+ try {
+ if (this._uploads.has(uploadId)) {
+ let upload = this._uploads.get(uploadId);
+ if (!this.isReusedUpload(upload)) {
+ await this.extension.emit("deleteFile", this, uploadId, window);
+ this._uploads.delete(uploadId);
+ }
+ }
+ } catch (ex) {
+ throw Components.Exception(
+ `Delete error: ${ex.message}`,
+ cloudFileAccounts.constants.deleteErr
+ );
+ }
+ }
+}
+
+function convertCloudFileAccount(nativeAccount) {
+ return {
+ id: nativeAccount.accountKey,
+ name: nativeAccount.displayName,
+ configured: nativeAccount.configured,
+ uploadSizeLimit: nativeAccount.fileUploadSizeLimit,
+ spaceRemaining: nativeAccount.remainingFileSpace,
+ spaceUsed: nativeAccount.fileSpaceUsed,
+ managementUrl: nativeAccount.managementURL,
+ };
+}
+
+this.cloudFile = class extends ExtensionAPIPersistent {
+ get providerType() {
+ return `ext-${this.extension.id}`;
+ }
+
+ onManifestEntry(entryName) {
+ if (entryName == "cloud_file") {
+ let { extension } = this;
+ cloudFileAccounts.registerProvider(this.providerType, {
+ type: this.providerType,
+ displayName: extension.manifest.cloud_file.name,
+ get iconURL() {
+ if (extension.manifest.icons) {
+ let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return extension.baseURI.resolve(icon);
+ }
+ return "chrome://messenger/content/extension.svg";
+ },
+ initAccount(accountKey) {
+ return new CloudFileAccount(accountKey, extension);
+ },
+ });
+ }
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ cloudFileAccounts.unregisterProvider(this.providerType);
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onFileUpload({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(
+ _event,
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, { id, name, data }, tab, relatedFileInfo);
+ }
+ extension.on("uploadFile", listener);
+ return {
+ unregister: () => {
+ extension.off("uploadFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileUploadAbort({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, tab);
+ }
+ extension.on("uploadAbort", listener);
+ return {
+ unregister: () => {
+ extension.off("uploadAbort", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileRename({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, newName, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, newName, tab);
+ }
+ extension.on("renameFile", listener);
+ return {
+ unregister: () => {
+ extension.off("renameFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileDeleted({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, tab);
+ }
+ extension.on("deleteFile", listener);
+ return {
+ unregister: () => {
+ extension.off("deleteFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onAccountAdded({ context, fire }) {
+ const self = this;
+ async function listener(_event, nativeAccount) {
+ if (nativeAccount.type != self.providerType) {
+ return null;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(convertCloudFileAccount(nativeAccount));
+ }
+ cloudFileAccounts.on("accountAdded", listener);
+ return {
+ unregister: () => {
+ cloudFileAccounts.off("accountAdded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onAccountDeleted({ context, fire }) {
+ const self = this;
+ async function listener(_event, key, type) {
+ if (self.providerType != type) {
+ return null;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(key);
+ }
+ cloudFileAccounts.on("accountDeleted", listener);
+ return {
+ unregister: () => {
+ cloudFileAccounts.off("accountDeleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let self = this;
+
+ return {
+ cloudFile: {
+ onFileUpload: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileUpload",
+ extensionApi: this,
+ }).api(),
+
+ onFileUploadAbort: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileUploadAbort",
+ extensionApi: this,
+ }).api(),
+
+ onFileRename: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileRename",
+ extensionApi: this,
+ }).api(),
+
+ onFileDeleted: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileDeleted",
+ extensionApi: this,
+ }).api(),
+
+ onAccountAdded: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onAccountAdded",
+ extensionApi: this,
+ }).api(),
+
+ onAccountDeleted: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onAccountDeleted",
+ extensionApi: this,
+ }).api(),
+
+ async getAccount(accountId) {
+ let account = cloudFileAccounts.getAccount(accountId);
+
+ if (!account || account.type != self.providerType) {
+ return undefined;
+ }
+
+ return convertCloudFileAccount(account);
+ },
+
+ async getAllAccounts() {
+ return cloudFileAccounts
+ .getAccountsForType(self.providerType)
+ .map(convertCloudFileAccount);
+ },
+
+ async updateAccount(accountId, updateProperties) {
+ let account = cloudFileAccounts.getAccount(accountId);
+
+ if (!account || account.type != self.providerType) {
+ return undefined;
+ }
+ if (updateProperties.configured !== null) {
+ account.configured = updateProperties.configured;
+ }
+ if (updateProperties.uploadSizeLimit !== null) {
+ account.quota.uploadSizeLimit = updateProperties.uploadSizeLimit;
+ }
+ if (updateProperties.spaceRemaining !== null) {
+ account.quota.spaceRemaining = updateProperties.spaceRemaining;
+ }
+ if (updateProperties.spaceUsed !== null) {
+ account.quota.spaceUsed = updateProperties.spaceUsed;
+ }
+ if (updateProperties.managementUrl !== null) {
+ account.managementURL = updateProperties.managementUrl;
+ }
+
+ return convertCloudFileAccount(account);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-commands.js b/comm/mail/components/extensions/parent/ext-commands.js
new file mode 100644
index 0000000000..309793b7fa
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-commands.js
@@ -0,0 +1,103 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailExtensionShortcuts",
+ "resource:///modules/MailExtensionShortcuts.jsm"
+);
+
+this.commands = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCommand({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(eventName, commandName) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let tab = tabManager.convert(tabTracker.activeTab);
+ fire.async(commandName, tab);
+ }
+ this.on("command", listener);
+ return {
+ unregister: () => {
+ this.off("command", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ onChanged({ context, fire }) {
+ async function listener(eventName, changeInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(changeInfo);
+ }
+ this.on("shortcutChanged", listener);
+ return {
+ unregister: () => {
+ this.off("shortcutChanged", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ static onUninstall(extensionId) {
+ return MailExtensionShortcuts.removeCommandsFromStorage(extensionId);
+ }
+
+ async onManifestEntry(entryName) {
+ let shortcuts = new MailExtensionShortcuts({
+ extension: this.extension,
+ onCommand: name => this.emit("command", name),
+ onShortcutChanged: changeInfo => this.emit("shortcutChanged", changeInfo),
+ });
+ this.extension.shortcuts = shortcuts;
+ await shortcuts.loadCommands();
+ await shortcuts.register();
+ }
+
+ onShutdown() {
+ this.extension.shortcuts.unregister();
+ }
+
+ getAPI(context) {
+ return {
+ commands: {
+ getAll: () => this.extension.shortcuts.allCommands(),
+ update: args => this.extension.shortcuts.updateCommand(args),
+ reset: name => this.extension.shortcuts.resetCommand(name),
+ onCommand: new EventManager({
+ context,
+ module: "commands",
+ event: "onCommand",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onChanged: new EventManager({
+ context,
+ module: "commands",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-compose.js b/comm/mail/components/extensions/parent/ext-compose.js
new file mode 100644
index 0000000000..33a52c5e08
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-compose.js
@@ -0,0 +1,1703 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["IOUtils", "PathUtils"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+let { MsgUtils } = ChromeUtils.import(
+ "resource:///modules/MimeMessageUtils.jsm"
+);
+let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File"]);
+
+const deliveryFormats = [
+ { id: Ci.nsIMsgCompSendFormat.Auto, value: "auto" },
+ { id: Ci.nsIMsgCompSendFormat.PlainText, value: "plaintext" },
+ { id: Ci.nsIMsgCompSendFormat.HTML, value: "html" },
+ { id: Ci.nsIMsgCompSendFormat.Both, value: "both" },
+];
+
+async function parseComposeRecipientList(
+ list,
+ requireSingleValidEmail = false
+) {
+ if (!list) {
+ return list;
+ }
+
+ function isValidAddress(address) {
+ return address.includes("@", 1) && !address.endsWith("@");
+ }
+
+ // A ComposeRecipientList could be just a single ComposeRecipient.
+ if (!Array.isArray(list)) {
+ list = [list];
+ }
+
+ let recipients = [];
+ for (let recipient of list) {
+ if (typeof recipient == "string") {
+ let addressObjects =
+ MailServices.headerParser.makeFromDisplayAddress(recipient);
+
+ for (let ao of addressObjects) {
+ if (requireSingleValidEmail && !isValidAddress(ao.email)) {
+ throw new ExtensionError(`Invalid address: ${ao.email}`);
+ }
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(ao.name, ao.email)
+ );
+ }
+ continue;
+ }
+ if (!("addressBookCache" in this)) {
+ await extensions.asyncLoadModule("addressBook");
+ }
+ if (recipient.type == "contact") {
+ let contactNode = this.addressBookCache.findContactById(recipient.id);
+
+ if (
+ requireSingleValidEmail &&
+ !isValidAddress(contactNode.item.primaryEmail)
+ ) {
+ throw new ExtensionError(
+ `Contact does not have a valid email address: ${recipient.id}`
+ );
+ }
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(
+ contactNode.item.displayName,
+ contactNode.item.primaryEmail
+ )
+ );
+ } else {
+ if (requireSingleValidEmail) {
+ throw new ExtensionError("Mailing list not allowed.");
+ }
+
+ let mailingListNode = this.addressBookCache.findMailingListById(
+ recipient.id
+ );
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(
+ mailingListNode.item.dirName,
+ mailingListNode.item.description || mailingListNode.item.dirName
+ )
+ );
+ }
+ }
+ if (requireSingleValidEmail && recipients.length != 1) {
+ throw new ExtensionError(
+ `Exactly one address instead of ${recipients.length} is required.`
+ );
+ }
+ return recipients.join(",");
+}
+
+function composeWindowIsReady(composeWindow) {
+ return new Promise(resolve => {
+ if (composeWindow.composeEditorReady) {
+ resolve();
+ return;
+ }
+ composeWindow.addEventListener("compose-editor-ready", resolve, {
+ once: true,
+ });
+ });
+}
+
+async function openComposeWindow(relatedMessageId, type, details, extension) {
+ let format = Ci.nsIMsgCompFormat.Default;
+ let identity = null;
+
+ if (details) {
+ if (details.isPlainText != null) {
+ format = details.isPlainText
+ ? Ci.nsIMsgCompFormat.PlainText
+ : Ci.nsIMsgCompFormat.HTML;
+ } else {
+ // If none or both of details.body and details.plainTextBody are given, the
+ // default compose format will be used.
+ if (details.body != null && details.plainTextBody == null) {
+ format = Ci.nsIMsgCompFormat.HTML;
+ }
+ if (details.plainTextBody != null && details.body == null) {
+ format = Ci.nsIMsgCompFormat.PlainText;
+ }
+ }
+
+ if (details.identityId != null) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Using identities requires the "accountsRead" permission'
+ );
+ }
+
+ identity = MailServices.accounts.allIdentities.find(
+ i => i.key == details.identityId
+ );
+ if (!identity) {
+ throw new ExtensionError(`Identity not found: ${details.identityId}`);
+ }
+ }
+ }
+
+ // ForwardInline is totally broken, see bug 1513824. Fake it 'til we make it.
+ if (
+ [
+ Ci.nsIMsgCompType.ForwardInline,
+ Ci.nsIMsgCompType.Redirect,
+ Ci.nsIMsgCompType.EditAsNew,
+ Ci.nsIMsgCompType.Template,
+ ].includes(type)
+ ) {
+ let msgHdr = null;
+ let msgURI = null;
+ if (relatedMessageId) {
+ msgHdr = messageTracker.getMessage(relatedMessageId);
+ msgURI = msgHdr.folder.getUriForMsg(msgHdr);
+ }
+
+ // For the types in this code path, OpenComposeWindow only uses
+ // nsIMsgCompFormat.Default or OppositeOfDefault. Check which is needed.
+ // See https://hg.mozilla.org/comm-central/file/592fb5c396ebbb75d4acd1f1287a26f56f4164b3/mailnews/compose/src/nsMsgComposeService.cpp#l395
+ if (format != Ci.nsIMsgCompFormat.Default) {
+ // The mimeConverter used in this code path is not setting any format but
+ // defaults to plaintext if no identity and also no default account is set.
+ // The "mail.identity.default.compose_html" preference is NOT used.
+ let usedIdentity =
+ identity || MailServices.accounts.defaultAccount?.defaultIdentity;
+ let defaultFormat = usedIdentity?.composeHtml
+ ? Ci.nsIMsgCompFormat.HTML
+ : Ci.nsIMsgCompFormat.PlainText;
+ format =
+ format == defaultFormat
+ ? Ci.nsIMsgCompFormat.Default
+ : Ci.nsIMsgCompFormat.OppositeOfDefault;
+ }
+
+ let composeWindowPromise = new Promise(resolve => {
+ function listener(event) {
+ let composeWindow = event.target.ownerGlobal;
+ // Skip if this window has been processed already. This already helps
+ // a lot to assign the opened windows in the correct order to the
+ // OpenCompomposeWindow calls.
+ if (composeWindowTracker.has(composeWindow)) {
+ return;
+ }
+ // Do a few more checks to make sure we are looking at the expected
+ // window. This is still a hack. We need to make OpenCompomposeWindow
+ // actually return the opened window.
+ let _msgURI = composeWindow.gMsgCompose.originalMsgURI;
+ let _type = composeWindow.gComposeType;
+ if (_msgURI == msgURI && _type == type) {
+ composeWindowTracker.add(composeWindow);
+ windowTracker.removeListener("compose-editor-ready", listener);
+ resolve(composeWindow);
+ }
+ }
+ windowTracker.addListener("compose-editor-ready", listener);
+ });
+ MailServices.compose.OpenComposeWindow(
+ null,
+ msgHdr,
+ msgURI,
+ type,
+ format,
+ identity,
+ null,
+ null
+ );
+ let composeWindow = await composeWindowPromise;
+
+ if (details) {
+ await setComposeDetails(composeWindow, details, extension);
+ if (details.attachments != null) {
+ let attachmentData = [];
+ for (let data of details.attachments) {
+ attachmentData.push(await createAttachment(data));
+ }
+ await AddAttachmentsToWindow(composeWindow, attachmentData);
+ }
+ }
+ composeWindow.gContentChanged = false;
+ return composeWindow;
+ }
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (relatedMessageId) {
+ let msgHdr = messageTracker.getMessage(relatedMessageId);
+ params.originalMsgURI = msgHdr.folder.getUriForMsg(msgHdr);
+ }
+
+ params.type = type;
+ params.format = format;
+ if (identity) {
+ params.identity = identity;
+ }
+
+ params.composeFields = composeFields;
+ let composeWindow = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ params
+ );
+ await composeWindowIsReady(composeWindow);
+
+ // Not all details can be set with params for all types, so some need an extra
+ // call to setComposeDetails here. Since we have to use setComposeDetails for
+ // the EditAsNew code path, unify API behavior by always calling it here too.
+ if (details) {
+ await setComposeDetails(composeWindow, details, extension);
+ if (details.attachments != null) {
+ let attachmentData = [];
+ for (let data of details.attachments) {
+ attachmentData.push(await createAttachment(data));
+ }
+ await AddAttachmentsToWindow(composeWindow, attachmentData);
+ }
+ }
+ composeWindow.gContentChanged = false;
+ return composeWindow;
+}
+
+/**
+ * Converts "\r\n" line breaks to "\n" and removes trailing line breaks.
+ *
+ * @param {string} content - original content
+ * @returns {string} - trimmed content
+ */
+function trimContent(content) {
+ let data = content.replaceAll("\r\n", "\n").split("\n");
+ while (data[data.length - 1] == "") {
+ data.pop();
+ }
+ return data.join("\n");
+}
+
+/**
+ * Get the compose details of the requested compose window.
+ *
+ * @param {DOMWindow} composeWindow
+ * @param {ExtensionData} extension
+ * @returns {ComposeDetails}
+ *
+ * @see mail/components/extensions/schemas/compose.json
+ */
+async function getComposeDetails(composeWindow, extension) {
+ let composeFields = composeWindow.GetComposeDetails();
+ let editor = composeWindow.GetCurrentEditor();
+
+ let type;
+ // check all known nsIMsgComposeParams
+ switch (composeWindow.gComposeType) {
+ case Ci.nsIMsgCompType.Draft:
+ type = "draft";
+ break;
+ case Ci.nsIMsgCompType.New:
+ case Ci.nsIMsgCompType.Template:
+ case Ci.nsIMsgCompType.MailToUrl:
+ case Ci.nsIMsgCompType.EditAsNew:
+ case Ci.nsIMsgCompType.EditTemplate:
+ case Ci.nsIMsgCompType.NewsPost:
+ type = "new";
+ break;
+ case Ci.nsIMsgCompType.Reply:
+ case Ci.nsIMsgCompType.ReplyAll:
+ case Ci.nsIMsgCompType.ReplyToSender:
+ case Ci.nsIMsgCompType.ReplyToGroup:
+ case Ci.nsIMsgCompType.ReplyToSenderAndGroup:
+ case Ci.nsIMsgCompType.ReplyWithTemplate:
+ case Ci.nsIMsgCompType.ReplyToList:
+ type = "reply";
+ break;
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ case Ci.nsIMsgCompType.ForwardInline:
+ type = "forward";
+ break;
+ case Ci.nsIMsgCompType.Redirect:
+ type = "redirect";
+ break;
+ }
+
+ let relatedMessageId = null;
+ if (composeWindow.gMsgCompose.originalMsgURI) {
+ try {
+ // This throws for messages opened from file and then being replied to.
+ let relatedMsgHdr = composeWindow.gMessenger.msgHdrFromURI(
+ composeWindow.gMsgCompose.originalMsgURI
+ );
+ relatedMessageId = messageTracker.getId(relatedMsgHdr);
+ } catch (ex) {
+ // We are currently unable to get the fake msgHdr from the uri of messages
+ // opened from file.
+ }
+ }
+
+ let customHeaders = [...composeFields.headerNames]
+ .map(h => h.toLowerCase())
+ .filter(h => h.startsWith("x-"))
+ .map(h => {
+ return {
+ // All-lower-case-names are ugly, so capitalize first letters.
+ name: h.replace(/(^|-)[a-z]/g, function (match) {
+ return match.toUpperCase();
+ }),
+ value: composeFields.getHeader(h),
+ };
+ });
+
+ // We have two file carbon copy settings: fcc and fcc2. fcc allows to override
+ // the default identity fcc and fcc2 is coupled to the UI selection.
+ let overrideDefaultFcc = false;
+ if (composeFields.fcc && composeFields.fcc != "") {
+ overrideDefaultFcc = true;
+ }
+ let overrideDefaultFccFolder = "";
+ if (overrideDefaultFcc && !composeFields.fcc.startsWith("nocopy://")) {
+ let folder = MailUtils.getExistingFolder(composeFields.fcc);
+ if (folder) {
+ overrideDefaultFccFolder = convertFolder(folder);
+ }
+ }
+ let additionalFccFolder = "";
+ if (composeFields.fcc2 && !composeFields.fcc2.startsWith("nocopy://")) {
+ let folder = MailUtils.getExistingFolder(composeFields.fcc2);
+ if (folder) {
+ additionalFccFolder = convertFolder(folder);
+ }
+ }
+
+ let deliveryFormat = composeWindow.IsHTMLEditor()
+ ? deliveryFormats.find(f => f.id == composeFields.deliveryFormat).value
+ : null;
+
+ let body = trimContent(
+ editor.outputToString("text/html", Ci.nsIDocumentEncoder.OutputRaw)
+ );
+ let plainTextBody;
+ if (composeWindow.IsHTMLEditor()) {
+ plainTextBody = trimContent(MsgUtils.convertToPlainText(body, true));
+ } else {
+ plainTextBody = parserUtils.convertToPlainText(
+ body,
+ Ci.nsIDocumentEncoder.OutputLFLineBreak,
+ 0
+ );
+ // Remove the extra new line at the end.
+ if (plainTextBody.endsWith("\n")) {
+ plainTextBody = plainTextBody.slice(0, -1);
+ }
+ }
+
+ let details = {
+ from: composeFields.splitRecipients(composeFields.from, false).shift(),
+ to: composeFields.splitRecipients(composeFields.to, false),
+ cc: composeFields.splitRecipients(composeFields.cc, false),
+ bcc: composeFields.splitRecipients(composeFields.bcc, false),
+ overrideDefaultFcc,
+ overrideDefaultFccFolder: overrideDefaultFcc
+ ? overrideDefaultFccFolder
+ : null,
+ additionalFccFolder,
+ type,
+ relatedMessageId,
+ replyTo: composeFields.splitRecipients(composeFields.replyTo, false),
+ followupTo: composeFields.splitRecipients(composeFields.followupTo, false),
+ newsgroups: composeFields.newsgroups
+ ? composeFields.newsgroups.split(",")
+ : [],
+ subject: composeFields.subject,
+ isPlainText: !composeWindow.IsHTMLEditor(),
+ deliveryFormat,
+ body,
+ plainTextBody,
+ customHeaders,
+ priority: composeFields.priority.toLowerCase() || "normal",
+ returnReceipt: composeFields.returnReceipt,
+ deliveryStatusNotification: composeFields.DSN,
+ attachVCard: composeFields.attachVCard,
+ };
+ if (extension.hasPermission("accountsRead")) {
+ details.identityId = composeWindow.getCurrentIdentityKey();
+ }
+ return details;
+}
+
+async function setFromField(composeWindow, details, extension) {
+ if (!details || details.from == null) {
+ return;
+ }
+
+ let from;
+ // Re-throw exceptions from parseComposeRecipientList with a prefix to
+ // minimize developers debugging time and make clear where restrictions are
+ // coming from.
+ try {
+ from = await parseComposeRecipientList(details.from, true);
+ } catch (ex) {
+ throw new ExtensionError(`ComposeDetails.from: ${ex.message}`);
+ }
+ if (!from) {
+ throw new ExtensionError(
+ "ComposeDetails.from: Address must not be set to an empty string."
+ );
+ }
+
+ let identityList = composeWindow.document.getElementById("msgIdentity");
+ // Make the from field editable only, if from differs from the currently shown identity.
+ if (from != identityList.value) {
+ let activeElement = composeWindow.document.activeElement;
+ // Manually update from, using the same approach used in
+ // https://hg.mozilla.org/comm-central/file/1283451c02926e2b7506a6450445b81f6d076f89/mail/components/compose/content/MsgComposeCommands.js#l3621
+ composeWindow.MakeFromFieldEditable(true);
+ identityList.value = from;
+ activeElement.focus();
+ }
+}
+
+/**
+ * Updates the compose details of the specified compose window, overwriting any
+ * property given in the details object.
+ *
+ * @param {DOMWindow} composeWindow
+ * @param {ComposeDetails} details - compose details to update the composer with
+ * @param {ExtensionData} extension
+ *
+ * @see mail/components/extensions/schemas/compose.json
+ */
+async function setComposeDetails(composeWindow, details, extension) {
+ let activeElement = composeWindow.document.activeElement;
+
+ // Check if conflicting formats have been specified.
+ if (
+ details.isPlainText === true &&
+ details.body != null &&
+ details.plainTextBody == null
+ ) {
+ throw new ExtensionError(
+ "Conflicting format setting: isPlainText = true and providing a body but no plainTextBody."
+ );
+ }
+ if (
+ details.isPlainText === false &&
+ details.body == null &&
+ details.plainTextBody != null
+ ) {
+ throw new ExtensionError(
+ "Conflicting format setting: isPlainText = false and providing a plainTextBody but no body."
+ );
+ }
+
+ // Remove any unsupported body type. Otherwise, this will throw an
+ // NS_UNEXPECTED_ERROR later. Note: setComposeDetails cannot change the compose
+ // format, details.isPlainText is ignored.
+ if (composeWindow.IsHTMLEditor()) {
+ delete details.plainTextBody;
+ } else {
+ delete details.body;
+ }
+
+ if (details.identityId) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Using identities requires the "accountsRead" permission'
+ );
+ }
+
+ let identity = MailServices.accounts.allIdentities.find(
+ i => i.key == details.identityId
+ );
+ if (!identity) {
+ throw new ExtensionError(`Identity not found: ${details.identityId}`);
+ }
+ let identityElement = composeWindow.document.getElementById("msgIdentity");
+ identityElement.selectedItem = [
+ ...identityElement.childNodes[0].childNodes,
+ ].find(e => e.getAttribute("identitykey") == details.identityId);
+ composeWindow.LoadIdentity(false);
+ }
+ for (let field of ["to", "cc", "bcc", "replyTo", "followupTo"]) {
+ if (field in details) {
+ details[field] = await parseComposeRecipientList(details[field]);
+ }
+ }
+ if (Array.isArray(details.newsgroups)) {
+ details.newsgroups = details.newsgroups.join(",");
+ }
+
+ composeWindow.SetComposeDetails(details);
+ await setFromField(composeWindow, details, extension);
+
+ // Set file carbon copy values.
+ if (details.overrideDefaultFcc === false) {
+ composeWindow.gMsgCompose.compFields.fcc = "";
+ } else if (details.overrideDefaultFccFolder != null) {
+ // Override identity fcc with enforced value.
+ if (details.overrideDefaultFccFolder) {
+ let uri = folderPathToURI(
+ details.overrideDefaultFccFolder.accountId,
+ details.overrideDefaultFccFolder.path
+ );
+ let folder = MailUtils.getExistingFolder(uri);
+ if (folder) {
+ composeWindow.gMsgCompose.compFields.fcc = uri;
+ } else {
+ throw new ExtensionError(
+ `Invalid MailFolder: {accountId:${details.overrideDefaultFccFolder.accountId}, path:${details.overrideDefaultFccFolder.path}}`
+ );
+ }
+ } else {
+ composeWindow.gMsgCompose.compFields.fcc = "nocopy://";
+ }
+ } else if (
+ details.overrideDefaultFcc === true &&
+ composeWindow.gMsgCompose.compFields.fcc == ""
+ ) {
+ throw new ExtensionError(
+ `Setting overrideDefaultFcc to true requires setting overrideDefaultFccFolder as well`
+ );
+ }
+
+ if (details.additionalFccFolder != null) {
+ if (details.additionalFccFolder) {
+ let uri = folderPathToURI(
+ details.additionalFccFolder.accountId,
+ details.additionalFccFolder.path
+ );
+ let folder = MailUtils.getExistingFolder(uri);
+ if (folder) {
+ composeWindow.gMsgCompose.compFields.fcc2 = uri;
+ } else {
+ throw new ExtensionError(
+ `Invalid MailFolder: {accountId:${details.additionalFccFolder.accountId}, path:${details.additionalFccFolder.path}}`
+ );
+ }
+ } else {
+ composeWindow.gMsgCompose.compFields.fcc2 = "";
+ }
+ }
+
+ // Update custom headers, if specified.
+ if (details.customHeaders) {
+ let newHeaderNames = details.customHeaders.map(h => h.name.toUpperCase());
+ let obsoleteHeaderNames = [
+ ...composeWindow.gMsgCompose.compFields.headerNames,
+ ]
+ .map(h => h.toUpperCase())
+ .filter(h => h.startsWith("X-") && !newHeaderNames.hasOwnProperty(h));
+
+ for (let headerName of obsoleteHeaderNames) {
+ composeWindow.gMsgCompose.compFields.deleteHeader(headerName);
+ }
+ for (let { name, value } of details.customHeaders) {
+ composeWindow.gMsgCompose.compFields.setHeader(name, value);
+ }
+ }
+
+ // Update priorities. The enum in the schema defines all allowed values, no
+ // need to validate here.
+ if (details.priority) {
+ if (details.priority == "normal") {
+ composeWindow.gMsgCompose.compFields.priority = "";
+ } else {
+ composeWindow.gMsgCompose.compFields.priority =
+ details.priority[0].toUpperCase() + details.priority.slice(1);
+ }
+ composeWindow.updatePriorityToolbarButton(
+ composeWindow.gMsgCompose.compFields.priority
+ );
+ }
+
+ // Update receipt notifications.
+ if (details.returnReceipt != null) {
+ composeWindow.ToggleReturnReceipt(details.returnReceipt);
+ }
+
+ if (
+ details.deliveryStatusNotification != null &&
+ details.deliveryStatusNotification !=
+ composeWindow.gMsgCompose.compFields.DSN
+ ) {
+ let target = composeWindow.document.getElementById("dsnMenu");
+ composeWindow.ToggleDSN(target);
+ }
+
+ if (details.deliveryFormat && composeWindow.IsHTMLEditor()) {
+ // Do not throw when a deliveryFormat is set on a plaint text composer, because
+ // it is allowed to set ComposeDetails of an html composer onto a plain text
+ // composer (and automatically pick the plainText body). The deliveryFormat
+ // will be ignored.
+ composeWindow.gMsgCompose.compFields.deliveryFormat = deliveryFormats.find(
+ f => f.value == details.deliveryFormat
+ ).id;
+ composeWindow.initSendFormatMenu();
+ }
+
+ if (details.attachVCard != null) {
+ composeWindow.gMsgCompose.compFields.attachVCard = details.attachVCard;
+ composeWindow.gAttachVCardOptionChanged = true;
+ }
+
+ activeElement.focus();
+}
+
+async function fileURLForFile(file) {
+ let realFile = await getRealFileForFile(file);
+ return Services.io.newFileURI(realFile).spec;
+}
+
+async function createAttachment(data) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ if (data.id) {
+ if (!composeAttachmentTracker.hasAttachment(data.id)) {
+ throw new ExtensionError(`Invalid attachment ID: ${data.id}`);
+ }
+
+ let { attachment: originalAttachment, window: originalWindow } =
+ composeAttachmentTracker.getAttachment(data.id);
+
+ let originalAttachmentItem =
+ originalWindow.gAttachmentBucket.findItemForAttachment(
+ originalAttachment
+ );
+
+ attachment.name = data.name || originalAttachment.name;
+ attachment.size = originalAttachment.size;
+ attachment.url = originalAttachment.url;
+
+ return {
+ attachment,
+ originalAttachment,
+ originalCloudFileAccount: originalAttachmentItem.cloudFileAccount,
+ originalCloudFileUpload: originalAttachmentItem.cloudFileUpload,
+ };
+ }
+
+ if (data.file) {
+ attachment.name = data.name || data.file.name;
+ attachment.size = data.file.size;
+ attachment.url = await fileURLForFile(data.file);
+ attachment.contentType = data.file.type;
+ return { attachment };
+ }
+
+ throw new ExtensionError(`Failed to create attachment.`);
+}
+
+async function AddAttachmentsToWindow(window, attachmentData) {
+ await window.AddAttachments(attachmentData.map(a => a.attachment));
+ // Check if an attachment has been cloned and the cloudFileUpload needs to be
+ // re-applied.
+ for (let entry of attachmentData) {
+ let addedAttachmentItem = window.gAttachmentBucket.findItemForAttachment(
+ entry.attachment
+ );
+ if (!addedAttachmentItem) {
+ continue;
+ }
+
+ if (
+ !entry.originalAttachment ||
+ !entry.originalCloudFileAccount ||
+ !entry.originalCloudFileUpload
+ ) {
+ continue;
+ }
+
+ let updateSettings = {
+ cloudFileAccount: entry.originalCloudFileAccount,
+ relatedCloudFileUpload: entry.originalCloudFileUpload,
+ };
+ if (entry.originalAttachment.name != entry.attachment.name) {
+ updateSettings.name = entry.attachment.name;
+ }
+
+ try {
+ await window.UpdateAttachment(addedAttachmentItem, updateSettings);
+ } catch (ex) {
+ throw new ExtensionError(ex.message);
+ }
+ }
+}
+
+var composeStates = {
+ _states: {
+ canSendNow: "cmd_sendNow",
+ canSendLater: "cmd_sendLater",
+ },
+
+ getStates(tab) {
+ let states = {};
+ for (let [state, command] of Object.entries(this._states)) {
+ state[state] = tab.nativeTab.defaultController.isCommandEnabled(command);
+ }
+ return states;
+ },
+
+ // Translate core states (commands) to API states.
+ convert(states) {
+ let converted = {};
+ for (let [state, command] of Object.entries(this._states)) {
+ if (states.hasOwnProperty(command)) {
+ converted[state] = states[command];
+ }
+ }
+ return converted;
+ },
+};
+
+class MsgOperationObserver {
+ constructor(composeWindow) {
+ this.composeWindow = composeWindow;
+ this.savedMessages = [];
+ this.headerMessageId = null;
+ this.deliveryCallbacks = null;
+ this.preparedCallbacks = null;
+ this.classifiedMessages = new Map();
+
+ // The preparedPromise fulfills when the message has been prepared and handed
+ // over to the send process.
+ this.preparedPromise = new Promise((resolve, reject) => {
+ this.preparedCallbacks = { resolve, reject };
+ });
+
+ // The deliveryPromise fulfills when the message has been saved/send.
+ this.deliveryPromise = new Promise((resolve, reject) => {
+ this.deliveryCallbacks = { resolve, reject };
+ });
+
+ Services.obs.addObserver(this, "mail:composeSendProgressStop");
+ this.composeWindow.gMsgCompose.addMsgSendListener(this);
+ MailServices.mfn.addListener(this, MailServices.mfn.msgsClassified);
+ this.composeWindow.addEventListener(
+ "compose-prepare-message-success",
+ event => this.preparedCallbacks.resolve(),
+ { once: true }
+ );
+ this.composeWindow.addEventListener(
+ "compose-prepare-message-failure",
+ event => this.preparedCallbacks.reject(event.detail.exception),
+ { once: true }
+ );
+ }
+
+ // Observer for mail:composeSendProgressStop.
+ observe(subject, topic, data) {
+ let { composeWindow } = subject.wrappedJSObject;
+ if (composeWindow == this.composeWindow) {
+ this.deliveryCallbacks.resolve();
+ }
+ }
+
+ // nsIMsgSendListener
+ onStartSending(msgID, msgSize) {}
+ onProgress(msgID, progress, progressMax) {}
+ onStatus(msgID, msg) {}
+ onStopSending(msgID, status, msg, returnFile) {
+ if (!Components.isSuccessCode(status)) {
+ this.deliveryCallbacks.reject(
+ new ExtensionError("Message operation failed")
+ );
+ return;
+ }
+ // In case of success, this is only called for sendNow, stating the
+ // headerMessageId of the outgoing message.
+ // The msgID starts with < and ends with > which is not used by the API.
+ this.headerMessageId = msgID.replace(/^<|>$/g, "");
+ }
+ onGetDraftFolderURI(msgID, folderURI) {
+ // Only called for save operations and sendLater. Collect messageIds and
+ // folders of saved messages.
+ let headerMessageId = msgID.replace(/^<|>$/g, "");
+ this.savedMessages.push(JSON.stringify({ headerMessageId, folderURI }));
+ }
+ onSendNotPerformed(msgID, status) {}
+ onTransportSecurityError(msgID, status, secInfo, location) {}
+
+ // Implementation for nsIMsgFolderListener::msgsClassified
+ msgsClassified(msgs, junkProcessed, traitProcessed) {
+ // Collect all msgHdrs added to folders during the current message operation.
+ for (let msgHdr of msgs) {
+ let key = JSON.stringify({
+ headerMessageId: msgHdr.messageId,
+ folderURI: msgHdr.folder.URI,
+ });
+ if (!this.classifiedMessages.has(key)) {
+ this.classifiedMessages.set(key, convertMessage(msgHdr));
+ }
+ }
+ }
+
+ /**
+ * @typedef MsgOperationInfo
+ * @property {string} headerMessageId - the id used in the "Message-Id" header
+ * of the outgoing message, only available for the "sendNow" mode
+ * @property {MessageHeader[]} messages - array of WebExtension MessageHeader
+ * objects, with information about saved messages (depends on fcc config)
+ * @see mail/components/extensions/schemas/compose.json
+ */
+
+ /**
+ * Returns a Promise, which resolves once the message operation has finished.
+ *
+ * @returns {Promise<MsgOperationInfo>} - Promise for information about the
+ * performed message operation.
+ */
+ async waitForOperation() {
+ try {
+ await Promise.all([this.deliveryPromise, this.preparedPromise]);
+ return {
+ messages: this.savedMessages
+ .map(m => this.classifiedMessages.get(m))
+ .filter(Boolean),
+ headerMessageId: this.headerMessageId,
+ };
+ } catch (ex) {
+ // In case of error, reject the pending delivery Promise.
+ this.deliveryCallbacks.reject();
+ throw ex;
+ } finally {
+ MailServices.mfn.removeListener(this);
+ Services.obs.removeObserver(this, "mail:composeSendProgressStop");
+ this.composeWindow?.gMsgCompose?.removeMsgSendListener(this);
+ }
+ }
+}
+
+/**
+ * @typedef MsgOperationReturnValue
+ * @property {string} headerMessageId - the id used in the "Message-Id" header
+ * of the outgoing message, only available for the "sendNow" mode
+ * @property {MessageHeader[]} messages - array of WebExtension MessageHeader
+ * objects, with information about saved messages (depends on fcc config)
+ * @see mail/components/extensions/schemas/compose.json
+ * @property {string} mode - the mode of the message operation
+ * @see mail/components/extensions/schemas/compose.json
+ */
+
+/**
+ * Executes the given save/send command. The returned Promise resolves once the
+ * message operation has finished.
+ *
+ * @returns {Promise<MsgOperationReturnValue>} - Promise for information about
+ * the performed message operation, which is passed to the WebExtension.
+ */
+async function goDoCommand(composeWindow, extension, mode) {
+ let commands = new Map([
+ ["draft", "cmd_saveAsDraft"],
+ ["template", "cmd_saveAsTemplate"],
+ ["sendNow", "cmd_sendNow"],
+ ["sendLater", "cmd_sendLater"],
+ ]);
+
+ if (!commands.has(mode)) {
+ throw new ExtensionError(`Unsupported mode: ${mode}`);
+ }
+
+ if (!composeWindow.defaultController.isCommandEnabled(commands.get(mode))) {
+ throw new ExtensionError(
+ `Message compose window not ready for the requested command`
+ );
+ }
+
+ let sendPromise = new Promise((resolve, reject) => {
+ let listener = {
+ onSuccess(window, mode, messages, headerMessageId) {
+ if (window == composeWindow) {
+ afterSaveSendEventTracker.removeListener(listener);
+ let info = { mode, messages };
+ if (mode == "sendNow") {
+ info.headerMessageId = headerMessageId;
+ }
+ resolve(info);
+ }
+ },
+ onFailure(window, mode, exception) {
+ if (window == composeWindow) {
+ afterSaveSendEventTracker.removeListener(listener);
+ reject(exception);
+ }
+ },
+ modes: [mode],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ });
+
+ // Initiate send.
+ switch (mode) {
+ case "draft":
+ composeWindow.SaveAsDraft();
+ break;
+ case "template":
+ composeWindow.SaveAsTemplate();
+ break;
+ case "sendNow":
+ composeWindow.SendMessage();
+ break;
+ case "sendLater":
+ composeWindow.SendMessageLater();
+ break;
+ }
+ return sendPromise;
+}
+
+var afterSaveSendEventTracker = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ },
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ },
+ async handleSuccess(window, mode, messages, headerMessageId) {
+ for (let listener of this.listeners) {
+ if (!listener.modes.includes(mode)) {
+ continue;
+ }
+ await listener.onSuccess(
+ window,
+ mode,
+ messages.map(message => {
+ // Strip data from MessageHeader if this extension doesn't have
+ // the required permission.
+ let clone = Object.assign({}, message);
+ if (!listener.extension.hasPermission("accountsRead")) {
+ delete clone.folders;
+ }
+ return clone;
+ }),
+ headerMessageId
+ );
+ }
+ },
+ async handleFailure(window, mode, exception) {
+ for (let listener of this.listeners) {
+ if (!listener.modes.includes(mode)) {
+ continue;
+ }
+ await listener.onFailure(window, mode, exception);
+ }
+ },
+
+ // Event handler for the "compose-prepare-message-start", which initiates a
+ // new message operation (send or save).
+ handleEvent(event) {
+ let composeWindow = event.target;
+ let msgType = event.detail.msgType;
+
+ let modes = new Map([
+ [Ci.nsIMsgCompDeliverMode.SaveAsDraft, "draft"],
+ [Ci.nsIMsgCompDeliverMode.SaveAsTemplate, "template"],
+ [Ci.nsIMsgCompDeliverMode.Now, "sendNow"],
+ [Ci.nsIMsgCompDeliverMode.Later, "sendLater"],
+ ]);
+ let mode = modes.get(msgType);
+
+ if (mode && this.listeners.size > 0) {
+ let msgOperationObserver = new MsgOperationObserver(composeWindow);
+ msgOperationObserver
+ .waitForOperation()
+ .then(msgOperationInfo =>
+ this.handleSuccess(
+ composeWindow,
+ mode,
+ msgOperationInfo.messages,
+ msgOperationInfo.headerMessageId
+ )
+ )
+ .catch(msgOperationException =>
+ this.handleFailure(composeWindow, mode, msgOperationException)
+ );
+ }
+ },
+};
+windowTracker.addListener(
+ "compose-prepare-message-start",
+ afterSaveSendEventTracker
+);
+
+var beforeSendEventTracker = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ if (this.listeners.size == 1) {
+ windowTracker.addListener("beforesend", this);
+ }
+ },
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ if (this.listeners.size == 0) {
+ windowTracker.removeListener("beforesend", this);
+ }
+ },
+ async handleEvent(event) {
+ event.preventDefault();
+
+ let sendPromise = event.detail;
+ let composeWindow = event.target;
+ await composeWindowIsReady(composeWindow);
+ composeWindow.ToggleWindowLock(true);
+
+ // Send process waits till sendPromise.resolve() or sendPromise.reject() is
+ // called.
+
+ for (let { handler, extension } of this.listeners) {
+ let result = await handler(
+ composeWindow,
+ await getComposeDetails(composeWindow, extension)
+ );
+ if (!result) {
+ continue;
+ }
+ if (result.cancel) {
+ composeWindow.ToggleWindowLock(false);
+ sendPromise.reject();
+ return;
+ }
+ if (result.details) {
+ await setComposeDetails(composeWindow, result.details, extension);
+ }
+ }
+
+ // Load the new details into gMsgCompose.compFields for sending.
+ composeWindow.GetComposeDetails();
+
+ composeWindow.ToggleWindowLock(false);
+ sendPromise.resolve();
+ },
+};
+
+var composeAttachmentTracker = {
+ _nextId: 1,
+ _attachments: new Map(),
+ _attachmentIds: new Map(),
+
+ getId(attachment, window) {
+ if (this._attachmentIds.has(attachment)) {
+ return this._attachmentIds.get(attachment).id;
+ }
+ let id = this._nextId++;
+ this._attachments.set(id, { attachment, window });
+ this._attachmentIds.set(attachment, { id, window });
+ return id;
+ },
+
+ getAttachment(id) {
+ return this._attachments.get(id);
+ },
+
+ hasAttachment(id) {
+ return this._attachments.has(id);
+ },
+
+ forgetAttachment(attachment) {
+ // This is called on all attachments when the window closes, whether the
+ // attachments have been assigned IDs or not.
+ let id = this._attachmentIds.get(attachment)?.id;
+ if (id) {
+ this._attachmentIds.delete(attachment);
+ this._attachments.delete(id);
+ }
+ },
+
+ forgetAttachments(window) {
+ if (window.location.href == COMPOSE_WINDOW_URI) {
+ let bucket = window.document.getElementById("attachmentBucket");
+ for (let item of bucket.itemChildren) {
+ this.forgetAttachment(item.attachment);
+ }
+ }
+ },
+
+ convert(attachment, window) {
+ return {
+ id: this.getId(attachment, window),
+ name: attachment.name,
+ size: attachment.size,
+ };
+ },
+
+ getFile(attachment) {
+ if (!attachment) {
+ return null;
+ }
+ let uri = Services.io.newURI(attachment.url).QueryInterface(Ci.nsIFileURL);
+ // Enforce the actual filename used in the composer, do not leak internal or
+ // temporary filenames.
+ return File.createFromNsIFile(uri.file, { name: attachment.name });
+ },
+};
+
+windowTracker.addCloseListener(
+ composeAttachmentTracker.forgetAttachments.bind(composeAttachmentTracker)
+);
+
+var composeWindowTracker = new Set();
+windowTracker.addCloseListener(window => composeWindowTracker.delete(window));
+
+this.compose = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onBeforeSend({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async handler(window, details) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ return fire.async(
+ tabManager.convert(win.activeTab.nativeTab),
+ details
+ );
+ },
+ extension,
+ };
+
+ beforeSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ beforeSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAfterSend({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async onSuccess(window, mode, messages, headerMessageId) {
+ let win = windowManager.wrapWindow(window);
+ let tab = tabManager.convert(win.activeTab.nativeTab);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let sendInfo = { mode, messages };
+ if (mode == "sendNow") {
+ sendInfo.headerMessageId = headerMessageId;
+ }
+ return fire.async(tab, sendInfo);
+ },
+ async onFailure(window, mode, exception) {
+ let win = windowManager.wrapWindow(window);
+ let tab = tabManager.convert(win.activeTab.nativeTab);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(tab, {
+ mode,
+ messages: [],
+ error: exception.message,
+ });
+ },
+ modes: ["sendNow", "sendLater"],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ afterSaveSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAfterSave({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async onSuccess(window, mode, messages, headerMessageId) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ let saveInfo = { mode, messages };
+ return fire.async(
+ tabManager.convert(win.activeTab.nativeTab),
+ saveInfo
+ );
+ },
+ async onFailure(window, mode, exception) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ return fire.async(tabManager.convert(win.activeTab.nativeTab), {
+ mode,
+ messages: [],
+ error: exception.message,
+ });
+ },
+ modes: ["draft", "template"],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ afterSaveSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAttachmentAdded({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ for (let attachment of event.detail) {
+ attachment = composeAttachmentTracker.convert(
+ attachment,
+ event.target.ownerGlobal
+ );
+ fire.async(tabManager.convert(event.target.ownerGlobal), attachment);
+ }
+ }
+ windowTracker.addListener("attachments-added", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("attachments-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAttachmentRemoved({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ for (let attachment of event.detail) {
+ let attachmentId = composeAttachmentTracker.getId(
+ attachment,
+ event.target.ownerGlobal
+ );
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ attachmentId
+ );
+ composeAttachmentTracker.forgetAttachment(attachment);
+ }
+ }
+ windowTracker.addListener("attachments-removed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("attachments-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onIdentityChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ event.target.getCurrentIdentityKey()
+ );
+ }
+ windowTracker.addListener("compose-from-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("compose-from-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onComposeStateChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ composeStates.convert(event.detail)
+ );
+ }
+ windowTracker.addListener("compose-state-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("compose-state-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onActiveDictionariesChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let activeDictionaries = event.detail.split(",");
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList()
+ .reduce((list, dict) => {
+ list[dict] = activeDictionaries.includes(dict);
+ return list;
+ }, {})
+ );
+ }
+ windowTracker.addListener("active-dictionaries-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("active-dictionaries-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ /**
+ * Guard to make sure the API waits until the compose tab has been fully loaded,
+ * to cope with tabs.onCreated returning tabs very early.
+ *
+ * @param {integer} tabId
+ * @returns {Tab} a fully loaded messageCompose tab
+ */
+ async function getComposeTab(tabId) {
+ let tab = tabManager.get(tabId);
+ if (tab.type != "messageCompose") {
+ throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+ }
+ await composeWindowIsReady(tab.nativeTab);
+ return tab;
+ }
+
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ compose: {
+ onBeforeSend: new EventManager({
+ context,
+ module: "compose",
+ event: "onBeforeSend",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAfterSend: new EventManager({
+ context,
+ module: "compose",
+ event: "onAfterSend",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAfterSave: new EventManager({
+ context,
+ module: "compose",
+ event: "onAfterSave",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAttachmentAdded: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onAttachmentAdded",
+ extensionApi: this,
+ }).api(),
+ onAttachmentRemoved: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onAttachmentRemoved",
+ extensionApi: this,
+ }).api(),
+ onIdentityChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onIdentityChanged",
+ extensionApi: this,
+ }).api(),
+ onComposeStateChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onComposeStateChanged",
+ extensionApi: this,
+ }).api(),
+ onActiveDictionariesChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onActiveDictionariesChanged",
+ extensionApi: this,
+ }).api(),
+ async beginNew(messageId, details) {
+ let type = Ci.nsIMsgCompType.New;
+ if (messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ type =
+ msgHdr.flags & Ci.nsMsgMessageFlags.Template
+ ? Ci.nsIMsgCompType.Template
+ : Ci.nsIMsgCompType.EditAsNew;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async beginReply(messageId, replyType, details) {
+ let type = Ci.nsIMsgCompType.Reply;
+ if (replyType == "replyToList") {
+ type = Ci.nsIMsgCompType.ReplyToList;
+ } else if (replyType == "replyToAll") {
+ type = Ci.nsIMsgCompType.ReplyAll;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async beginForward(messageId, forwardType, details) {
+ let type = Ci.nsIMsgCompType.ForwardInline;
+ if (forwardType == "forwardAsAttachment") {
+ type = Ci.nsIMsgCompType.ForwardAsAttachment;
+ } else if (
+ forwardType === null &&
+ Services.prefs.getIntPref("mail.forward_message_mode") == 0
+ ) {
+ type = Ci.nsIMsgCompType.ForwardAsAttachment;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async saveMessage(tabId, options) {
+ let tab = await getComposeTab(tabId);
+ let saveMode = options?.mode || "draft";
+
+ try {
+ return await goDoCommand(
+ tab.nativeTab,
+ context.extension,
+ saveMode
+ );
+ } catch (ex) {
+ throw new ExtensionError(
+ `compose.saveMessage failed: ${ex.message}`
+ );
+ }
+ },
+ async sendMessage(tabId, options) {
+ let tab = await getComposeTab(tabId);
+ let sendMode = options?.mode;
+ if (!["sendLater", "sendNow"].includes(sendMode)) {
+ sendMode = Services.io.offline ? "sendLater" : "sendNow";
+ }
+
+ try {
+ return await goDoCommand(
+ tab.nativeTab,
+ context.extension,
+ sendMode
+ );
+ } catch (ex) {
+ throw new ExtensionError(
+ `compose.sendMessage failed: ${ex.message}`
+ );
+ }
+ },
+ async getComposeState(tabId) {
+ let tab = await getComposeTab(tabId);
+ return composeStates.getStates(tab);
+ },
+ async getComposeDetails(tabId) {
+ let tab = await getComposeTab(tabId);
+ return getComposeDetails(tab.nativeTab, extension);
+ },
+ async setComposeDetails(tabId, details) {
+ let tab = await getComposeTab(tabId);
+ return setComposeDetails(tab.nativeTab, details, extension);
+ },
+ async getActiveDictionaries(tabId) {
+ let tab = await getComposeTab(tabId);
+ let dictionaries = tab.nativeTab.gActiveDictionaries;
+
+ // Return the list of installed dictionaries, setting those who are
+ // enabled to true.
+ return Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList()
+ .reduce((list, dict) => {
+ list[dict] = dictionaries.has(dict);
+ return list;
+ }, {});
+ },
+ async setActiveDictionaries(tabId, activeDictionaries) {
+ let tab = await getComposeTab(tabId);
+ let installedDictionaries = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList();
+
+ for (let dict of activeDictionaries) {
+ if (!installedDictionaries.includes(dict)) {
+ throw new ExtensionError(`Dictionary not found: ${dict}`);
+ }
+ }
+
+ await tab.nativeTab.ComposeChangeLanguage(activeDictionaries);
+ },
+ async listAttachments(tabId) {
+ let tab = await getComposeTab(tabId);
+
+ let bucket =
+ tab.nativeTab.document.getElementById("attachmentBucket");
+ let attachments = [];
+ for (let item of bucket.itemChildren) {
+ attachments.push(
+ composeAttachmentTracker.convert(item.attachment, tab.nativeTab)
+ );
+ }
+ return attachments;
+ },
+ async getAttachmentFile(attachmentId) {
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ return composeAttachmentTracker.getFile(attachment);
+ },
+ async addAttachment(tabId, data) {
+ let tab = await getComposeTab(tabId);
+ let attachmentData = await createAttachment(data);
+ await AddAttachmentsToWindow(tab.nativeTab, [attachmentData]);
+ return composeAttachmentTracker.convert(
+ attachmentData.attachment,
+ tab.nativeTab
+ );
+ },
+ async updateAttachment(tabId, attachmentId, data) {
+ let tab = await getComposeTab(tabId);
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment, window } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ if (window != tab.nativeTab) {
+ throw new ExtensionError(
+ `Attachment ${attachmentId} is not associated with tab ${tabId}`
+ );
+ }
+
+ let attachmentItem =
+ window.gAttachmentBucket.findItemForAttachment(attachment);
+ if (!attachmentItem) {
+ throw new ExtensionError(`Unexpected invalid attachment item`);
+ }
+
+ if (!data.file && !data.name) {
+ throw new ExtensionError(
+ `Either data.file or data.name property must be specified`
+ );
+ }
+
+ let realFile = data.file ? await getRealFileForFile(data.file) : null;
+ try {
+ await window.UpdateAttachment(attachmentItem, {
+ file: realFile,
+ name: data.name,
+ relatedCloudFileUpload: attachmentItem.cloudFileUpload,
+ });
+ } catch (ex) {
+ throw new ExtensionError(ex.message);
+ }
+
+ return composeAttachmentTracker.convert(attachmentItem.attachment);
+ },
+ async removeAttachment(tabId, attachmentId) {
+ let tab = await getComposeTab(tabId);
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment, window } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ if (window != tab.nativeTab) {
+ throw new ExtensionError(
+ `Attachment ${attachmentId} is not associated with tab ${tabId}`
+ );
+ }
+
+ let item = window.gAttachmentBucket.findItemForAttachment(attachment);
+ await window.RemoveAttachments([item]);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-composeAction.js b/comm/mail/components/extensions/parent/ext-composeAction.js
new file mode 100644
index 0000000000..fb2a462d33
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-composeAction.js
@@ -0,0 +1,154 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ToolbarButtonAPI",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+const composeActionMap = new WeakMap();
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+this.composeAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return composeActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ composeActionMap.set(this.extension, this);
+ }
+
+ close() {
+ super.close();
+ composeActionMap.delete(this.extension);
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name = "compose_action";
+ this.manifestName = "composeAction";
+ this.manifest = extension.manifest[this.manifest_name];
+ this.moduleName = this.manifestName;
+
+ this.windowURLs = [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ];
+ let isFormatToolbar =
+ extension.manifest.compose_action.default_area == "formattoolbar";
+ this.toolboxId = isFormatToolbar ? "FormatToolbox" : "compose-toolbox";
+ this.toolbarId = isFormatToolbar ? "FormatToolbar" : "composeToolbar2";
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-composeAction-toolbarbutton`;
+ let windowURL =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+
+ // Check all possible toolbars and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let toolbars = ["composeToolbar2", "FormatToolbar"];
+ for (let toolbar of toolbars) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(windowURL, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = [
+ "format-toolbar-context-menu",
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ ];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ tab: window,
+ pageUrl: window.browser.currentURI.spec,
+ extension: this.extension,
+ onComposeAction: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == "composeAction" &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ global.actionContextMenu({
+ tab: window,
+ pageUrl: window.browser.currentURI.spec,
+ extension: this.extension,
+ inComposeActionMenu: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+
+ makeButton(window) {
+ let button = super.makeButton(window);
+ if (this.toolbarId == "FormatToolbar") {
+ button.classList.add("formatting-button");
+ // The format toolbar has no associated context menu. Add one directly to
+ // this button.
+ button.setAttribute("context", "format-toolbar-context-menu");
+ }
+ return button;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ let before = toolbar.lastElementChild;
+ while (before.localName == "spacer") {
+ before = before.previousElementSibling;
+ }
+ return before.nextElementSibling;
+ }
+};
+
+global.composeActionFor = this.composeAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-extensionScripts.js b/comm/mail/components/extensions/parent/ext-extensionScripts.js
new file mode 100644
index 0000000000..ef5da07586
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-extensionScripts.js
@@ -0,0 +1,185 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { getUniqueId } = ExtensionUtils;
+
+let scripts = new Set();
+
+ExtensionSupport.registerWindowListener("ext-composeScripts", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow: async win => {
+ await new Promise(resolve =>
+ win.addEventListener("compose-editor-ready", resolve, { once: true })
+ );
+ for (let script of scripts) {
+ if (script.type == "compose") {
+ script.executeInWindow(
+ win,
+ script.extension.tabManager.getWrapper(win)
+ );
+ }
+ }
+ },
+});
+
+ExtensionSupport.registerWindowListener("ext-messageDisplayScripts", {
+ chromeURLs: [
+ "chrome://messenger/content/messageWindow.xhtml",
+ "chrome://messenger/content/messenger.xhtml",
+ ],
+ onLoadWindow(win) {
+ win.addEventListener("MsgLoaded", event => {
+ // `event.target` is an about:message window.
+ let nativeTab = event.target.tabOrWindow;
+ for (let script of scripts) {
+ if (script.type == "messageDisplay") {
+ script.executeInWindow(
+ win,
+ script.extension.tabManager.wrapTab(nativeTab)
+ );
+ }
+ }
+ });
+ },
+});
+
+/**
+ * Represents (in the main browser process) a script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ * The parent proxy context related to the extension context which
+ * has registered the script.
+ * @param {RegisteredScriptOptions} details
+ * The options object related to the registered script
+ * (which has the properties described in the extensionScripts.json
+ * JSON API schema file).
+ */
+class ExtensionScriptParent {
+ constructor(type, context, details) {
+ this.type = type;
+ this.context = context;
+ this.extension = context.extension;
+ this.scriptId = getUniqueId();
+
+ this.options = this._convertOptions(details);
+ context.callOnClose(this);
+
+ scripts.add(this);
+ }
+
+ close() {
+ this.destroy();
+ }
+
+ destroy() {
+ if (this.destroyed) {
+ throw new ExtensionError("Unable to destroy ExtensionScriptParent twice");
+ }
+
+ scripts.delete(this);
+
+ this.destroyed = true;
+ this.context.forgetOnClose(this);
+ this.context = null;
+ this.options = null;
+ }
+
+ _convertOptions(details) {
+ const options = {
+ js: [],
+ css: [],
+ };
+
+ if (details.js && details.js.length) {
+ options.js = details.js.map(data => {
+ return {
+ code: data.code || null,
+ file: data.file || null,
+ };
+ });
+ }
+
+ if (details.css && details.css.length) {
+ options.css = details.css.map(data => {
+ return {
+ code: data.code || null,
+ file: data.file || null,
+ };
+ });
+ }
+
+ return options;
+ }
+
+ async executeInWindow(window, tab) {
+ for (let css of this.options.css) {
+ await tab.insertCSS(this.context, { ...css, frameId: null });
+ }
+ for (let js of this.options.js) {
+ await tab.executeScript(this.context, { ...js, frameId: null });
+ }
+ window.dispatchEvent(new window.CustomEvent("extension-scripts-added"));
+ }
+}
+
+this.extensionScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ // Map of the script registered from the extension context.
+ //
+ // Map<scriptId -> ExtensionScriptParent>
+ const parentScriptsMap = new Map();
+
+ // Unregister all the scriptId related to a context when it is closed.
+ context.callOnClose({
+ close() {
+ for (let script of parentScriptsMap.values()) {
+ script.destroy();
+ }
+ parentScriptsMap.clear();
+ },
+ });
+
+ return {
+ extensionScripts: {
+ async register(type, details) {
+ const script = new ExtensionScriptParent(type, context, details);
+ const { scriptId } = script;
+
+ parentScriptsMap.set(scriptId, script);
+ return scriptId;
+ },
+
+ // This method is not available to the extension code, the extension code
+ // doesn't have access to the internally used scriptId, on the contrary
+ // the extension code will call script.unregister on the script API object
+ // that is resolved from the register API method returned promise.
+ async unregister(scriptId) {
+ const script = parentScriptsMap.get(scriptId);
+ if (!script) {
+ console.error(new ExtensionError(`No such script ID: ${scriptId}`));
+
+ return;
+ }
+
+ parentScriptsMap.delete(scriptId);
+ script.destroy();
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-folders.js b/comm/mail/components/extensions/parent/ext-folders.js
new file mode 100644
index 0000000000..63704b9dd7
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-folders.js
@@ -0,0 +1,675 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+/**
+ * Tracks folder events.
+ *
+ * @implements {nsIMsgFolderListener}
+ */
+var folderTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.pendingInfoNotifications = new ExtensionUtils.DefaultMap(
+ () => new Map()
+ );
+ this.deferredInfoNotifications = new ExtensionUtils.DefaultMap(
+ folder =>
+ new DeferredTask(
+ () => this.emitPendingInfoNotification(folder),
+ NOTIFICATION_COLLAPSE_TIME
+ )
+ );
+ }
+
+ on(...args) {
+ super.on(...args);
+ this.incrementListeners();
+ }
+
+ off(...args) {
+ super.off(...args);
+ this.decrementListeners();
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ // nsIMsgFolderListener
+ const flags =
+ MailServices.mfn.folderAdded |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed;
+ MailServices.mfn.addListener(this, flags);
+ // nsIFolderListener
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.intPropertyChanged
+ );
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ MailServices.mfn.removeListener(this);
+ MailServices.mailSession.RemoveFolderListener(this);
+ }
+ }
+
+ // nsIFolderListener
+
+ onFolderIntPropertyChanged(item, property, oldValue, newValue) {
+ if (!(item instanceof Ci.nsIMsgFolder)) {
+ return;
+ }
+
+ switch (property) {
+ case "FolderFlag":
+ if (
+ (oldValue & Ci.nsMsgFolderFlags.Favorite) !=
+ (newValue & Ci.nsMsgFolderFlags.Favorite)
+ ) {
+ this.addPendingInfoNotification(
+ item,
+ "favorite",
+ !!(newValue & Ci.nsMsgFolderFlags.Favorite)
+ );
+ }
+ break;
+ case "TotalMessages":
+ this.addPendingInfoNotification(item, "totalMessageCount", newValue);
+ break;
+ case "TotalUnreadMessages":
+ this.addPendingInfoNotification(item, "unreadMessageCount", newValue);
+ break;
+ }
+ }
+
+ addPendingInfoNotification(folder, key, value) {
+ // If there is already a notification entry, decide if it must be emitted,
+ // or if it can be collapsed: Message count changes can be collapsed.
+ // This also collapses multiple different notifications types into a
+ // single event.
+ if (
+ ["favorite"].includes(key) &&
+ this.deferredInfoNotifications.has(folder) &&
+ this.pendingInfoNotifications.get(folder).has(key)
+ ) {
+ this.deferredInfoNotifications.get(folder).disarm();
+ this.emitPendingInfoNotification(folder);
+ }
+
+ this.pendingInfoNotifications.get(folder).set(key, value);
+ this.deferredInfoNotifications.get(folder).disarm();
+ this.deferredInfoNotifications.get(folder).arm();
+ }
+
+ emitPendingInfoNotification(folder) {
+ let folderInfo = this.pendingInfoNotifications.get(folder);
+ if (folderInfo.size > 0) {
+ this.emit(
+ "folder-info-changed",
+ convertFolder(folder),
+ Object.fromEntries(folderInfo)
+ );
+ this.pendingInfoNotifications.delete(folder);
+ }
+ }
+
+ // nsIMsgFolderListener
+
+ folderAdded(childFolder) {
+ this.emit("folder-created", convertFolder(childFolder));
+ }
+ folderDeleted(oldFolder) {
+ // Deleting an account, will trigger delete notifications for its folders,
+ // but the account lookup fails, so skip them.
+ let server = oldFolder.server;
+ let account = MailServices.accounts.FindAccountForServer(server);
+ if (account) {
+ this.emit("folder-deleted", convertFolder(oldFolder, account.key));
+ }
+ }
+ folderMoveCopyCompleted(move, srcFolder, targetFolder) {
+ // targetFolder is not the copied/moved folder, but its parent. Find the
+ // actual folder by its name (which is unique).
+ let dstFolder = null;
+ if (targetFolder && targetFolder.hasSubFolders) {
+ dstFolder = targetFolder.subFolders.find(
+ f => f.prettyName == srcFolder.prettyName
+ );
+ }
+
+ if (move) {
+ this.emit(
+ "folder-moved",
+ convertFolder(srcFolder),
+ convertFolder(dstFolder)
+ );
+ } else {
+ this.emit(
+ "folder-copied",
+ convertFolder(srcFolder),
+ convertFolder(dstFolder)
+ );
+ }
+ }
+ folderRenamed(oldFolder, newFolder) {
+ this.emit(
+ "folder-renamed",
+ convertFolder(oldFolder),
+ convertFolder(newFolder)
+ );
+ }
+})();
+
+/**
+ * Accepts a MailFolder or a MailAccount and returns the actual folder and its
+ * accountId. Throws if the requested folder does not exist.
+ */
+function getFolder({ accountId, path, id }) {
+ if (id && !path && !accountId) {
+ accountId = id;
+ path = "/";
+ }
+
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ return { folder, accountId };
+}
+
+/**
+ * Copy or Move a folder.
+ */
+async function doMoveCopyOperation(source, destination, isMove) {
+ // The schema file allows destination to be either a MailFolder or a
+ // MailAccount.
+ let srcFolder = getFolder(source);
+ let dstFolder = getFolder(destination);
+
+ if (
+ srcFolder.folder.server.type == "nntp" ||
+ dstFolder.folder.server.type == "nntp"
+ ) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() is not supported in news accounts`
+ );
+ }
+
+ if (
+ dstFolder.folder.hasSubFolders &&
+ dstFolder.folder.subFolders.find(
+ f => f.prettyName == srcFolder.folder.prettyName
+ )
+ ) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() failed, because ${
+ srcFolder.folder.prettyName
+ } already exists in ${folderURIToPath(
+ dstFolder.accountId,
+ dstFolder.folder.URI
+ )}`
+ );
+ }
+
+ let rv = await new Promise(resolve => {
+ let _destination = null;
+ const listener = {
+ folderMoveCopyCompleted(_isMove, _srcFolder, _dstFolder) {
+ if (
+ _destination != null ||
+ _isMove != isMove ||
+ _srcFolder.URI != srcFolder.folder.URI ||
+ _dstFolder.URI != dstFolder.folder.URI
+ ) {
+ return;
+ }
+
+ // The targetFolder is not the copied/moved folder, but its parent.
+ // Find the actual folder by its name (which is unique).
+ if (_dstFolder && _dstFolder.hasSubFolders) {
+ _destination = _dstFolder.subFolders.find(
+ f => f.prettyName == _srcFolder.prettyName
+ );
+ }
+ },
+ };
+ MailServices.mfn.addListener(
+ listener,
+ MailServices.mfn.folderMoveCopyCompleted
+ );
+ MailServices.copy.copyFolder(
+ srcFolder.folder,
+ dstFolder.folder,
+ isMove,
+ {
+ OnStartCopy() {},
+ OnProgress() {},
+ SetMessageKey() {},
+ GetMessageId() {},
+ OnStopCopy(status) {
+ MailServices.mfn.removeListener(listener);
+ resolve({
+ status,
+ folder: _destination,
+ });
+ },
+ },
+ null
+ );
+ });
+
+ if (!Components.isSuccessCode(rv.status)) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() failed for unknown reasons`
+ );
+ }
+
+ return convertFolder(rv.folder, dstFolder.accountId);
+}
+
+/**
+ * Wait for a folder operation.
+ */
+function waitForOperation(flags, uri) {
+ return new Promise(resolve => {
+ MailServices.mfn.addListener(
+ {
+ folderAdded(childFolder) {
+ if (childFolder.parent.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(childFolder);
+ },
+ folderDeleted(oldFolder) {
+ if (oldFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve();
+ },
+ folderMoveCopyCompleted(move, srcFolder, destFolder) {
+ if (srcFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(destFolder);
+ },
+ folderRenamed(oldFolder, newFolder) {
+ if (oldFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(newFolder);
+ },
+ },
+ flags
+ );
+ });
+}
+
+this.folders = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(event, createdMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(createdMailFolder);
+ }
+ folderTracker.on("folder-created", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onRenamed({ context, fire }) {
+ async function listener(event, originalMailFolder, renamedMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(originalMailFolder, renamedMailFolder);
+ }
+ folderTracker.on("folder-renamed", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-renamed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ async function listener(event, srcMailFolder, dstMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcMailFolder, dstMailFolder);
+ }
+ folderTracker.on("folder-moved", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ async function listener(event, srcMailFolder, dstMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcMailFolder, dstMailFolder);
+ }
+ folderTracker.on("folder-copied", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(event, deletedMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedMailFolder);
+ }
+ folderTracker.on("folder-deleted", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onFolderInfoChanged({ context, fire }) {
+ async function listener(event, changedMailFolder, mailFolderInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(changedMailFolder, mailFolderInfo);
+ }
+ folderTracker.on("folder-info-changed", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-info-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ return {
+ folders: {
+ onCreated: new EventManager({
+ context,
+ module: "folders",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onRenamed: new EventManager({
+ context,
+ module: "folders",
+ event: "onRenamed",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "folders",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "folders",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "folders",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ onFolderInfoChanged: new EventManager({
+ context,
+ module: "folders",
+ event: "onFolderInfoChanged",
+ extensionApi: this,
+ }).api(),
+ async create(parent, childName) {
+ // The schema file allows parent to be either a MailFolder or a
+ // MailAccount.
+ let { folder: parentFolder, accountId } = getFolder(parent);
+
+ if (
+ parentFolder.hasSubFolders &&
+ parentFolder.subFolders.find(f => f.prettyName == childName)
+ ) {
+ throw new ExtensionError(
+ `folders.create() failed, because ${childName} already exists in ${folderURIToPath(
+ accountId,
+ parentFolder.URI
+ )}`
+ );
+ }
+
+ let childFolderPromise = waitForOperation(
+ MailServices.mfn.folderAdded,
+ parentFolder.URI
+ );
+ parentFolder.createSubfolder(childName, null);
+
+ let childFolder = await childFolderPromise;
+ return convertFolder(childFolder, accountId);
+ },
+ async rename({ accountId, path }, newName) {
+ let { folder } = getFolder({ accountId, path });
+
+ if (!folder.parent) {
+ throw new ExtensionError(
+ `folders.rename() failed, because it cannot rename the root of the account`
+ );
+ }
+ if (folder.server.type == "nntp") {
+ throw new ExtensionError(
+ `folders.rename() is not supported in news accounts`
+ );
+ }
+
+ if (folder.parent.subFolders.find(f => f.prettyName == newName)) {
+ throw new ExtensionError(
+ `folders.rename() failed, because ${newName} already exists in ${folderURIToPath(
+ accountId,
+ folder.parent.URI
+ )}`
+ );
+ }
+
+ let newFolderPromise = waitForOperation(
+ MailServices.mfn.folderRenamed,
+ folder.URI
+ );
+ folder.rename(newName, null);
+
+ let newFolder = await newFolderPromise;
+ return convertFolder(newFolder, accountId);
+ },
+ async move(source, destination) {
+ return doMoveCopyOperation(source, destination, true /* isMove */);
+ },
+ async copy(source, destination) {
+ return doMoveCopyOperation(source, destination, false /* isMove */);
+ },
+ async delete({ accountId, path }) {
+ if (
+ !context.extension.hasPermission("accountsFolders") ||
+ !context.extension.hasPermission("messagesDelete")
+ ) {
+ throw new ExtensionError(
+ 'Using folders.delete() requires the "accountsFolders" and the "messagesDelete" permission'
+ );
+ }
+
+ let { folder } = getFolder({ accountId, path });
+ if (folder.server.type == "nntp") {
+ throw new ExtensionError(
+ `folders.delete() is not supported in news accounts`
+ );
+ }
+
+ if (folder.server.type == "imap") {
+ let inTrash = false;
+ let parent = folder.parent;
+ while (!inTrash && parent) {
+ inTrash = parent.flags & Ci.nsMsgFolderFlags.Trash;
+ parent = parent.parent;
+ }
+ if (inTrash) {
+ // FixMe: The UI is not updated, the folder is still shown, only after
+ // a restart it is removed from trash.
+ let deletedPromise = new Promise(resolve => {
+ MailServices.imap.deleteFolder(
+ folder,
+ {
+ OnStartRunningUrl() {},
+ OnStopRunningUrl(url, status) {
+ resolve(status);
+ },
+ },
+ null
+ );
+ });
+ let status = await deletedPromise;
+ if (!Components.isSuccessCode(status)) {
+ throw new ExtensionError(
+ `folders.delete() failed for unknown reasons`
+ );
+ }
+ } else {
+ // FixMe: Accounts could have their trash folder outside of their
+ // own folder structure.
+ let trash = folder.server.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ );
+ let deletedPromise = new Promise(resolve => {
+ MailServices.imap.moveFolder(
+ folder,
+ trash,
+ {
+ OnStartRunningUrl() {},
+ OnStopRunningUrl(url, status) {
+ resolve(status);
+ },
+ },
+ null
+ );
+ });
+ let status = await deletedPromise;
+ if (!Components.isSuccessCode(status)) {
+ throw new ExtensionError(
+ `folders.delete() failed for unknown reasons`
+ );
+ }
+ }
+ } else {
+ let deletedPromise = waitForOperation(
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted,
+ folder.URI
+ );
+ folder.deleteSelf(null);
+ await deletedPromise;
+ }
+ },
+ async getFolderInfo({ accountId, path }) {
+ let { folder } = getFolder({ accountId, path });
+
+ let mailFolderInfo = {
+ favorite: folder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ totalMessageCount: folder.getTotalMessages(false),
+ unreadMessageCount: folder.getNumUnread(false),
+ };
+
+ return mailFolderInfo;
+ },
+ async getParentFolders({ accountId, path }, includeFolders) {
+ let { folder } = getFolder({ accountId, path });
+ let parentFolders = [];
+ // We do not consider the absolute root ("/") as a root folder, but
+ // the first real folders (all folders returned in MailAccount.folders
+ // are considered root folders).
+ while (folder.parent != null && folder.parent.parent != null) {
+ folder = folder.parent;
+
+ if (includeFolders) {
+ parentFolders.push(traverseSubfolders(folder, accountId));
+ } else {
+ parentFolders.push(convertFolder(folder, accountId));
+ }
+ }
+ return parentFolders;
+ },
+ async getSubFolders(accountOrFolder, includeFolders) {
+ let { folder, accountId } = getFolder(accountOrFolder);
+ let subFolders = [];
+ if (folder.hasSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ if (includeFolders) {
+ subFolders.push(traverseSubfolders(subFolder, accountId));
+ } else {
+ subFolders.push(convertFolder(subFolder, accountId));
+ }
+ }
+ }
+ return subFolders;
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-identities.js b/comm/mail/components/extensions/parent/ext-identities.js
new file mode 100644
index 0000000000..1b9e719ebe
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-identities.js
@@ -0,0 +1,360 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+function findIdentityAndAccount(identityId) {
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ return { account, identity };
+ }
+ }
+ }
+ return null;
+}
+
+function checkForProtectedProperties(details) {
+ const protectedProperties = ["id", "accountId"];
+ for (let [key, value] of Object.entries(details)) {
+ // Check only properties explicitly provided.
+ if (value != null && protectedProperties.includes(key)) {
+ throw new ExtensionError(
+ `Setting the ${key} property of a MailIdentity is not supported.`
+ );
+ }
+ }
+}
+
+function updateIdentity(identity, details) {
+ for (let [key, value] of Object.entries(details)) {
+ // Update only properties explicitly provided.
+ if (value == null) {
+ continue;
+ }
+ // Map from WebExtension property names to nsIMsgIdentity property names.
+ switch (key) {
+ case "signatureIsPlainText":
+ identity.htmlSigFormat = !value;
+ break;
+ case "name":
+ identity.fullName = value;
+ break;
+ case "signature":
+ identity.htmlSigText = value;
+ break;
+ default:
+ identity[key] = value;
+ }
+ }
+}
+
+/**
+ * @implements {nsIObserver}
+ */
+var identitiesTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+
+ this.identities = new Map();
+ this.deferredNotifications = new ExtensionUtils.DefaultMap(
+ key =>
+ new DeferredTask(
+ () => this.emitPendingNotification(key),
+ NOTIFICATION_COLLAPSE_TIME
+ )
+ );
+
+ // Keep track of identities and their values, to suppress superfluous
+ // update notifications. The deferredTask timer is used to collapse multiple
+ // update notifications.
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ this.identities.set(
+ identity.key,
+ convertMailIdentity(account, identity)
+ );
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ Services.prefs.addObserver("mail.identity.", this);
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ Services.prefs.removeObserver("mail.identity.", this);
+ }
+ }
+
+ emitPendingNotification(key) {
+ let ia = findIdentityAndAccount(key);
+ if (!ia) {
+ return;
+ }
+
+ let oldValues = this.identities.get(key);
+ let newValues = convertMailIdentity(ia.account, ia.identity);
+ let changedValues = {};
+ for (let propertyName of Object.keys(newValues)) {
+ if (
+ !oldValues.hasOwnProperty(propertyName) ||
+ oldValues[propertyName] != newValues[propertyName]
+ ) {
+ changedValues[propertyName] = newValues[propertyName];
+ }
+ }
+ if (Object.keys(changedValues).length > 0) {
+ changedValues.accountId = ia.account.key;
+ changedValues.id = ia.identity.key;
+ let notification =
+ Object.keys(oldValues).length == 0
+ ? "account-identity-added"
+ : "account-identity-updated";
+ this.identities.set(key, newValues);
+ this.emit(notification, key, changedValues);
+ }
+ }
+
+ // nsIObserver
+ _notifications = ["account-identity-added", "account-identity-removed"];
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "account-identity-added":
+ {
+ let key = data;
+ this.identities.set(key, {});
+ this.deferredNotifications.get(key).arm();
+ }
+ break;
+
+ case "nsPref:changed":
+ {
+ let key = data.split(".").slice(2, 3).pop();
+
+ // Ignore update notifications for created identities, before they are
+ // added to an account (looks like they are cloned from a default
+ // identity). Also ignore notifications for deleted identities.
+ if (
+ key &&
+ this.identities.has(key) &&
+ this.identities.get(key) != null
+ ) {
+ this.deferredNotifications.get(key).disarm();
+ this.deferredNotifications.get(key).arm();
+ }
+ }
+ break;
+
+ case "account-identity-removed":
+ {
+ let key = data;
+ if (
+ key &&
+ this.identities.has(key) &&
+ this.identities.get(key) != null
+ ) {
+ // Mark identities as deleted instead of removing them.
+ this.identities.set(key, null);
+ // Force any pending notification to be emitted.
+ await this.deferredNotifications.get(key).finalize();
+
+ this.emit("account-identity-removed", key);
+ }
+ }
+ break;
+ }
+ }
+})();
+
+this.identities = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(event, key, identity) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, identity);
+ }
+ identitiesTracker.on("account-identity-added", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ async function listener(event, key, changedValues) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, changedValues);
+ }
+ identitiesTracker.on("account-identity-updated", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(event, key) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key);
+ }
+ identitiesTracker.on("account-identity-removed", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ identitiesTracker.incrementListeners();
+ }
+
+ onShutdown() {
+ identitiesTracker.decrementListeners();
+ }
+
+ getAPI(context) {
+ return {
+ identities: {
+ async list(accountId) {
+ let accounts = accountId
+ ? [MailServices.accounts.getAccount(accountId)]
+ : MailServices.accounts.accounts;
+
+ let identities = [];
+ for (let account of accounts) {
+ for (let identity of account.identities) {
+ identities.push(convertMailIdentity(account, identity));
+ }
+ }
+ return identities;
+ },
+ async get(identityId) {
+ let ia = findIdentityAndAccount(identityId);
+ return ia ? convertMailIdentity(ia.account, ia.identity) : null;
+ },
+ async delete(identityId) {
+ let ia = findIdentityAndAccount(identityId);
+ if (!ia) {
+ throw new ExtensionError(`Identity not found: ${identityId}`);
+ }
+ if (
+ ia.account?.defaultIdentity &&
+ ia.account.defaultIdentity.key == ia.identity.key
+ ) {
+ throw new ExtensionError(
+ `Identity ${identityId} is the default identity of account ${ia.account.key} and cannot be deleted`
+ );
+ }
+ ia.account.removeIdentity(ia.identity);
+ },
+ async create(accountId, details) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ // Abort and throw, if details include protected properties.
+ checkForProtectedProperties(details);
+
+ let identity = MailServices.accounts.createIdentity();
+ updateIdentity(identity, details);
+ account.addIdentity(identity);
+ return convertMailIdentity(account, identity);
+ },
+ async update(identityId, details) {
+ let ia = findIdentityAndAccount(identityId);
+ if (!ia) {
+ throw new ExtensionError(`Identity not found: ${identityId}`);
+ }
+ // Abort and throw, if details include protected properties.
+ checkForProtectedProperties(details);
+
+ updateIdentity(ia.identity, details);
+ return convertMailIdentity(ia.account, ia.identity);
+ },
+ async getDefault(accountId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertMailIdentity(account, account?.defaultIdentity);
+ },
+ async setDefault(accountId, identityId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ account.defaultIdentity = identity;
+ return;
+ }
+ }
+ throw new ExtensionError(
+ `Identity ${identityId} not found for ${accountId}`
+ );
+ },
+ onCreated: new EventManager({
+ context,
+ module: "identities",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "identities",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "identities",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-mail.js b/comm/mail/components/extensions/parent/ext-mail.js
new file mode 100644
index 0000000000..31e86fe7b4
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-mail.js
@@ -0,0 +1,2883 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var { ExtensionError, getInnerWindowID } = ExtensionUtils;
+var { defineLazyGetter, makeWidgetId } = ExtensionCommon;
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MailServices: "resource:///modules/MailServices.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gJunkThreshold",
+ "mail.adaptivefilters.junk_threshold",
+ 90
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gMessagesPerPage",
+ "extensions.webextensions.messagesPerPage",
+ 100
+);
+XPCOMUtils.defineLazyGlobalGetters(this, [
+ "IOUtils",
+ "PathUtils",
+ "FileReader",
+]);
+
+const MAIN_WINDOW_URI = "chrome://messenger/content/messenger.xhtml";
+const POPUP_WINDOW_URI = "chrome://messenger/content/extensionPopup.xhtml";
+const COMPOSE_WINDOW_URI =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+const MESSAGE_WINDOW_URI = "chrome://messenger/content/messageWindow.xhtml";
+const MESSAGE_PROTOCOLS = ["imap", "mailbox", "news", "nntp", "snews"];
+
+const NOTIFICATION_COLLAPSE_TIME = 200;
+
+(function () {
+ // Monkey-patch all processes to add the "messenger" alias in all contexts.
+ Services.ppmm.loadProcessScript(
+ "chrome://messenger/content/processScript.js",
+ true
+ );
+
+ // This allows scripts to run in the compose document or message display
+ // document if and only if the extension has permission.
+ let { defaultConstructor } = ExtensionContent.contentScripts;
+ ExtensionContent.contentScripts.defaultConstructor = function (matcher) {
+ let script = defaultConstructor.call(this, matcher);
+
+ let { matchesWindowGlobal } = script;
+ script.matchesWindowGlobal = function (windowGlobal) {
+ let { browsingContext, windowContext } = windowGlobal;
+
+ if (
+ browsingContext.topChromeWindow?.location.href == COMPOSE_WINDOW_URI &&
+ windowContext.documentPrincipal.isNullPrincipal &&
+ windowContext.documentURI?.spec == "about:blank?compose"
+ ) {
+ return script.extension.hasPermission("compose");
+ }
+
+ if (MESSAGE_PROTOCOLS.includes(windowContext.documentURI?.scheme)) {
+ return script.extension.hasPermission("messagesModify");
+ }
+
+ return matchesWindowGlobal.apply(script, arguments);
+ };
+
+ return script;
+ };
+})();
+
+let tabTracker;
+let spaceTracker;
+let windowTracker;
+
+// This function is pretty tightly tied to Extension.jsm.
+// Its job is to fill in the |tab| property of the sender.
+const getSender = (extension, target, sender) => {
+ let tabId = -1;
+ if ("tabId" in sender) {
+ // The message came from a privileged extension page running in a tab. In
+ // that case, it should include a tabId property (which is filled in by the
+ // page-open listener below).
+ tabId = sender.tabId;
+ delete sender.tabId;
+ } else if (
+ ExtensionCommon.instanceOf(target, "XULFrameElement") ||
+ ExtensionCommon.instanceOf(target, "HTMLIFrameElement")
+ ) {
+ tabId = tabTracker.getBrowserData(target).tabId;
+ }
+
+ if (tabId != null && tabId >= 0) {
+ let tab = extension.tabManager.get(tabId, null);
+ if (tab) {
+ sender.tab = tab.convert();
+ }
+ }
+};
+
+// Used by Extension.jsm.
+global.tabGetSender = getSender;
+
+global.clickModifiersFromEvent = event => {
+ const map = {
+ shiftKey: "Shift",
+ altKey: "Alt",
+ metaKey: "Command",
+ ctrlKey: "Ctrl",
+ };
+ let modifiers = Object.keys(map)
+ .filter(key => event[key])
+ .map(key => map[key]);
+
+ if (event.ctrlKey && AppConstants.platform === "macosx") {
+ modifiers.push("MacCtrl");
+ }
+
+ return modifiers;
+};
+
+global.openOptionsPage = extension => {
+ let window = windowTracker.topNormalWindow;
+ if (!window) {
+ return Promise.reject({ message: "No mail window available" });
+ }
+
+ if (extension.manifest.options_ui.open_in_tab) {
+ window.switchToTabHavingURI(extension.manifest.options_ui.page, true, {
+ triggeringPrincipal: extension.principal,
+ });
+ return Promise.resolve();
+ }
+
+ let viewId = `addons://detail/${encodeURIComponent(
+ extension.id
+ )}/preferences`;
+
+ return window.openAddonsMgr(viewId);
+};
+
+/**
+ * Returns a real file for the given DOM File.
+ *
+ * @param {File} file - the DOM File
+ * @returns {nsIFile}
+ */
+async function getRealFileForFile(file) {
+ if (file.mozFullPath) {
+ let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ realFile.initWithPath(file.mozFullPath);
+ return realFile;
+ }
+
+ let pathTempFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ file.name.replaceAll(/[/:*?\"<>|]/g, "_"),
+ 0o600
+ );
+
+ let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tempFile.initWithPath(pathTempFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let bytes = await new Promise(function (resolve) {
+ let reader = new FileReader();
+ reader.onloadend = function () {
+ resolve(new Uint8Array(reader.result));
+ };
+ reader.readAsArrayBuffer(file);
+ });
+
+ await IOUtils.write(pathTempFile, bytes);
+ return tempFile;
+}
+
+/**
+ * Gets the window for a tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {Window} - The browser element for the tab
+ */
+function getTabWindow(nativeTabInfo) {
+ return Cu.getGlobalForObject(nativeTabInfo);
+}
+global.getTabWindow = getTabWindow;
+
+/**
+ * Gets the tabmail for a tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {?XULElement} - The browser element for the tab
+ */
+function getTabTabmail(nativeTabInfo) {
+ return getTabWindow(nativeTabInfo).document.getElementById("tabmail");
+}
+global.getTabTabmail = getTabTabmail;
+
+/**
+ * Gets the tab browser for the tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {?XULElement} The browser element for the tab
+ */
+function getTabBrowser(nativeTabInfo) {
+ if (!nativeTabInfo) {
+ return null;
+ }
+
+ if (nativeTabInfo.mode) {
+ if (nativeTabInfo.mode.getBrowser) {
+ return nativeTabInfo.mode.getBrowser(nativeTabInfo);
+ }
+
+ if (nativeTabInfo.mode.tabType.getBrowser) {
+ return nativeTabInfo.mode.tabType.getBrowser(nativeTabInfo);
+ }
+ }
+
+ if (nativeTabInfo.ownerGlobal && nativeTabInfo.ownerGlobal.getBrowser) {
+ return nativeTabInfo.ownerGlobal.getBrowser();
+ }
+
+ return null;
+}
+global.getTabBrowser = getTabBrowser;
+
+/**
+ * Manages tab-specific and window-specific context data, and dispatches
+ * tab select events across all windows.
+ */
+global.TabContext = class extends EventEmitter {
+ /**
+ * @param {Function} getDefaultPrototype
+ * Provides the prototype of the context value for a tab or window when there is none.
+ * Called with a XULElement or ChromeWindow argument.
+ * Should return an object or null.
+ */
+ constructor(getDefaultPrototype) {
+ super();
+ this.getDefaultPrototype = getDefaultPrototype;
+ this.tabData = new WeakMap();
+ }
+
+ /**
+ * Returns the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ * @returns {object}
+ */
+ get(keyObject) {
+ if (!this.tabData.has(keyObject)) {
+ let data = Object.create(this.getDefaultPrototype(keyObject));
+ this.tabData.set(keyObject, data);
+ }
+
+ return this.tabData.get(keyObject);
+ }
+
+ /**
+ * Clears the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ */
+ clear(keyObject) {
+ this.tabData.delete(keyObject);
+ }
+};
+
+/* global searchInitialized */
+// This promise is used to wait for the search service to be initialized.
+// None of the code in the WebExtension modules requests that initialization.
+// It is assumed that it is started at some point. That might never happen,
+// e.g. if the application shuts down before the search service initializes.
+XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
+ if (Services.search.isInitialized) {
+ return Promise.resolve();
+ }
+ return ExtensionUtils.promiseObserved(
+ "browser-search-service",
+ (_, data) => data == "init-complete"
+ );
+});
+
+/**
+ * Class for dummy message Headers.
+ */
+class nsDummyMsgHeader {
+ constructor(msgHdr) {
+ this.mProperties = [];
+ this.messageSize = 0;
+ this.author = null;
+ this.subject = "";
+ this.recipients = null;
+ this.ccList = null;
+ this.listPost = null;
+ this.messageId = null;
+ this.date = 0;
+ this.accountKey = "";
+ this.flags = 0;
+ // If you change us to return a fake folder, please update
+ // folderDisplay.js's FolderDisplayWidget's selectedMessageIsExternal getter.
+ this.folder = null;
+
+ if (msgHdr) {
+ for (let member of [
+ "accountKey",
+ "ccList",
+ "date",
+ "flags",
+ "listPost",
+ "messageId",
+ "messageSize",
+ ]) {
+ // Members are either (associative) arrays or primitives.
+ if (typeof msgHdr[member] == "object") {
+ this[member] = [];
+ for (let property in msgHdr[member]) {
+ this[member][property] = msgHdr[member][property];
+ }
+ } else {
+ this[member] = msgHdr[member];
+ }
+ }
+ this.author = msgHdr.mime2DecodedAuthor;
+ this.recipients = msgHdr.mime2DecodedRecipients;
+ this.subject = msgHdr.mime2DecodedSubject;
+ this.mProperties.dummyMsgUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ this.mProperties.dummyMsgLastModifiedTime = msgHdr.getUint32Property(
+ "dummyMsgLastModifiedTime"
+ );
+ }
+ }
+ getProperty(aProperty) {
+ return this.getStringProperty(aProperty);
+ }
+ setProperty(aProperty, aVal) {
+ return this.setStringProperty(aProperty, aVal);
+ }
+ getStringProperty(aProperty) {
+ if (aProperty in this.mProperties) {
+ return this.mProperties[aProperty];
+ }
+ return "";
+ }
+ setStringProperty(aProperty, aVal) {
+ this.mProperties[aProperty] = aVal;
+ }
+ getUint32Property(aProperty) {
+ if (aProperty in this.mProperties) {
+ return parseInt(this.mProperties[aProperty]);
+ }
+ return 0;
+ }
+ setUint32Property(aProperty, aVal) {
+ this.mProperties[aProperty] = aVal.toString();
+ }
+ markHasAttachments(hasAttachments) {}
+ get mime2DecodedAuthor() {
+ return this.author;
+ }
+ get mime2DecodedSubject() {
+ return this.subject;
+ }
+ get mime2DecodedRecipients() {
+ return this.recipients;
+ }
+}
+
+/**
+ * Returns the WebExtension window type for the given window, or null, if it is
+ * not supported.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {[string]} - The WebExtension type of the window
+ */
+function getWebExtensionWindowType(window) {
+ let { documentElement } = window.document;
+ if (!documentElement) {
+ return null;
+ }
+ switch (documentElement.getAttribute("windowtype")) {
+ case "msgcompose":
+ return "messageCompose";
+ case "mail:messageWindow":
+ return "messageDisplay";
+ case "mail:extensionPopup":
+ return "popup";
+ case "mail:3pane":
+ return "normal";
+ default:
+ return "unknown";
+ }
+}
+
+/**
+ * The window tracker tracks opening and closing Thunderbird windows. Each window has an id, which
+ * is mapped to native window objects.
+ */
+class WindowTracker extends WindowTrackerBase {
+ /**
+ * Adds a tab progress listener to the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window to which to add the listener.
+ * @param {object} listener - The listener to add
+ */
+ addProgressListener(window, listener) {
+ if (window.contentProgress) {
+ window.contentProgress.addListener(listener);
+ }
+ }
+
+ /**
+ * Removes a tab progress listener from the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window from which to remove the listener.
+ * @param {object} listener - The listener to remove
+ */
+ removeProgressListener(window, listener) {
+ if (window.contentProgress) {
+ window.contentProgress.removeListener(listener);
+ }
+ }
+
+ /**
+ * Determines if the passed window object is supported by the windows API. The
+ * function name is for base class compatibility with toolkit.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {boolean} True, if the window is supported by the windows API
+ */
+ isBrowserWindow(window) {
+ let type = getWebExtensionWindowType(window);
+ return !!type && type != "unknown";
+ }
+
+ /**
+ * Determines if the passed window object is a mail window but not the main
+ * window. This is useful to find windows where the window itself is the
+ * "nativeTab" object in API terms.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {boolean} True, if the window is a mail window but not the main window
+ */
+ isSecondaryWindow(window) {
+ let { documentElement } = window.document;
+ if (!documentElement) {
+ return false;
+ }
+
+ return ["msgcompose", "mail:messageWindow", "mail:extensionPopup"].includes(
+ documentElement.getAttribute("windowtype")
+ );
+ }
+
+ /**
+ * The currently active, or topmost window supported by the API, or null if no
+ * supported window is currently open.
+ *
+ * @property {?DOMWindow} topWindow
+ * @readonly
+ */
+ get topWindow() {
+ let win = Services.wm.getMostRecentWindow(null);
+ // If we're lucky, this is a window supported by the API and we can return it
+ // directly.
+ if (win && !this.isBrowserWindow(win)) {
+ win = null;
+ // This is oldest to newest, so this gets a bit ugly.
+ for (let nextWin of Services.wm.getEnumerator(null)) {
+ if (this.isBrowserWindow(nextWin)) {
+ win = nextWin;
+ }
+ }
+ }
+ return win;
+ }
+
+ /**
+ * The currently active, or topmost window, or null if no window is currently open, that
+ * is not private browsing.
+ *
+ * @property {DOMWindow|null} topWindow
+ * @readonly
+ */
+ get topNonPBWindow() {
+ // Thunderbird does not support private browsing, return topWindow.
+ return this.topWindow;
+ }
+
+ /**
+ * The currently active, or topmost, mail window, or null if no mail window is currently open.
+ * Will only return the topmost "normal" (i.e., not popup) window.
+ *
+ * @property {?DOMWindow} topNormalWindow
+ * @readonly
+ */
+ get topNormalWindow() {
+ return Services.wm.getMostRecentWindow("mail:3pane");
+ }
+}
+
+/**
+ * Convenience class to keep track of and manage spaces.
+ */
+class SpaceTracker {
+ /**
+ * @typedef SpaceData
+ * @property {string} name - name of the space as used by the extension
+ * @property {integer} spaceId - id of the space as used by the tabs API
+ * @property {string} spaceButtonId - id of the button of this space in the
+ * spaces toolbar
+ * @property {string} defaultUrl - the url for the default space tab
+ * @property {ButtonProperties} buttonProperties
+ * @see mail/components/extensions/schemas/spaces.json
+ * @property {ExtensionData} extension - the extension the space belongs to
+ */
+
+ constructor() {
+ this._nextId = 1;
+ this._spaceData = new Map();
+ this._spaceIds = new Map();
+
+ // Keep this in sync with the default spaces in gSpacesToolbar.
+ let builtInSpaces = [
+ {
+ name: "mail",
+ spaceButtonId: "mailButton",
+ tabInSpace: tabInfo =>
+ ["folder", "mail3PaneTab", "mailMessageTab"].includes(
+ tabInfo.mode.name
+ )
+ ? 1
+ : 0,
+ },
+ {
+ name: "addressbook",
+ spaceButtonId: "addressBookButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "addressBookTab" ? 1 : 0),
+ },
+ {
+ name: "calendar",
+ spaceButtonId: "calendarButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "calendar" ? 1 : 0),
+ },
+ {
+ name: "tasks",
+ spaceButtonId: "tasksButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "tasks" ? 1 : 0),
+ },
+ {
+ name: "chat",
+ spaceButtonId: "chatButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "chat" ? 1 : 0),
+ },
+ {
+ name: "settings",
+ spaceButtonId: "settingsButton",
+ tabInSpace: tabInfo => {
+ switch (tabInfo.mode.name) {
+ case "preferencesTab":
+ // A primary tab that the open method creates.
+ return 1;
+ case "contentTab":
+ let url = tabInfo.urlbar?.value;
+ if (url == "about:accountsettings" || url == "about:addons") {
+ // A secondary tab, that is related to this space.
+ return 2;
+ }
+ }
+ return 0;
+ },
+ },
+ ];
+ for (let builtInSpace of builtInSpaces) {
+ this._add(builtInSpace);
+ }
+ }
+
+ findSpaceForTab(tabInfo) {
+ for (let spaceData of this._spaceData.values()) {
+ if (spaceData.tabInSpace(tabInfo)) {
+ return spaceData;
+ }
+ }
+ return undefined;
+ }
+
+ _add(spaceData) {
+ let spaceId = this._nextId++;
+ let { spaceButtonId } = spaceData;
+ this._spaceData.set(spaceButtonId, { ...spaceData, spaceId });
+ this._spaceIds.set(spaceId, spaceButtonId);
+ return { ...spaceData, spaceId };
+ }
+
+ /**
+ * Generate an id of the form <add-on-id>-spacesButton-<spaceId>.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {ExtensionData} extension
+ * @returns {string} id of the html element of the spaces toolbar button of
+ * this space
+ */
+ _getSpaceButtonId(name, extension) {
+ return `${makeWidgetId(extension.id)}-spacesButton-${name}`;
+ }
+
+ /**
+ * Get the SpaceData for the space with the given name for the given extension.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {ExtensionData} extension
+ * @returns {SpaceData}
+ */
+ fromSpaceName(name, extension) {
+ let spaceButtonId = this._getSpaceButtonId(name, extension);
+ return this.fromSpaceButtonId(spaceButtonId);
+ }
+
+ /**
+ * Get the SpaceData for the space with the given spaceId.
+ *
+ * @param {integer} spaceId - id of the space as used by the tabs API
+ * @returns {SpaceData}
+ */
+ fromSpaceId(spaceId) {
+ let spaceButtonId = this._spaceIds.get(spaceId);
+ return this.fromSpaceButtonId(spaceButtonId);
+ }
+
+ /**
+ * Get the SpaceData for the space with the given spaceButtonId.
+ *
+ * @param {string} spaceButtonId - id of the html element of a spaces toolbar
+ * button
+ * @returns {SpaceData}
+ */
+ fromSpaceButtonId(spaceButtonId) {
+ if (!spaceButtonId || !this._spaceData.has(spaceButtonId)) {
+ return null;
+ }
+ return this._spaceData.get(spaceButtonId);
+ }
+
+ /**
+ * Create a new space and return its SpaceData.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {string} defaultUrl - the url for the default space tab
+ * @param {ButtonProperties} buttonProperties
+ * @see mail/components/extensions/schemas/spaces.json
+ * @param {ExtensionData} extension - the extension the space belongs to
+ * @returns {SpaceData}
+ */
+ async create(name, defaultUrl, buttonProperties, extension) {
+ let spaceButtonId = this._getSpaceButtonId(name, extension);
+ if (this._spaceData.has(spaceButtonId)) {
+ return false;
+ }
+ return this._add({
+ name,
+ spaceButtonId,
+ tabInSpace: tabInfo => (tabInfo.spaceButtonId == spaceButtonId ? 1 : 0),
+ defaultUrl,
+ buttonProperties,
+ extension,
+ });
+ }
+
+ /**
+ * Return a WebExtension Space object, representing the given spaceData.
+ *
+ * @param {SpaceData} spaceData
+ * @returns {Space} - @see mail/components/extensions/schemas/spaces.json
+ */
+ convert(spaceData, extension) {
+ let space = {
+ id: spaceData.spaceId,
+ name: spaceData.name,
+ isBuiltIn: !spaceData.extension,
+ isSelfOwned: spaceData.extension?.id == extension.id,
+ };
+ if (spaceData.extension && extension.hasPermission("management")) {
+ space.extensionId = spaceData.extension.id;
+ }
+ return space;
+ }
+
+ /**
+ * Remove a space and its SpaceData from the tracker.
+ *
+ * @param {SpaceData} spaceData
+ */
+ remove(spaceData) {
+ if (!this._spaceData.has(spaceData.spaceButtonId)) {
+ return;
+ }
+ this._spaceData.delete(spaceData.spaceButtonId);
+ }
+
+ /**
+ * Update spaceData for a space in the tracker.
+ *
+ * @param {SpaceData} spaceData
+ */
+ update(spaceData) {
+ if (!this._spaceData.has(spaceData.spaceButtonId)) {
+ return;
+ }
+ this._spaceData.set(spaceData.spaceButtonId, spaceData);
+ }
+
+ /**
+ * Return the SpaceData of all spaces known to the tracker.
+ *
+ * @returns {SpaceData[]}
+ */
+ getAll() {
+ return this._spaceData.values();
+ }
+}
+
+/**
+ * Tracks the opening and closing of tabs and maps them between their numeric WebExtension ID and
+ * the native tab info objects.
+ */
+class TabTracker extends TabTrackerBase {
+ constructor() {
+ super();
+
+ this._tabs = new WeakMap();
+ this._browsers = new Map();
+ this._tabIds = new Map();
+ this._nextId = 1;
+ this._movingTabs = new Map();
+
+ this._handleTabDestroyed = this._handleTabDestroyed.bind(this);
+
+ ExtensionSupport.registerWindowListener("ext-sessions", {
+ chromeURLs: [MAIN_WINDOW_URI],
+ onLoadWindow(window) {
+ window.gTabmail.registerTabMonitor({
+ monitorName: "extensionSession",
+ onTabTitleChanged(aTab) {},
+ onTabClosing(aTab) {},
+ onTabPersist(aTab) {
+ return aTab._ext.extensionSession;
+ },
+ onTabRestored(aTab, aState) {
+ aTab._ext.extensionSession = aState;
+ },
+ onTabSwitched(aNewTab, aOldTab) {},
+ onTabOpened(aTab) {},
+ });
+ },
+ });
+ }
+
+ /**
+ * Initialize tab tracking listeners the first time that an event listener is added.
+ */
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ this._handleWindowOpen = this._handleWindowOpen.bind(this);
+ this._handleWindowClose = this._handleWindowClose.bind(this);
+
+ windowTracker.addListener("TabClose", this);
+ windowTracker.addListener("TabOpen", this);
+ windowTracker.addListener("TabSelect", this);
+ windowTracker.addOpenListener(this._handleWindowOpen);
+ windowTracker.addCloseListener(this._handleWindowClose);
+
+ /* eslint-disable mozilla/balanced-listeners */
+ this.on("tab-detached", this._handleTabDestroyed);
+ this.on("tab-removed", this._handleTabDestroyed);
+ /* eslint-enable mozilla/balanced-listeners */
+ }
+
+ /**
+ * Returns the numeric ID for the given native tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabmail tabInfo for which to return an ID
+ * @returns {Integer} The tab's numeric ID
+ */
+ getId(nativeTabInfo) {
+ let id = this._tabs.get(nativeTabInfo);
+ if (id) {
+ return id;
+ }
+
+ this.init();
+
+ id = this._nextId++;
+ this.setId(nativeTabInfo, id);
+ return id;
+ }
+
+ /**
+ * Returns the tab id corresponding to the given browser element.
+ *
+ * @param {XULElement} browser - The <browser> element to retrieve for
+ * @returns {Integer} The tab's numeric ID
+ */
+ getBrowserTabId(browser) {
+ let id = this._browsers.get(browser.browserId);
+ if (id) {
+ return id;
+ }
+
+ let window = browser.browsingContext.topChromeWindow;
+ let tabmail = window.document.getElementById("tabmail");
+ let tab = tabmail && tabmail.getTabForBrowser(browser);
+
+ if (tab) {
+ id = this.getId(tab);
+ this._browsers.set(browser.browserId, id);
+ return id;
+ }
+ if (windowTracker.isSecondaryWindow(window)) {
+ return this.getId(window);
+ }
+ return -1;
+ }
+
+ /**
+ * Records the tab information for the given tabInfo object.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info to record for
+ * @param {Integer} id - The tab id to record
+ */
+ setId(nativeTabInfo, id) {
+ this._tabs.set(nativeTabInfo, id);
+ let browser = getTabBrowser(nativeTabInfo);
+ if (browser) {
+ this._browsers.set(browser.browserId, id);
+ }
+ this._tabIds.set(id, nativeTabInfo);
+ }
+
+ /**
+ * Function to call when a tab was close, deletes tab information for the tab.
+ *
+ * @param {Event} event - The event triggering the detroyal
+ * @param {{ nativeTabInfo:NativeTabInfo}} - The object containing tab info
+ */
+ _handleTabDestroyed(event, { nativeTabInfo }) {
+ let id = this._tabs.get(nativeTabInfo);
+ if (id) {
+ this._tabs.delete(nativeTabInfo);
+ if (nativeTabInfo.browser) {
+ this._browsers.delete(nativeTabInfo.browser.browserId);
+ }
+ if (this._tabIds.get(id) === nativeTabInfo) {
+ this._tabIds.delete(id);
+ }
+ }
+ }
+
+ /**
+ * Returns the native tab with the given numeric ID.
+ *
+ * @param {Integer} tabId - The numeric ID of the tab to return.
+ * @param {*} default_ - The value to return if no tab exists with the given ID.
+ * @returns {NativeTabInfo} The tab information for the given id.
+ */
+ getTab(tabId, default_ = undefined) {
+ let nativeTabInfo = this._tabIds.get(tabId);
+ if (nativeTabInfo) {
+ return nativeTabInfo;
+ }
+ if (default_ !== undefined) {
+ return default_;
+ }
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+
+ /**
+ * Handles load events for recently-opened windows, and adds additional
+ * listeners which may only be safely added when the window is fully loaded.
+ *
+ * @param {Event} event - A DOM event to handle.
+ */
+ handleEvent(event) {
+ let nativeTabInfo = event.detail.tabInfo;
+
+ switch (event.type) {
+ case "TabOpen": {
+ // Save the current tab, since the newly-created tab will likely be
+ // active by the time the promise below resolves and the event is
+ // dispatched.
+ let tabmail = event.target.ownerDocument.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ // We need to delay sending this event until the next tick, since the
+ // tab does not have its final index when the TabOpen event is dispatched.
+ Promise.resolve().then(() => {
+ if (event.detail.moving) {
+ let srcTabId = this._movingTabs.get(event.detail.moving);
+ this.setId(nativeTabInfo, srcTabId);
+ this._movingTabs.delete(event.detail.moving);
+
+ this.emitAttached(nativeTabInfo);
+ } else {
+ this.emitCreated(nativeTabInfo, currentTab);
+ }
+ });
+ break;
+ }
+
+ case "TabClose": {
+ if (event.detail.moving) {
+ this._movingTabs.set(event.detail.moving, this.getId(nativeTabInfo));
+ this.emitDetached(nativeTabInfo);
+ } else {
+ this.emitRemoved(nativeTabInfo, false);
+ }
+ break;
+ }
+
+ case "TabSelect":
+ // Because we are delaying calling emitCreated above, we also need to
+ // delay sending this event because it shouldn't fire before onCreated.
+ Promise.resolve().then(() => {
+ this.emitActivated(nativeTabInfo, event.detail.previousTabInfo);
+ });
+ break;
+ }
+ }
+
+ /**
+ * A private method which is called whenever a new mail window is opened, and dispatches the
+ * necessary events for it.
+ *
+ * @param {DOMWindow} window - The window being opened.
+ */
+ _handleWindowOpen(window) {
+ if (windowTracker.isSecondaryWindow(window)) {
+ this.emit("tab-created", {
+ nativeTabInfo: window,
+ currentTab: window,
+ });
+ return;
+ }
+
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ for (let nativeTabInfo of tabmail.tabInfo) {
+ this.emitCreated(nativeTabInfo);
+ }
+ }
+
+ /**
+ * A private method which is called whenever a mail window is closed, and dispatches the necessary
+ * events for it.
+ *
+ * @param {DOMWindow} window - The window being closed.
+ */
+ _handleWindowClose(window) {
+ if (windowTracker.isSecondaryWindow(window)) {
+ this.emit("tab-removed", {
+ nativeTabInfo: window,
+ tabId: this.getId(window),
+ windowId: windowTracker.getId(getTabWindow(window)),
+ isWindowClosing: true,
+ });
+ return;
+ }
+
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ for (let nativeTabInfo of tabmail.tabInfo) {
+ this.emitRemoved(nativeTabInfo, true);
+ }
+ }
+
+ /**
+ * Emits a "tab-activated" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which has been activated.
+ * @param {NativeTab} previousTabInfo - The previously active tab element.
+ */
+ emitActivated(nativeTabInfo, previousTabInfo) {
+ let previousTabId;
+ if (previousTabInfo && !previousTabInfo.closed) {
+ previousTabId = this.getId(previousTabInfo);
+ }
+ this.emit("tab-activated", {
+ tabId: this.getId(nativeTabInfo),
+ previousTabId,
+ windowId: windowTracker.getId(getTabWindow(nativeTabInfo)),
+ });
+ }
+
+ /**
+ * Emits a "tab-attached" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being attached.
+ */
+ emitAttached(nativeTabInfo) {
+ let tabId = this.getId(nativeTabInfo);
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ let tabIndex = tabmail._getTabContextForTabbyThing(nativeTabInfo)[0];
+ let newWindowId = windowTracker.getId(browser.ownerGlobal);
+
+ this.emit("tab-attached", {
+ nativeTabInfo,
+ tabId,
+ newWindowId,
+ newPosition: tabIndex,
+ });
+ }
+
+ /**
+ * Emits a "tab-detached" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being detached.
+ */
+ emitDetached(nativeTabInfo) {
+ let tabId = this.getId(nativeTabInfo);
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ let tabIndex = tabmail._getTabContextForTabbyThing(nativeTabInfo)[0];
+ let oldWindowId = windowTracker.getId(browser.ownerGlobal);
+
+ this.emit("tab-detached", {
+ nativeTabInfo,
+ tabId,
+ oldWindowId,
+ oldPosition: tabIndex,
+ });
+ }
+
+ /**
+ * Emits a "tab-created" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being created.
+ * @param {?NativeTab} currentTab - The tab info for the currently active tab.
+ */
+ emitCreated(nativeTabInfo, currentTab) {
+ this.emit("tab-created", { nativeTabInfo, currentTab });
+ }
+
+ /**
+ * Emits a "tab-removed" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info in the window to which the tab is being
+ * removed
+ * @param {boolean} isWindowClosing - If true, the window with these tabs is closing
+ */
+ emitRemoved(nativeTabInfo, isWindowClosing) {
+ this.emit("tab-removed", {
+ nativeTabInfo,
+ tabId: this.getId(nativeTabInfo),
+ windowId: windowTracker.getId(getTabWindow(nativeTabInfo)),
+ isWindowClosing,
+ });
+ }
+
+ /**
+ * Returns tab id and window id for the given browser element.
+ *
+ * @param {Element} browser - The browser element to check
+ * @returns {{ tabId:Integer, windowId:Integer }} The browsing data for the element
+ */
+ getBrowserData(browser) {
+ return {
+ tabId: this.getBrowserTabId(browser),
+ windowId: windowTracker.getId(browser.ownerGlobal),
+ };
+ }
+
+ /**
+ * Returns the active tab info for the given window
+ *
+ * @property {?NativeTabInfo} activeTab The active tab
+ * @readonly
+ */
+ get activeTab() {
+ let window = windowTracker.topWindow;
+ let tabmail = window && window.document.getElementById("tabmail");
+ return tabmail ? tabmail.selectedTab : window;
+ }
+}
+
+tabTracker = new TabTracker();
+spaceTracker = new SpaceTracker();
+windowTracker = new WindowTracker();
+Object.assign(global, { tabTracker, spaceTracker, windowTracker });
+
+/**
+ * Extension-specific wrapper around a Thunderbird tab. Note that for actual
+ * tabs in the main window, some of these methods are overridden by the
+ * TabmailTab subclass.
+ */
+class Tab extends TabBase {
+ get spaceId() {
+ let tabWindow = getTabWindow(this.nativeTab);
+ if (getWebExtensionWindowType(tabWindow) != "normal") {
+ return undefined;
+ }
+
+ let spaceData = spaceTracker.findSpaceForTab(this.nativeTab);
+ return spaceData?.spaceId ?? undefined;
+ }
+
+ /** What sort of tab is this? */
+ get type() {
+ switch (this.nativeTab.location?.href) {
+ case COMPOSE_WINDOW_URI:
+ return "messageCompose";
+ case MESSAGE_WINDOW_URI:
+ return "messageDisplay";
+ case POPUP_WINDOW_URI:
+ return "content";
+ default:
+ return null;
+ }
+ }
+
+ /** Overrides the matches function to enable querying for tab types. */
+ matches(queryInfo, context) {
+ // If the query includes url or title, but this is a non-browser tab, return
+ // false directly.
+ if ((queryInfo.url || queryInfo.title) && !this.browser) {
+ return false;
+ }
+ let result = super.matches(queryInfo, context);
+
+ let type = queryInfo.mailTab ? "mail" : queryInfo.type;
+ if (result && type && this.type != type) {
+ return false;
+ }
+
+ if (result && queryInfo.spaceId && this.spaceId != queryInfo.spaceId) {
+ return false;
+ }
+
+ return result;
+ }
+
+ /** Adds the mailTab property and removes some useless properties from a tab object. */
+ convert(fallback) {
+ let result = super.convert(fallback);
+ result.spaceId = this.spaceId;
+ result.type = this.type;
+ result.mailTab = result.type == "mail";
+
+ // These properties are not useful to Thunderbird extensions and are not returned.
+ for (let key of [
+ "attention",
+ "audible",
+ "discarded",
+ "hidden",
+ "incognito",
+ "isArticle",
+ "isInReaderMode",
+ "lastAccessed",
+ "mutedInfo",
+ "pinned",
+ "sharingState",
+ "successorTabId",
+ ]) {
+ delete result[key];
+ }
+
+ return result;
+ }
+
+ /** Always returns false. This feature doesn't exist in Thunderbird. */
+ get _incognito() {
+ return false;
+ }
+
+ /** Returns the XUL browser for the tab. */
+ get browser() {
+ if (this.type == "messageCompose") {
+ return this.nativeTab.GetCurrentEditorElement();
+ }
+ if (this.nativeTab.getBrowser) {
+ return this.nativeTab.getBrowser();
+ }
+ return null;
+ }
+
+ get innerWindowID() {
+ if (!this.browser) {
+ return null;
+ }
+ if (this.type == "messageCompose") {
+ return this.browser.contentWindow.windowUtils.currentInnerWindowID;
+ }
+ return super.innerWindowID;
+ }
+
+ /** Returns the frame loader for the tab. */
+ get frameLoader() {
+ // If we don't have a frameLoader yet, just return a dummy with no width and
+ // height.
+ return super.frameLoader || { lazyWidth: 0, lazyHeight: 0 };
+ }
+
+ /** Returns false if the current tab does not have a url associated. */
+ get matchesHostPermission() {
+ if (!this._url) {
+ return false;
+ }
+ return super.matchesHostPermission;
+ }
+
+ /** Returns the current URL of this tab, without permission checks. */
+ get _url() {
+ if (this.type == "messageCompose") {
+ return undefined;
+ }
+ return this.browser?.currentURI?.spec;
+ }
+
+ /** Returns the current title of this tab, without permission checks. */
+ get _title() {
+ if (this.browser && this.browser.contentTitle) {
+ return this.browser.contentTitle;
+ }
+ return this.nativeTab.label;
+ }
+
+ /** Returns the favIcon, without permission checks. */
+ get _favIconUrl() {
+ return null;
+ }
+
+ /** Returns the last accessed time. */
+ get lastAccessed() {
+ return 0;
+ }
+
+ /** Returns the audible state. */
+ get audible() {
+ return false;
+ }
+
+ /** Returns the cookie store id. */
+ get cookieStoreId() {
+ if (this.browser && this.browser.contentPrincipal) {
+ return getCookieStoreIdForOriginAttributes(
+ this.browser.contentPrincipal.originAttributes
+ );
+ }
+
+ return DEFAULT_STORE;
+ }
+
+ /** Returns the discarded state. */
+ get discarded() {
+ return false;
+ }
+
+ /** Returns the tab height. */
+ get height() {
+ return this.frameLoader.lazyHeight;
+ }
+
+ /** Returns hidden status. */
+ get hidden() {
+ return false;
+ }
+
+ /** Returns the tab index. */
+ get index() {
+ return 0;
+ }
+
+ /** Returns information about the muted state of the tab. */
+ get mutedInfo() {
+ return { muted: false };
+ }
+
+ /** Returns information about the sharing state of the tab. */
+ get sharingState() {
+ return { camera: false, microphone: false, screen: false };
+ }
+
+ /** Returns the pinned state of the tab. */
+ get pinned() {
+ return false;
+ }
+
+ /** Returns the active state of the tab. */
+ get active() {
+ return true;
+ }
+
+ /** Returns the highlighted state of the tab. */
+ get highlighted() {
+ return this.active;
+ }
+
+ /** Returns the selected state of the tab. */
+ get selected() {
+ return this.active;
+ }
+
+ /** Returns the loading status of the tab. */
+ get status() {
+ let isComplete;
+ switch (this.type) {
+ case "messageDisplay":
+ case "addressBook":
+ isComplete = this.browser?.contentDocument?.readyState == "complete";
+ break;
+ case "mail":
+ {
+ // If the messagePane is hidden or all browsers are hidden, there is
+ // nothing to be loaded and we should return complete.
+ let about3Pane = this.nativeTab.chromeBrowser.contentWindow;
+ isComplete =
+ !about3Pane.paneLayout?.messagePaneVisible ||
+ this.browser?.webProgress?.isLoadingDocument === false ||
+ (about3Pane.webBrowser?.hidden &&
+ about3Pane.messageBrowser?.hidden &&
+ about3Pane.multiMessageBrowser?.hidden);
+ }
+ break;
+ case "content":
+ case "special":
+ isComplete = this.browser?.webProgress?.isLoadingDocument === false;
+ break;
+ default:
+ // All other tabs (chat, task, calendar, messageCompose) do not fire the
+ // tabs.onUpdated event (Bug 1827929). Let them always be complete.
+ isComplete = true;
+ }
+ return isComplete ? "complete" : "loading";
+ }
+
+ /** Returns the width of the tab. */
+ get width() {
+ return this.frameLoader.lazyWidth;
+ }
+
+ /** Returns the native window object of the tab. */
+ get window() {
+ return this.nativeTab;
+ }
+
+ /** Returns the window id of the tab. */
+ get windowId() {
+ return windowTracker.getId(this.window);
+ }
+
+ /** Returns the attention state of the tab. */
+ get attention() {
+ return false;
+ }
+
+ /** Returns the article state of the tab. */
+ get isArticle() {
+ return false;
+ }
+
+ /** Returns the reader mode state of the tab. */
+ get isInReaderMode() {
+ return false;
+ }
+
+ /** Returns the id of the successor tab of the tab. */
+ get successorTabId() {
+ return -1;
+ }
+}
+
+class TabmailTab extends Tab {
+ constructor(extension, nativeTab, id) {
+ if (nativeTab.localName == "tab") {
+ let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+ nativeTab = tabmail._getTabContextForTabbyThing(nativeTab)[1];
+ }
+ super(extension, nativeTab, id);
+ }
+
+ /** What sort of tab is this? */
+ get type() {
+ switch (this.nativeTab.mode.name) {
+ case "mail3PaneTab":
+ return "mail";
+ case "addressBookTab":
+ return "addressBook";
+ case "mailMessageTab":
+ return "messageDisplay";
+ case "contentTab": {
+ let currentURI = this.nativeTab.browser.currentURI;
+ if (currentURI?.schemeIs("about")) {
+ switch (currentURI.filePath) {
+ case "accountprovisioner":
+ return "accountProvisioner";
+ case "blank":
+ return "content";
+ default:
+ return "special";
+ }
+ }
+ if (currentURI?.schemeIs("chrome")) {
+ return "special";
+ }
+ return "content";
+ }
+ case "calendar":
+ case "calendarEvent":
+ case "calendarTask":
+ case "tasks":
+ case "chat":
+ return this.nativeTab.mode.name;
+ case "provisionerCheckoutTab":
+ case "glodaFacet":
+ case "preferencesTab":
+ return "special";
+ default:
+ // We should not get here, unless a new type is registered with tabmail.
+ return null;
+ }
+ }
+
+ /** Returns the XUL browser for the tab. */
+ get browser() {
+ return getTabBrowser(this.nativeTab);
+ }
+
+ /** Returns the favIcon, without permission checks. */
+ get _favIconUrl() {
+ return this.nativeTab.favIconUrl;
+ }
+
+ /** Returns the tabmail element for the tab. */
+ get tabmail() {
+ return getTabTabmail(this.nativeTab);
+ }
+
+ /** Returns the tab index. */
+ get index() {
+ return this.tabmail.tabInfo.indexOf(this.nativeTab);
+ }
+
+ /** Returns the active state of the tab. */
+ get active() {
+ return this.nativeTab == this.tabmail.selectedTab;
+ }
+
+ /** Returns the title of the tab, without permission checks. */
+ get _title() {
+ if (this.browser && this.browser.contentTitle) {
+ return this.browser.contentTitle;
+ }
+ // Do we want to be using this.nativeTab.title instead? The difference is
+ // that the tabNode label may use defaultTabTitle instead, but do we want to
+ // send this out?
+ return this.nativeTab.tabNode.getAttribute("label");
+ }
+
+ /** Returns the native window object of the tab. */
+ get window() {
+ return this.tabmail.ownerGlobal;
+ }
+}
+
+/**
+ * Extension-specific wrapper around a Thunderbird window.
+ */
+class Window extends WindowBase {
+ /**
+ * @property {string} type - The type of the window, as defined by the
+ * WebExtension API.
+ * @see mail/components/extensions/schemas/windows.json
+ * @readonly
+ */
+ get type() {
+ let type = getWebExtensionWindowType(this.window);
+ if (!type) {
+ throw new ExtensionError(
+ "Windows API encountered an invalid window type."
+ );
+ }
+ return type;
+ }
+
+ /** Returns the title of the tab, without permission checks. */
+ get _title() {
+ return this.window.document.title;
+ }
+
+ /** Returns the title of the tab, checking tab permissions. */
+ get title() {
+ // Thunderbird can have an empty active tab while a window is loading
+ if (this.activeTab && this.activeTab.hasTabPermission) {
+ return this._title;
+ }
+ return null;
+ }
+
+ /**
+ * Sets the title preface of the window.
+ *
+ * @param {string} titlePreface - The title preface to set
+ */
+ setTitlePreface(titlePreface) {
+ this.window.document.documentElement.setAttribute(
+ "titlepreface",
+ titlePreface
+ );
+ }
+
+ /** Gets the foucsed state of the window. */
+ get focused() {
+ return this.window.document.hasFocus();
+ }
+
+ /** Gets the top position of the window. */
+ get top() {
+ return this.window.screenY;
+ }
+
+ /** Gets the left position of the window. */
+ get left() {
+ return this.window.screenX;
+ }
+
+ /** Gets the width of the window. */
+ get width() {
+ return this.window.outerWidth;
+ }
+
+ /** Gets the height of the window. */
+ get height() {
+ return this.window.outerHeight;
+ }
+
+ /** Gets the private browsing status of the window. */
+ get incognito() {
+ return false;
+ }
+
+ /** Checks if the window is considered always on top. */
+ get alwaysOnTop() {
+ return this.appWindow.zLevel >= Ci.nsIAppWindow.raisedZ;
+ }
+
+ /** Checks if the window was the last one focused. */
+ get isLastFocused() {
+ return this.window === windowTracker.topWindow;
+ }
+
+ /**
+ * Returns the window state for the given window.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {string} "maximized", "minimized", "normal" or "fullscreen"
+ */
+ static getState(window) {
+ const STATES = {
+ [window.STATE_MAXIMIZED]: "maximized",
+ [window.STATE_MINIMIZED]: "minimized",
+ [window.STATE_NORMAL]: "normal",
+ };
+ let state = STATES[window.windowState];
+ if (window.fullScreen) {
+ state = "fullscreen";
+ }
+ return state;
+ }
+
+ /** Returns the window state for this specific window. */
+ get state() {
+ return Window.getState(this.window);
+ }
+
+ /**
+ * Sets the window state for this specific window.
+ *
+ * @param {string} state - "maximized", "minimized", "normal" or "fullscreen"
+ */
+ async setState(state) {
+ let { window } = this;
+ const expectedState = (function () {
+ switch (state) {
+ case "maximized":
+ return window.STATE_MAXIMIZED;
+ case "minimized":
+ case "docked":
+ return window.STATE_MINIMIZED;
+ case "normal":
+ return window.STATE_NORMAL;
+ case "fullscreen":
+ return window.STATE_FULLSCREEN;
+ }
+ throw new ExtensionError(`Unexpected window state: ${state}`);
+ })();
+
+ const initialState = window.windowState;
+ if (expectedState == initialState) {
+ return;
+ }
+
+ // We check for window.fullScreen here to make sure to exit fullscreen even
+ // if DOM and widget disagree on what the state is. This is a speculative
+ // fix for bug 1780876, ideally it should not be needed.
+ if (initialState == window.STATE_FULLSCREEN || window.fullScreen) {
+ window.fullScreen = false;
+ }
+
+ switch (expectedState) {
+ case window.STATE_MAXIMIZED:
+ window.maximize();
+ break;
+ case window.STATE_MINIMIZED:
+ window.minimize();
+ break;
+
+ case window.STATE_NORMAL:
+ // Restore sometimes returns the window to its previous state, rather
+ // than to the "normal" state, so it may need to be called anywhere from
+ // zero to two times.
+ window.restore();
+ if (window.windowState !== window.STATE_NORMAL) {
+ window.restore();
+ }
+ if (window.windowState !== window.STATE_NORMAL) {
+ // And on OS-X, where normal vs. maximized is basically a heuristic,
+ // we need to cheat.
+ window.sizeToContent();
+ }
+ break;
+
+ case window.STATE_FULLSCREEN:
+ window.fullScreen = true;
+ break;
+
+ default:
+ throw new ExtensionError(`Unexpected window state: ${state}`);
+ }
+
+ if (window.windowState != expectedState) {
+ // On Linux, sizemode changes are asynchronous. Some of them might not
+ // even happen if the window manager doesn't want to, so wait for a bit
+ // instead of forever for a sizemode change that might not ever happen.
+ const noWindowManagerTimeout = 2000;
+
+ let onSizeModeChange;
+ const promiseExpectedSizeMode = new Promise(resolve => {
+ onSizeModeChange = function () {
+ if (window.windowState == expectedState) {
+ resolve();
+ }
+ };
+ window.addEventListener("sizemodechange", onSizeModeChange);
+ });
+
+ await Promise.any([
+ promiseExpectedSizeMode,
+ new Promise(resolve =>
+ window.setTimeout(resolve, noWindowManagerTimeout)
+ ),
+ ]);
+ window.removeEventListener("sizemodechange", onSizeModeChange);
+ }
+
+ if (window.windowState != expectedState) {
+ console.warn(
+ `Window manager refused to set window to state ${expectedState}.`
+ );
+ }
+ }
+
+ /**
+ * Retrieves the (relevant) tabs in this window.
+ *
+ * @yields {Tab} The wrapped Tab in this window
+ */
+ *getTabs() {
+ let { tabManager } = this.extension;
+ yield tabManager.getWrapper(this.window);
+ }
+
+ /**
+ * Returns an iterator of TabBase objects for the highlighted tab in this
+ * window. This is an alias for the active tab.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ *getHighlightedTabs() {
+ yield this.activeTab;
+ }
+
+ /** Retrieves the active tab in this window */
+ get activeTab() {
+ let { tabManager } = this.extension;
+ return tabManager.getWrapper(this.window);
+ }
+
+ /**
+ * Retrieves the tab at the given index.
+ *
+ * @param {number} index - The index to look at
+ * @returns {Tab} The wrapped tab at the index
+ */
+ getTabAtIndex(index) {
+ let { tabManager } = this.extension;
+ if (index == 0) {
+ return tabManager.getWrapper(this.window);
+ }
+ return null;
+ }
+}
+
+class TabmailWindow extends Window {
+ /** Returns the tabmail element for the tab. */
+ get tabmail() {
+ return this.window.document.getElementById("tabmail");
+ }
+
+ /**
+ * Retrieves the (relevant) tabs in this window.
+ *
+ * @yields {Tab} The wrapped Tab in this window
+ */
+ *getTabs() {
+ let { tabManager } = this.extension;
+
+ for (let nativeTabInfo of this.tabmail.tabInfo) {
+ // Only tabs that have a browser element.
+ yield tabManager.getWrapper(nativeTabInfo);
+ }
+ }
+
+ /** Retrieves the active tab in this window */
+ get activeTab() {
+ let { tabManager } = this.extension;
+ let selectedTab = this.tabmail.selectedTab;
+ if (selectedTab) {
+ return tabManager.getWrapper(selectedTab);
+ }
+ return null;
+ }
+
+ /**
+ * Retrieves the tab at the given index.
+ *
+ * @param {number} index - The index to look at
+ * @returns {Tab} The wrapped tab at the index
+ */
+ getTabAtIndex(index) {
+ let { tabManager } = this.extension;
+ let nativeTabInfo = this.tabmail.tabInfo[index];
+ if (nativeTabInfo) {
+ return tabManager.getWrapper(nativeTabInfo);
+ }
+ return null;
+ }
+}
+
+Object.assign(global, { Tab, Window });
+
+/**
+ * Manages native tabs, their wrappers, and their dynamic permissions for a particular extension.
+ */
+class TabManager extends TabManagerBase {
+ /**
+ * Returns a Tab wrapper for the tab with the given ID.
+ *
+ * @param {integer} tabId - The ID of the tab for which to return a wrapper.
+ * @param {*} default_ - The value to return if no tab exists with the given ID.
+ * @returns {Tab|*} The wrapped tab, or the default value
+ */
+ get(tabId, default_ = undefined) {
+ let nativeTabInfo = tabTracker.getTab(tabId, default_);
+
+ if (nativeTabInfo) {
+ return this.getWrapper(nativeTabInfo);
+ }
+ return default_;
+ }
+
+ /**
+ * If the extension has requested activeTab permission, grant it those permissions for the current
+ * inner window in the given native tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to grant permissions.
+ */
+ addActiveTabPermission(nativeTabInfo = tabTracker.activeTab) {
+ if (nativeTabInfo.browser) {
+ super.addActiveTabPermission(nativeTabInfo);
+ }
+ }
+
+ /**
+ * Revoke the extension's activeTab permissions for the current inner window of the given native
+ * tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to revoke permissions.
+ */
+ revokeActiveTabPermission(nativeTabInfo = tabTracker.activeTab) {
+ super.revokeActiveTabPermission(nativeTabInfo);
+ }
+
+ /**
+ * Determines access using extension context.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab to check access on.
+ * @returns {boolean}
+ * True if the extension has permissions for this tab.
+ */
+ canAccessTab(nativeTab) {
+ return true;
+ }
+
+ /**
+ * Returns a new Tab instance wrapping the given native tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to return a wrapper.
+ * @returns {Tab} The wrapped native tab
+ */
+ wrapTab(nativeTabInfo) {
+ let tabClass = TabmailTab;
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ tabClass = Tab;
+ }
+ return new tabClass(
+ this.extension,
+ nativeTabInfo,
+ tabTracker.getId(nativeTabInfo)
+ );
+ }
+}
+
+/**
+ * Manages native browser windows and their wrappers for a particular extension.
+ */
+class WindowManager extends WindowManagerBase {
+ /**
+ * Returns a Window wrapper for the mail window with the given ID.
+ *
+ * @param {Integer} windowId - The ID of the browser window for which to return a wrapper.
+ * @param {BaseContext} context - The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ * @returns {Window} The wrapped window
+ */
+ get(windowId, context) {
+ let window = windowTracker.getWindow(windowId, context);
+ return this.getWrapper(window);
+ }
+
+ /**
+ * Yields an iterator of WindowBase wrappers for each currently existing browser window.
+ *
+ * @yields {Window}
+ */
+ *getAll() {
+ for (let window of windowTracker.browserWindows()) {
+ yield this.getWrapper(window);
+ }
+ }
+
+ /**
+ * Returns a new Window instance wrapping the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window for which to return a wrapper.
+ * @returns {Window} The wrapped window
+ */
+ wrapWindow(window) {
+ let windowClass = Window;
+ if (
+ window.document.documentElement.getAttribute("windowtype") == "mail:3pane"
+ ) {
+ windowClass = TabmailWindow;
+ }
+ return new windowClass(this.extension, window, windowTracker.getId(window));
+ }
+}
+
+/**
+ * Wait until the normal window identified by the given windowId has finished its
+ * delayed startup. Returns its DOMWindow when done. Waits for the top normal
+ * window, if no window is specified.
+ *
+ * @param {*} [context] - a WebExtension context
+ * @param {*} [windowId] - a WebExtension window id
+ * @returns {DOMWindow}
+ */
+async function getNormalWindowReady(context, windowId) {
+ let window;
+ if (windowId) {
+ let win = context.extension.windowManager.get(windowId, context);
+ if (win.type != "normal") {
+ throw new ExtensionError(
+ `Window with ID ${windowId} is not a normal window`
+ );
+ }
+ window = win.window;
+ } else {
+ window = windowTracker.topNormalWindow;
+ }
+
+ // Wait for session restore.
+ await new Promise(resolve => {
+ if (!window.SessionStoreManager._restored) {
+ let obs = (observedWindow, topic, data) => {
+ if (observedWindow != window) {
+ return;
+ }
+ Services.obs.removeObserver(obs, "mail-tabs-session-restored");
+ resolve();
+ };
+ Services.obs.addObserver(obs, "mail-tabs-session-restored");
+ } else {
+ resolve();
+ }
+ });
+
+ // Wait for all mail3PaneTab's to have been fully restored and loaded.
+ for (let tabInfo of window.gTabmail.tabInfo) {
+ let { chromeBrowser, mode, closed } = tabInfo;
+ if (!closed && mode.name == "mail3PaneTab") {
+ await new Promise(resolve => {
+ if (
+ chromeBrowser.contentDocument.readyState == "complete" &&
+ chromeBrowser.currentURI.spec == "about:3pane"
+ ) {
+ resolve();
+ } else {
+ chromeBrowser.contentWindow.addEventListener(
+ "load",
+ () => resolve(),
+ {
+ once: true,
+ }
+ );
+ }
+ });
+ }
+ }
+
+ return window;
+}
+
+/**
+ * Converts an nsIMsgAccount to a simple object
+ *
+ * @param {nsIMsgAccount} account
+ * @returns {object}
+ */
+function convertAccount(account, includeFolders = true) {
+ if (!account) {
+ return null;
+ }
+
+ account = account.QueryInterface(Ci.nsIMsgAccount);
+ let server = account.incomingServer;
+ if (server.type == "im") {
+ return null;
+ }
+
+ let folders = null;
+ if (includeFolders) {
+ folders = traverseSubfolders(
+ account.incomingServer.rootFolder,
+ account.key
+ ).subFolders;
+ }
+
+ return {
+ id: account.key,
+ name: account.incomingServer.prettyName,
+ type: account.incomingServer.type,
+ folders,
+ identities: account.identities.map(identity =>
+ convertMailIdentity(account, identity)
+ ),
+ };
+}
+
+/**
+ * Converts an nsIMsgIdentity to a simple object for use in messages.
+ *
+ * @param {nsIMsgAccount} account
+ * @param {nsIMsgIdentity} identity
+ * @returns {object}
+ */
+function convertMailIdentity(account, identity) {
+ if (!account || !identity) {
+ return null;
+ }
+ identity = identity.QueryInterface(Ci.nsIMsgIdentity);
+ return {
+ accountId: account.key,
+ id: identity.key,
+ label: identity.label || "",
+ name: identity.fullName || "",
+ email: identity.email || "",
+ replyTo: identity.replyTo || "",
+ organization: identity.organization || "",
+ composeHtml: identity.composeHtml,
+ signature: identity.htmlSigText || "",
+ signatureIsPlainText: !identity.htmlSigFormat,
+ };
+}
+
+/**
+ * The following functions turn nsIMsgFolder references into more human-friendly forms.
+ * A folder can be referenced with the account key, and the path to the folder in that account.
+ */
+
+/**
+ * Convert a folder URI to a human-friendly path.
+ *
+ * @returns {string}
+ */
+function folderURIToPath(accountId, uri) {
+ let server = MailServices.accounts.getAccount(accountId).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ if (rootURI == uri) {
+ return "/";
+ }
+ // The .URI property of an IMAP folder doesn't have %-encoded characters, but
+ // may include literal % chars. Services.io.newURI(uri) applies encodeURI to
+ // the returned filePath, but will not encode any literal % chars, which will
+ // cause decodeURIComponent to fail (bug 1707408).
+ if (server.type == "imap") {
+ return uri.substring(rootURI.length);
+ }
+ let path = Services.io.newURI(uri).filePath;
+ return path.split("/").map(decodeURIComponent).join("/");
+}
+
+/**
+ * Convert a human-friendly path to a folder URI. This function does not assume
+ * that the folder referenced exists.
+ *
+ * @returns {string}
+ */
+function folderPathToURI(accountId, path) {
+ let server = MailServices.accounts.getAccount(accountId).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ if (path == "/") {
+ return rootURI;
+ }
+ // The .URI property of an IMAP folder doesn't have %-encoded characters.
+ // If encoded here, the folder lookup service won't find the folder.
+ if (server.type == "imap") {
+ return rootURI + path;
+ }
+ return (
+ rootURI +
+ path
+ .split("/")
+ .map(p =>
+ encodeURIComponent(p)
+ .replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16))
+ // We do not encode "+" chars in folder URIs. Manually convert them
+ // back to literal + chars, otherwise folder lookup will fail.
+ .replaceAll("%2B", "+")
+ )
+ .join("/")
+ );
+}
+
+const folderTypeMap = new Map([
+ [Ci.nsMsgFolderFlags.Inbox, "inbox"],
+ [Ci.nsMsgFolderFlags.Drafts, "drafts"],
+ [Ci.nsMsgFolderFlags.SentMail, "sent"],
+ [Ci.nsMsgFolderFlags.Trash, "trash"],
+ [Ci.nsMsgFolderFlags.Templates, "templates"],
+ [Ci.nsMsgFolderFlags.Archive, "archives"],
+ [Ci.nsMsgFolderFlags.Junk, "junk"],
+ [Ci.nsMsgFolderFlags.Queue, "outbox"],
+]);
+
+/**
+ * Converts an nsIMsgFolder to a simple object for use in API messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to convert.
+ * @param {string} [accountId] - An optimization to avoid looking up the
+ * account. The value from nsIMsgHdr.accountKey must not be used here.
+ * @returns {MailFolder}
+ * @see mail/components/extensions/schemas/folders.json
+ */
+function convertFolder(folder, accountId) {
+ if (!folder) {
+ return null;
+ }
+ if (!accountId) {
+ let server = folder.server;
+ let account = MailServices.accounts.FindAccountForServer(server);
+ accountId = account.key;
+ }
+
+ let folderObject = {
+ accountId,
+ name: folder.prettyName,
+ path: folderURIToPath(accountId, folder.URI),
+ };
+
+ for (let [flag, typeName] of folderTypeMap.entries()) {
+ if (folder.flags & flag) {
+ folderObject.type = typeName;
+ }
+ }
+
+ return folderObject;
+}
+
+/**
+ * Converts an nsIMsgFolder and all its subfolders to a simple object for use in
+ * API messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to convert.
+ * @param {string} [accountId] - An optimization to avoid looking up the
+ * account. The value from nsIMsgHdr.accountKey must not be used here.
+ * @returns {MailFolder}
+ * @see mail/components/extensions/schemas/folders.json
+ */
+function traverseSubfolders(folder, accountId) {
+ let f = convertFolder(folder, accountId);
+ f.subFolders = [];
+ if (folder.hasSubFolders) {
+ // Use the same order as used by Thunderbird.
+ let subFolders = [...folder.subFolders].sort((a, b) =>
+ a.sortOrder == b.sortOrder
+ ? a.name.localeCompare(b.name)
+ : a.sortOrder - b.sortOrder
+ );
+ for (let subFolder of subFolders) {
+ f.subFolders.push(
+ traverseSubfolders(subFolder, accountId || f.accountId)
+ );
+ }
+ }
+ return f;
+}
+
+class FolderManager {
+ constructor(extension) {
+ this.extension = extension;
+ }
+
+ convert(folder, accountId) {
+ return convertFolder(folder, accountId);
+ }
+
+ get(accountId, path) {
+ return MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(accountId, path)
+ );
+ }
+}
+
+/**
+ * Checks if the provided nsIMsgHdr is a dummy message header of an attached message.
+ */
+function isAttachedMessage(msgHdr) {
+ try {
+ return (
+ !msgHdr.folder &&
+ new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.has("part")
+ );
+ } catch (ex) {
+ return false;
+ }
+}
+
+/**
+ * Converts an nsIMsgHdr to a simple object for use in messages.
+ * This function WILL change as the API develops.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {ExtensionData} extension
+ * @returns {MessageHeader} MessageHeader object
+ *
+ * @see /mail/components/extensions/schemas/messages.json
+ */
+function convertMessage(msgHdr, extension) {
+ if (!msgHdr) {
+ return null;
+ }
+
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let junkScore = parseInt(msgHdr.getStringProperty("junkscore"), 10) || 0;
+ let tags = (msgHdr.getStringProperty("keywords") || "")
+ .split(" ")
+ .filter(MailServices.tags.isValidKey);
+
+ let external = !msgHdr.folder;
+
+ // Getting the size of attached messages does not work consistently. For imap://
+ // and mailbox:// messages the returned size in msgHdr.messageSize is 0, and for
+ // file:// messages the returned size is always the total file size
+ // Be consistent here and always return 0. The user can obtain the message size
+ // from the size of the associated attachment file.
+ let size = isAttachedMessage(msgHdr) ? 0 : msgHdr.messageSize;
+
+ let messageObject = {
+ id: messageTracker.getId(msgHdr),
+ date: new Date(Math.round(msgHdr.date / 1000)),
+ author: msgHdr.mime2DecodedAuthor,
+ recipients: composeFields.splitRecipients(
+ msgHdr.mime2DecodedRecipients,
+ false
+ ),
+ ccList: composeFields.splitRecipients(msgHdr.ccList, false),
+ bccList: composeFields.splitRecipients(msgHdr.bccList, false),
+ subject: msgHdr.mime2DecodedSubject,
+ read: msgHdr.isRead,
+ new: !!(msgHdr.flags & Ci.nsMsgMessageFlags.New),
+ headersOnly: !!(msgHdr.flags & Ci.nsMsgMessageFlags.Partial),
+ flagged: !!msgHdr.isFlagged,
+ junk: junkScore >= gJunkThreshold,
+ junkScore,
+ headerMessageId: msgHdr.messageId,
+ size,
+ tags,
+ external,
+ };
+ // convertMessage can be called without providing an extension, if the info is
+ // needed for multiple extensions. The caller has to ensure that the folder info
+ // is not forwarded to extensions, which do not have the required permission.
+ if (
+ msgHdr.folder &&
+ (!extension || extension.hasPermission("accountsRead"))
+ ) {
+ messageObject.folder = convertFolder(msgHdr.folder);
+ }
+ return messageObject;
+}
+
+/**
+ * A map of numeric identifiers to messages for easy reference.
+ *
+ * @implements {nsIFolderListener}
+ * @implements {nsIMsgFolderListener}
+ * @implements {nsIObserver}
+ */
+var messageTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this._nextId = 1;
+ this._messages = new Map();
+ this._messageIds = new Map();
+ this._listenerCount = 0;
+ this._pendingKeyChanges = new Map();
+ this._dummyMessageHeaders = new Map();
+
+ // nsIObserver
+ Services.obs.addObserver(this, "quit-application-granted");
+ Services.obs.addObserver(this, "attachment-delete-msgkey-changed");
+ // nsIFolderListener
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.propertyFlagChanged |
+ Ci.nsIFolderListener.intPropertyChanged
+ );
+ // nsIMsgFolderListener
+ MailServices.mfn.addListener(
+ this,
+ MailServices.mfn.msgsJunkStatusChanged |
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.msgKeyChanged
+ );
+
+ this._messageOpenListener = {
+ registered: false,
+ async handleEvent(event) {
+ let msgHdr = event.detail;
+ // It is not possible to retrieve the dummyMsgHdr of messages opened
+ // from file at a later time, track them manually.
+ if (
+ msgHdr &&
+ !msgHdr.folder &&
+ msgHdr.getStringProperty("dummyMsgUrl").startsWith("file://")
+ ) {
+ messageTracker.getId(msgHdr);
+ }
+ },
+ };
+ try {
+ windowTracker.addListener("MsgLoaded", this._messageOpenListener);
+ this._messageOpenListener.registered = true;
+ } catch (ex) {
+ // Fails during XPCSHELL tests, which mock the WindowWatcher but do not
+ // implement registerNotification.
+ }
+ }
+
+ cleanup() {
+ // nsIObserver
+ Services.obs.removeObserver(this, "quit-application-granted");
+ Services.obs.removeObserver(this, "attachment-delete-msgkey-changed");
+ // nsIFolderListener
+ MailServices.mailSession.RemoveFolderListener(this);
+ // nsIMsgFolderListener
+ MailServices.mfn.removeListener(this);
+ if (this._messageOpenListener.registered) {
+ windowTracker.removeListener("MsgLoaded", this._messageOpenListener);
+ this._messageOpenListener.registered = false;
+ }
+ }
+
+ /**
+ * Maps the provided message identifier to the given messageTracker id.
+ */
+ _set(id, msgIdentifier, msgHdr) {
+ let hash = JSON.stringify(msgIdentifier);
+ this._messageIds.set(hash, id);
+ this._messages.set(id, msgIdentifier);
+ // Keep track of dummy message headers, which do not have a folderURI property
+ // and cannot be retrieved later.
+ if (msgHdr && !msgHdr.folder) {
+ this._dummyMessageHeaders.set(msgIdentifier.dummyMsgUrl, msgHdr);
+ }
+ }
+
+ /**
+ * Lookup the messageTracker id for the given message identifier, return null
+ * if not known.
+ */
+ _get(msgIdentifier) {
+ let hash = JSON.stringify(msgIdentifier);
+ if (this._messageIds.has(hash)) {
+ return this._messageIds.get(hash);
+ }
+ return null;
+ }
+
+ /**
+ * Removes the provided message identifier from the messageTracker.
+ */
+ _remove(msgIdentifier) {
+ let hash = JSON.stringify(msgIdentifier);
+ let id = this._get(msgIdentifier);
+ this._messages.delete(id);
+ this._messageIds.delete(hash);
+ this._dummyMessageHeaders.delete(msgIdentifier.dummyMsgUrl);
+ }
+
+ /**
+ * Finds a message in the messageTracker or adds it.
+ *
+ * @returns {int} The messageTracker id of the message
+ */
+ getId(msgHdr) {
+ let msgIdentifier;
+ if (msgHdr.folder) {
+ msgIdentifier = {
+ folderURI: msgHdr.folder.URI,
+ messageKey: msgHdr.messageKey,
+ };
+ } else {
+ // Normalize the dummyMsgUrl by sorting its parameters and striping them
+ // to a minimum.
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+ let parameters = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["group", "number", "key", "part"].includes(p)
+ );
+ for (let parameter of parameters) {
+ url.searchParams.delete(parameter);
+ }
+ url.searchParams.sort();
+
+ msgIdentifier = {
+ dummyMsgUrl: url.href,
+ dummyMsgLastModifiedTime: msgHdr.getUint32Property(
+ "dummyMsgLastModifiedTime"
+ ),
+ };
+ }
+
+ let id = this._get(msgIdentifier);
+ if (id) {
+ return id;
+ }
+ id = this._nextId++;
+
+ this._set(id, msgIdentifier, new nsDummyMsgHeader(msgHdr));
+ return id;
+ }
+
+ /**
+ * Check if the provided msgIdentifier belongs to a modified file message.
+ *
+ * @param {*} msgIdentifier - the msgIdentifier object of the message
+ * @returns {boolean}
+ */
+ isModifiedFileMsg(msgIdentifier) {
+ if (!msgIdentifier.dummyMsgUrl?.startsWith("file://")) {
+ return false;
+ }
+
+ try {
+ let file = Services.io
+ .newURI(msgIdentifier.dummyMsgUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ if (!file?.exists()) {
+ throw new ExtensionError("File does not exist");
+ }
+ if (
+ msgIdentifier.dummyMsgLastModifiedTime &&
+ Math.floor(file.lastModifiedTime / 1000000) !=
+ msgIdentifier.dummyMsgLastModifiedTime
+ ) {
+ throw new ExtensionError("File has been modified");
+ }
+ } catch (ex) {
+ console.error(ex);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Retrieves a message from the messageTracker. If the message no longer,
+ * exists it is removed from the messageTracker.
+ *
+ * @returns {nsIMsgHdr} The identifier of the message
+ */
+ getMessage(id) {
+ let msgIdentifier = this._messages.get(id);
+ if (!msgIdentifier) {
+ return null;
+ }
+
+ if (msgIdentifier.folderURI) {
+ let folder = MailServices.folderLookup.getFolderForURL(
+ msgIdentifier.folderURI
+ );
+ if (folder) {
+ let msgHdr = folder.msgDatabase.getMsgHdrForKey(
+ msgIdentifier.messageKey
+ );
+ if (msgHdr) {
+ return msgHdr;
+ }
+ }
+ } else {
+ let msgHdr = this._dummyMessageHeaders.get(msgIdentifier.dummyMsgUrl);
+ if (msgHdr && !this.isModifiedFileMsg(msgIdentifier)) {
+ return msgHdr;
+ }
+ }
+
+ this._remove(msgIdentifier);
+ return null;
+ }
+
+ // nsIFolderListener
+
+ onFolderPropertyFlagChanged(item, property, oldFlag, newFlag) {
+ let changes = {};
+ switch (property) {
+ case "Status":
+ if ((oldFlag ^ newFlag) & Ci.nsMsgMessageFlags.Read) {
+ changes.read = item.isRead;
+ }
+ if ((oldFlag ^ newFlag) & Ci.nsMsgMessageFlags.New) {
+ changes.new = !!(newFlag & Ci.nsMsgMessageFlags.New);
+ }
+ break;
+ case "Flagged":
+ changes.flagged = item.isFlagged;
+ break;
+ case "Keywords":
+ {
+ let tags = item.getStringProperty("keywords");
+ tags = tags ? tags.split(" ") : [];
+ changes.tags = tags.filter(MailServices.tags.isValidKey);
+ }
+ break;
+ }
+ if (Object.keys(changes).length) {
+ this.emit("message-updated", item, changes);
+ }
+ }
+
+ onFolderIntPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "BiffState":
+ if (newValue == Ci.nsIMsgFolder.nsMsgBiffState_NewMail) {
+ // The folder argument is a root folder.
+ this.findNewMessages(folder);
+ }
+ break;
+ case "NewMailReceived":
+ // The folder argument is a real folder.
+ this.findNewMessages(folder);
+ break;
+ }
+ }
+
+ /**
+ * Finds all folders with new messages in the specified changedFolder and
+ * returns those.
+ *
+ * @see MailNotificationManager._getFirstRealFolderWithNewMail()
+ */
+ findNewMessages(changedFolder) {
+ let folders = changedFolder.descendants;
+ folders.unshift(changedFolder);
+ for (let folder of folders) {
+ let flags = folder.flags;
+ if (
+ !(flags & Ci.nsMsgFolderFlags.Inbox) &&
+ flags & (Ci.nsMsgFolderFlags.SpecialUse | Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ // Do not notify if the folder is not Inbox but one of
+ // Drafts|Trash|SentMail|Templates|Junk|Archive|Queue or Virtual.
+ continue;
+ }
+ let numNewMessages = folder.getNumNewMessages(false);
+ if (!numNewMessages) {
+ continue;
+ }
+ let msgDb = folder.msgDatabase;
+ let newMsgKeys = msgDb.getNewList().slice(-numNewMessages);
+ if (newMsgKeys.length == 0) {
+ continue;
+ }
+ this.emit(
+ "messages-received",
+ folder,
+ newMsgKeys.map(key => msgDb.getMsgHdrForKey(key))
+ );
+ }
+ }
+
+ // nsIMsgFolderListener
+
+ msgsJunkStatusChanged(messages) {
+ for (let msgHdr of messages) {
+ let junkScore = parseInt(msgHdr.getStringProperty("junkscore"), 10) || 0;
+ this.emit("message-updated", msgHdr, {
+ junk: junkScore >= gJunkThreshold,
+ });
+ }
+ }
+
+ msgsDeleted(deletedMsgs) {
+ if (deletedMsgs.length > 0) {
+ this.emit("messages-deleted", deletedMsgs);
+ }
+ }
+
+ msgsMoveCopyCompleted(move, srcMsgs, dstFolder, dstMsgs) {
+ if (srcMsgs.length > 0 && dstMsgs.length > 0) {
+ let emitMsg = move ? "messages-moved" : "messages-copied";
+ this.emit(emitMsg, srcMsgs, dstMsgs);
+ }
+ }
+
+ msgKeyChanged(oldKey, newMsgHdr) {
+ // For IMAP messages there is a delayed update of database keys and if those
+ // keys change, the messageTracker needs to update its maps, otherwise wrong
+ // messages will be returned. Key changes are replayed in multi-step swaps.
+ let newKey = newMsgHdr.messageKey;
+
+ // Replay pending swaps.
+ while (this._pendingKeyChanges.has(oldKey)) {
+ let next = this._pendingKeyChanges.get(oldKey);
+ this._pendingKeyChanges.delete(oldKey);
+ oldKey = next;
+
+ // Check if we are left with a no-op swap and exit early.
+ if (oldKey == newKey) {
+ this._pendingKeyChanges.delete(oldKey);
+ return;
+ }
+ }
+
+ if (oldKey != newKey) {
+ // New key swap, log the mirror swap as pending.
+ this._pendingKeyChanges.set(newKey, oldKey);
+
+ // Swap tracker entries.
+ let oldId = this._get({
+ folderURI: newMsgHdr.folder.URI,
+ messageKey: oldKey,
+ });
+ let newId = this._get({
+ folderURI: newMsgHdr.folder.URI,
+ messageKey: newKey,
+ });
+ this._set(oldId, { folderURI: newMsgHdr.folder.URI, messageKey: newKey });
+ this._set(newId, { folderURI: newMsgHdr.folder.URI, messageKey: oldKey });
+ }
+ }
+
+ // nsIObserver
+
+ /**
+ * Observer to update message tracker if a message has received a new key due
+ * to attachments being removed, which we do not consider to be a new message.
+ */
+ observe(subject, topic, data) {
+ if (topic == "attachment-delete-msgkey-changed") {
+ data = JSON.parse(data);
+
+ if (data && data.folderURI && data.oldMessageKey && data.newMessageKey) {
+ let id = this._get({
+ folderURI: data.folderURI,
+ messageKey: data.oldMessageKey,
+ });
+ if (id) {
+ // Replace tracker entries.
+ this._set(id, {
+ folderURI: data.folderURI,
+ messageKey: data.newMessageKey,
+ });
+ }
+ }
+ } else if (topic == "quit-application-granted") {
+ this.cleanup();
+ }
+ }
+})();
+
+/**
+ * Tracks lists of messages so that an extension can consume them in chunks.
+ * Any WebExtensions method that could return multiple messages should instead call
+ * messageListTracker.startList and return the results, which contain the first
+ * chunk. Further chunks can be fetched by the extension calling
+ * browser.messages.continueList. Chunk size is controlled by a pref.
+ */
+var messageListTracker = {
+ _contextLists: new WeakMap(),
+
+ /**
+ * Takes an array or enumerator of messages and returns the first chunk.
+ *
+ * @returns {object}
+ */
+ startList(messages, extension) {
+ let messageList = this.createList(extension);
+ if (Array.isArray(messages)) {
+ messages = this._createEnumerator(messages);
+ }
+ while (messages.hasMoreElements()) {
+ let next = messages.getNext();
+ messageList.add(next.QueryInterface(Ci.nsIMsgDBHdr));
+ }
+ messageList.done();
+ return this.getNextPage(messageList);
+ },
+
+ _createEnumerator(array) {
+ let current = 0;
+ return {
+ hasMoreElements() {
+ return current < array.length;
+ },
+ getNext() {
+ return array[current++];
+ },
+ };
+ },
+
+ /**
+ * Creates and returns a new messageList object.
+ *
+ * @returns {object}
+ */
+ createList(extension) {
+ let messageListId = Services.uuid.generateUUID().number.substring(1, 37);
+ let messageList = this._createListObject(messageListId, extension);
+ let lists = this._contextLists.get(extension);
+ if (!lists) {
+ lists = new Map();
+ this._contextLists.set(extension, lists);
+ }
+ lists.set(messageListId, messageList);
+ return messageList;
+ },
+
+ /**
+ * Returns the messageList object for a given id.
+ *
+ * @returns {object}
+ */
+ getList(messageListId, extension) {
+ let lists = this._contextLists.get(extension);
+ let messageList = lists ? lists.get(messageListId, null) : null;
+ if (!messageList) {
+ throw new ExtensionError(
+ `No message list for id ${messageListId}. Have you reached the end of a list?`
+ );
+ }
+ return messageList;
+ },
+
+ /**
+ * Returns the first/next message page of the given messageList.
+ *
+ * @returns {object}
+ */
+ async getNextPage(messageList) {
+ let messageListId = messageList.id;
+ let messages = await messageList.getNextPage();
+ if (!messageList.hasMorePages()) {
+ let lists = this._contextLists.get(messageList.extension);
+ if (lists && lists.has(messageListId)) {
+ lists.delete(messageListId);
+ }
+ messageListId = null;
+ }
+ return {
+ id: messageListId,
+ messages,
+ };
+ },
+
+ _createListObject(messageListId, extension) {
+ function getCurrentPage() {
+ return pages.length > 0 ? pages[pages.length - 1] : null;
+ }
+
+ function addPage() {
+ let contents = getCurrentPage();
+ let resolvePage = currentPageResolveCallback;
+
+ pages.push([]);
+ pagePromises.push(
+ new Promise(resolve => {
+ currentPageResolveCallback = resolve;
+ })
+ );
+
+ if (contents && resolvePage) {
+ resolvePage(contents);
+ }
+ }
+
+ let _messageListId = messageListId;
+ let _extension = extension;
+ let isDone = false;
+ let pages = [];
+ let pagePromises = [];
+ let currentPageResolveCallback = null;
+ let readIndex = 0;
+
+ // Add first page.
+ addPage();
+
+ return {
+ get id() {
+ return _messageListId;
+ },
+ get extension() {
+ return _extension;
+ },
+ add(message) {
+ if (isDone) {
+ return;
+ }
+ if (getCurrentPage().length >= gMessagesPerPage) {
+ addPage();
+ }
+ getCurrentPage().push(convertMessage(message, _extension));
+ },
+ done() {
+ if (isDone) {
+ return;
+ }
+ isDone = true;
+ currentPageResolveCallback(getCurrentPage());
+ },
+ hasMorePages() {
+ return readIndex < pages.length;
+ },
+ async getNextPage() {
+ if (readIndex >= pages.length) {
+ return null;
+ }
+ const pageContent = await pagePromises[readIndex];
+ // Increment readIndex only after pagePromise has resolved, so multiple
+ // calls to getNextPage get the same page.
+ readIndex++;
+ return pageContent;
+ },
+ };
+ },
+};
+
+class MessageManager {
+ constructor(extension) {
+ this.extension = extension;
+ }
+
+ convert(msgHdr) {
+ return convertMessage(msgHdr, this.extension);
+ }
+
+ get(id) {
+ return messageTracker.getMessage(id);
+ }
+
+ startMessageList(messageList) {
+ return messageListTracker.startList(messageList, this.extension);
+ }
+}
+
+extensions.on("startup", (type, extension) => {
+ // eslint-disable-line mozilla/balanced-listeners
+ if (extension.hasPermission("accountsRead")) {
+ defineLazyGetter(
+ extension,
+ "folderManager",
+ () => new FolderManager(extension)
+ );
+ }
+ if (extension.hasPermission("addressBooks")) {
+ defineLazyGetter(extension, "addressBookManager", () => {
+ if (!("addressBookCache" in this)) {
+ extensions.loadModule("addressBook");
+ }
+ return {
+ findAddressBookById: this.addressBookCache.findAddressBookById.bind(
+ this.addressBookCache
+ ),
+ findContactById: this.addressBookCache.findContactById.bind(
+ this.addressBookCache
+ ),
+ findMailingListById: this.addressBookCache.findMailingListById.bind(
+ this.addressBookCache
+ ),
+ convert: this.addressBookCache.convert.bind(this.addressBookCache),
+ };
+ });
+ }
+ if (extension.hasPermission("messagesRead")) {
+ defineLazyGetter(
+ extension,
+ "messageManager",
+ () => new MessageManager(extension)
+ );
+ }
+ defineLazyGetter(extension, "tabManager", () => new TabManager(extension));
+ defineLazyGetter(
+ extension,
+ "windowManager",
+ () => new WindowManager(extension)
+ );
+});
diff --git a/comm/mail/components/extensions/parent/ext-mailTabs.js b/comm/mail/components/extensions/parent/ext-mailTabs.js
new file mode 100644
index 0000000000..9cf0bc0844
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-mailTabs.js
@@ -0,0 +1,485 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ QuickFilterManager: "resource:///modules/QuickFilterManager.jsm",
+ MailServices: "resource:///modules/MailServices.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gDynamicPaneConfig",
+ "mail.pane_config.dynamic",
+ 0
+);
+
+const LAYOUTS = ["standard", "wide", "vertical"];
+// From nsIMsgDBView.idl
+const SORT_TYPE_MAP = new Map(
+ Object.keys(Ci.nsMsgViewSortType).map(key => {
+ // Change "byFoo" to "foo".
+ let shortKey = key[2].toLowerCase() + key.substring(3);
+ return [Ci.nsMsgViewSortType[key], shortKey];
+ })
+);
+const SORT_ORDER_MAP = new Map(
+ Object.keys(Ci.nsMsgViewSortOrder).map(key => [
+ Ci.nsMsgViewSortOrder[key],
+ key,
+ ])
+);
+
+/**
+ * Converts a mail tab to a simple object for use in messages.
+ *
+ * @returns {object}
+ */
+function convertMailTab(tab, context) {
+ let mailTabObject = {
+ id: tab.id,
+ windowId: tab.windowId,
+ active: tab.active,
+ sortType: null,
+ sortOrder: null,
+ viewType: null,
+ layout: LAYOUTS[gDynamicPaneConfig],
+ folderPaneVisible: null,
+ messagePaneVisible: null,
+ };
+
+ let about3Pane = tab.nativeTab.chromeBrowser.contentWindow;
+ let { gViewWrapper, paneLayout } = about3Pane;
+ mailTabObject.folderPaneVisible = paneLayout.folderPaneVisible;
+ mailTabObject.messagePaneVisible = paneLayout.messagePaneVisible;
+ mailTabObject.sortType = SORT_TYPE_MAP.get(gViewWrapper?.primarySortType);
+ mailTabObject.sortOrder = SORT_ORDER_MAP.get(gViewWrapper?.primarySortOrder);
+ if (gViewWrapper?.showGroupedBySort) {
+ mailTabObject.viewType = "groupedBySortType";
+ } else if (gViewWrapper?.showThreaded) {
+ mailTabObject.viewType = "groupedByThread";
+ } else {
+ mailTabObject.viewType = "ungrouped";
+ }
+ if (context.extension.hasPermission("accountsRead")) {
+ mailTabObject.displayedFolder = convertFolder(about3Pane.gFolder);
+ }
+ return mailTabObject;
+}
+
+/**
+ * Listens for changes in the UI to fire events.
+ */
+var uiListener = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.handleEvent = this.handleEvent.bind(this);
+ this.lastSelected = new WeakMap();
+ }
+
+ handleEvent(event) {
+ let browser = event.target.browsingContext.embedderElement;
+ let tabmail = browser.ownerGlobal.top.document.getElementById("tabmail");
+ let nativeTab = tabmail.tabInfo.find(
+ t =>
+ t.chromeBrowser == browser ||
+ t.chromeBrowser == browser.browsingContext.parent.embedderElement
+ );
+
+ if (nativeTab.mode.name != "mail3PaneTab") {
+ return;
+ }
+
+ let tabId = tabTracker.getId(nativeTab);
+ let tab = tabTracker.getTab(tabId);
+
+ if (event.type == "folderURIChanged") {
+ let folderURI = event.detail;
+ let folder = MailServices.folderLookup.getFolderForURL(folderURI);
+ if (this.lastSelected.get(tab) == folder) {
+ return;
+ }
+ this.lastSelected.set(tab, folder);
+ this.emit("folder-changed", tab, folder);
+ } else if (event.type == "messageURIChanged") {
+ let messages =
+ nativeTab.chromeBrowser.contentWindow.gDBView?.getSelectedMsgHdrs();
+ if (messages) {
+ this.emit("messages-changed", tab, messages);
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ windowTracker.addListener("folderURIChanged", this);
+ windowTracker.addListener("messageURIChanged", this);
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ windowTracker.removeListener("folderURIChanged", this);
+ windowTracker.removeListener("messageURIChanged", this);
+ this.lastSelected = new WeakMap();
+ }
+ }
+})();
+
+this.mailTabs = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onDisplayedFolderChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event, tab, folder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(tabManager.convert(tab), convertFolder(folder));
+ }
+ uiListener.on("folder-changed", listener);
+ uiListener.incrementListeners();
+ return {
+ unregister: () => {
+ uiListener.off("folder-changed", listener);
+ uiListener.decrementListeners();
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onSelectedMessagesChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event, tab, messages) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let page = await messageListTracker.startList(messages, extension);
+ fire.sync(tabManager.convert(tab), page);
+ }
+ uiListener.on("messages-changed", listener);
+ uiListener.incrementListeners();
+ return {
+ unregister: () => {
+ uiListener.off("messages-changed", listener);
+ uiListener.decrementListeners();
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ /**
+ * Gets the tab for the given tab id, or the active tab if the id is null.
+ *
+ * @param {?Integer} tabId - The tab id to get
+ * @returns {Tab} The matching tab, or the active tab
+ */
+ async function getTabOrActive(tabId) {
+ let tab;
+ if (tabId) {
+ tab = tabManager.get(tabId);
+ } else {
+ tab = tabManager.wrapTab(tabTracker.activeTab);
+ tabId = tab.id;
+ }
+
+ if (tab && tab.type == "mail") {
+ let windowId = windowTracker.getId(getTabWindow(tab.nativeTab));
+ // Before doing anything with the mail tab, ensure its outer window is
+ // fully loaded.
+ await getNormalWindowReady(context, windowId);
+ return tab;
+ }
+ throw new ExtensionError(`Invalid mail tab ID: ${tabId}`);
+ }
+
+ /**
+ * Set the currently displayed folder in the given tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo
+ * @param {nsIMsgFolder} folder
+ * @param {boolean} restorePreviousSelection - Select the previously selected
+ * messages of the folder, after it has been set.
+ */
+ async function setFolder(nativeTabInfo, folder, restorePreviousSelection) {
+ let about3Pane = nativeTabInfo.chromeBrowser.contentWindow;
+ if (!nativeTabInfo.folder || nativeTabInfo.folder.URI != folder.URI) {
+ await new Promise(resolve => {
+ let listener = event => {
+ if (event.detail == folder.URI) {
+ about3Pane.removeEventListener("folderURIChanged", listener);
+ resolve();
+ }
+ };
+ about3Pane.addEventListener("folderURIChanged", listener);
+ if (restorePreviousSelection) {
+ about3Pane.restoreState({
+ folderURI: folder.URI,
+ });
+ } else {
+ about3Pane.threadPane.forgetSelection(folder.URI);
+ nativeTabInfo.folder = folder;
+ }
+ });
+ }
+ }
+
+ return {
+ mailTabs: {
+ async query({ active, currentWindow, lastFocusedWindow, windowId }) {
+ await getNormalWindowReady();
+ return Array.from(
+ tabManager.query(
+ {
+ active,
+ currentWindow,
+ lastFocusedWindow,
+ mailTab: true,
+ windowId,
+
+ // All of these are needed for tabManager to return every tab we want.
+ cookieStoreId: null,
+ index: null,
+ screen: null,
+ title: null,
+ url: null,
+ windowType: null,
+ },
+ context
+ ),
+ tab => convertMailTab(tab, context)
+ );
+ },
+
+ async get(tabId) {
+ let tab = await getTabOrActive(tabId);
+ return convertMailTab(tab, context);
+ },
+ async getCurrent() {
+ try {
+ let tab = await getTabOrActive();
+ return convertMailTab(tab, context);
+ } catch (e) {
+ // Do not throw, if the active tab is not a mail tab, but return undefined.
+ return undefined;
+ }
+ },
+
+ async update(tabId, args) {
+ let tab = await getTabOrActive(tabId);
+ let { nativeTab } = tab;
+ let about3Pane = nativeTab.chromeBrowser.contentWindow;
+
+ let {
+ displayedFolder,
+ layout,
+ folderPaneVisible,
+ messagePaneVisible,
+ sortOrder,
+ sortType,
+ viewType,
+ } = args;
+
+ if (displayedFolder) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Updating the displayed folder requires the "accountsRead" permission'
+ );
+ }
+
+ let folderUri = folderPathToURI(
+ displayedFolder.accountId,
+ displayedFolder.path
+ );
+ let folder = MailServices.folderLookup.getFolderForURL(folderUri);
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder "${displayedFolder.path}" for account ` +
+ `"${displayedFolder.accountId}" not found.`
+ );
+ }
+ await setFolder(nativeTab, folder, true);
+ }
+
+ if (sortType) {
+ // Change "foo" to "byFoo".
+ sortType = "by" + sortType[0].toUpperCase() + sortType.substring(1);
+ if (
+ sortType in Ci.nsMsgViewSortType &&
+ sortOrder &&
+ sortOrder in Ci.nsMsgViewSortOrder
+ ) {
+ about3Pane.gViewWrapper.sort(
+ Ci.nsMsgViewSortType[sortType],
+ Ci.nsMsgViewSortOrder[sortOrder]
+ );
+ }
+ }
+
+ switch (viewType) {
+ case "groupedBySortType":
+ about3Pane.gViewWrapper.showGroupedBySort = true;
+ break;
+ case "groupedByThread":
+ about3Pane.gViewWrapper.showThreaded = true;
+ break;
+ case "ungrouped":
+ about3Pane.gViewWrapper.showUnthreaded = true;
+ break;
+ }
+
+ // Layout applies to all folder tabs.
+ if (layout) {
+ Services.prefs.setIntPref(
+ "mail.pane_config.dynamic",
+ LAYOUTS.indexOf(layout)
+ );
+ }
+
+ if (typeof folderPaneVisible == "boolean") {
+ about3Pane.paneLayout.folderPaneVisible = folderPaneVisible;
+ }
+ if (typeof messagePaneVisible == "boolean") {
+ about3Pane.paneLayout.messagePaneVisible = messagePaneVisible;
+ }
+ },
+
+ async getSelectedMessages(tabId) {
+ let tab = await getTabOrActive(tabId);
+ let dbView = tab.nativeTab.chromeBrowser.contentWindow?.gDBView;
+ let messageList = dbView ? dbView.getSelectedMsgHdrs() : [];
+ return messageListTracker.startList(messageList, extension);
+ },
+
+ async setSelectedMessages(tabId, messageIds) {
+ if (
+ !extension.hasPermission("messagesRead") ||
+ !extension.hasPermission("accountsRead")
+ ) {
+ throw new ExtensionError(
+ 'Using mailTabs.setSelectedMessages() requires the "accountsRead" and the "messagesRead" permission'
+ );
+ }
+
+ let tab = await getTabOrActive(tabId);
+ let refFolder, refMsgId;
+ let msgHdrs = [];
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!refFolder) {
+ refFolder = msgHdr.folder;
+ refMsgId = messageId;
+ }
+ if (msgHdr.folder == refFolder) {
+ msgHdrs.push(msgHdr);
+ } else {
+ throw new ExtensionError(
+ `Message ${refMsgId} and message ${messageId} are not in the same folder, cannot select them both.`
+ );
+ }
+ }
+
+ if (refFolder) {
+ await setFolder(tab.nativeTab, refFolder, false);
+ }
+ let about3Pane = tab.nativeTab.chromeBrowser.contentWindow;
+ const selectedIndices = msgHdrs.map(
+ about3Pane.gViewWrapper.getViewIndexForMsgHdr,
+ about3Pane.gViewWrapper
+ );
+ about3Pane.threadTree.selectedIndices = selectedIndices;
+ if (selectedIndices.length) {
+ about3Pane.threadTree.scrollToIndex(selectedIndices[0], true);
+ }
+ },
+
+ async setQuickFilter(tabId, state) {
+ let tab = await getTabOrActive(tabId);
+ let nativeTab = tab.nativeTab;
+ let about3Pane = nativeTab.chromeBrowser.contentWindow;
+
+ let filterer = about3Pane.quickFilterBar.filterer;
+ filterer.clear();
+
+ // Map of QuickFilter state names to possible WebExtensions state names.
+ let stateMap = {
+ unread: "unread",
+ starred: "flagged",
+ addrBook: "contact",
+ attachment: "attachment",
+ };
+
+ filterer.visible = state.show !== false;
+ for (let [key, name] of Object.entries(stateMap)) {
+ filterer.setFilterValue(key, state[name]);
+ }
+
+ if (state.tags) {
+ filterer.filterValues.tags = {
+ mode: "OR",
+ tags: {},
+ };
+ for (let tag of MailServices.tags.getAllTags()) {
+ filterer.filterValues.tags[tag.key] = null;
+ }
+ if (typeof state.tags == "object") {
+ filterer.filterValues.tags.mode =
+ state.tags.mode == "any" ? "OR" : "AND";
+ for (let [key, value] of Object.entries(state.tags.tags)) {
+ filterer.filterValues.tags.tags[key] = value;
+ }
+ }
+ }
+ if (state.text) {
+ filterer.filterValues.text = {
+ states: {
+ recipients: state.text.recipients || false,
+ sender: state.text.author || false,
+ subject: state.text.subject || false,
+ body: state.text.body || false,
+ },
+ text: state.text.text,
+ };
+ }
+
+ about3Pane.quickFilterBar.updateSearch();
+ },
+
+ onDisplayedFolderChanged: new EventManager({
+ context,
+ module: "mailTabs",
+ event: "onDisplayedFolderChanged",
+ extensionApi: this,
+ }).api(),
+
+ onSelectedMessagesChanged: new EventManager({
+ context,
+ module: "mailTabs",
+ event: "onSelectedMessagesChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-menus.js b/comm/mail/components/extensions/parent/ext-menus.js
new file mode 100644
index 0000000000..0db7ddf809
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1544 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { SelectionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/SelectionUtils.sys.mjs"
+);
+
+var { DefaultMap, ExtensionError } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { IconDetails, StartupCache } = ExtensionParent;
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
+
+// Map[Extension -> Map[ID -> MenuItem]]
+// Note: we want to enumerate all the menu items so
+// this cannot be a weak map.
+var gMenuMap = new Map();
+
+// Map[Extension -> Map[ID -> MenuCreateProperties]]
+// The map object for each extension is a reference to the same
+// object in StartupCache.menus. This provides a non-async
+// getter for that object.
+var gStartupCache = new Map();
+
+// Map[Extension -> MenuItem]
+var gRootItems = new Map();
+
+// Map[Extension -> ID[]]
+// Menu IDs that were eligible for being shown in the current menu.
+var gShownMenuItems = new DefaultMap(() => []);
+
+// Map[Extension -> Set[Contexts]]
+// A DefaultMap (keyed by extension) which keeps track of the
+// contexts with a subscribed onShown event listener.
+var gOnShownSubscribers = new DefaultMap(() => new Set());
+
+// If id is not specified for an item we use an integer.
+var gNextMenuItemID = 0;
+
+// Used to assign unique names to radio groups.
+var gNextRadioGroupID = 0;
+
+// The max length of a menu item's label.
+var gMaxLabelLength = 64;
+
+var gMenuBuilder = {
+ // When a new menu is opened, this function is called and
+ // we populate the |xulMenu| with all the items from extensions
+ // to be displayed. We always clear all the items again when
+ // popuphidden fires.
+ build(contextData) {
+ contextData = this.maybeOverrideContextData(contextData);
+ let xulMenu = contextData.menu;
+ xulMenu.addEventListener("popuphidden", this);
+ this.xulMenu = xulMenu;
+ for (let [, root] of gRootItems) {
+ this.createAndInsertTopLevelElements(root, contextData, null);
+ }
+ this.afterBuildingMenu(contextData);
+
+ if (
+ contextData.webExtContextData &&
+ !contextData.webExtContextData.showDefaults
+ ) {
+ // Wait until nsContextMenu.js has toggled the visibility of the default
+ // menu items before hiding the default items.
+ Promise.resolve().then(() => this.hideDefaultMenuItems());
+ }
+ },
+
+ maybeOverrideContextData(contextData) {
+ let { webExtContextData } = contextData;
+ if (!webExtContextData || !webExtContextData.overrideContext) {
+ return contextData;
+ }
+ let contextDataBase = {
+ menu: contextData.menu,
+ // eslint-disable-next-line no-use-before-define
+ originalViewType: getContextViewType(contextData),
+ originalViewUrl: contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl,
+ webExtContextData,
+ };
+ if (webExtContextData.overrideContext === "tab") {
+ // TODO: Handle invalid tabs more gracefully (instead of throwing).
+ let tab = tabTracker.getTab(webExtContextData.tabId);
+ return {
+ ...contextDataBase,
+ tab,
+ pageUrl: tab.linkedBrowser?.currentURI?.spec,
+ onTab: true,
+ };
+ }
+ throw new ExtensionError(
+ `Unexpected overrideContext: ${webExtContextData.overrideContext}`
+ );
+ },
+
+ createAndInsertTopLevelElements(root, contextData, nextSibling) {
+ const newWebExtensionGroupSeparator = () => {
+ let element =
+ this.xulMenu.ownerDocument.createXULElement("menuseparator");
+ element.classList.add("webextension-group-separator");
+ return element;
+ };
+
+ let rootElements;
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onComposeAction ||
+ contextData.onMessageDisplayAction
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ ACTION_MENU_TOP_LEVEL_LIMIT,
+ false
+ );
+
+ // Action menu items are prepended to the menu, followed by a separator.
+ nextSibling = nextSibling || this.xulMenu.firstElementChild;
+ if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
+ rootElements.push(newWebExtensionGroupSeparator());
+ }
+ } else if (
+ contextData.inActionMenu ||
+ contextData.inBrowserActionMenu ||
+ contextData.inComposeActionMenu ||
+ contextData.inMessageDisplayActionMenu
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ } else if (contextData.webExtContextData) {
+ let { extensionId, showDefaults, overrideContext } =
+ contextData.webExtContextData;
+ if (extensionId === root.extension.id) {
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ // The extension menu should be rendered at the top, but after the navigation buttons.
+ nextSibling =
+ nextSibling || this.xulMenu.querySelector(":scope > :first-child");
+ if (
+ rootElements.length &&
+ showDefaults &&
+ !this.itemsToCleanUp.has(nextSibling)
+ ) {
+ rootElements.push(newWebExtensionGroupSeparator());
+ }
+ } else if (!showDefaults && !overrideContext) {
+ // When the default menu items should be hidden, menu items from other
+ // extensions should be hidden too.
+ return;
+ }
+ // Fall through to show default extension menu items.
+ }
+
+ if (!rootElements) {
+ rootElements = this.buildTopLevelElements(root, contextData, 1, true);
+ if (
+ rootElements.length &&
+ !this.itemsToCleanUp.has(this.xulMenu.lastElementChild) &&
+ this.xulMenu.firstChild
+ ) {
+ // All extension menu items are appended at the end.
+ // Prepend separator if this is the first extension menu item.
+ rootElements.unshift(newWebExtensionGroupSeparator());
+ }
+ }
+
+ if (!rootElements.length) {
+ return;
+ }
+
+ if (nextSibling) {
+ nextSibling.before(...rootElements);
+ } else {
+ this.xulMenu.append(...rootElements);
+ }
+ for (let item of rootElements) {
+ this.itemsToCleanUp.add(item);
+ }
+ },
+
+ buildElementWithChildren(item, contextData) {
+ const element = this.buildSingleElement(item, contextData);
+ const children = this.buildChildren(item, contextData);
+ if (children.length) {
+ element.firstElementChild.append(...children);
+ }
+ return element;
+ },
+
+ buildChildren(item, contextData) {
+ let groupName;
+ let children = [];
+ for (let child of item.children) {
+ if (child.type == "radio" && !child.groupName) {
+ if (!groupName) {
+ groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+ }
+ child.groupName = groupName;
+ } else {
+ groupName = null;
+ }
+
+ if (child.enabledForContext(contextData)) {
+ children.push(this.buildElementWithChildren(child, contextData));
+ }
+ }
+ return children;
+ },
+
+ buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
+ let children = this.buildChildren(root, contextData);
+
+ // TODO: Fix bug 1492969 and remove this whole if block.
+ if (
+ children.length === 1 &&
+ maxCount === 1 &&
+ forceManifestIcons &&
+ AppConstants.platform === "linux" &&
+ children[0].getAttribute("type") === "checkbox"
+ ) {
+ // Keep single checkbox items in the submenu on Linux since
+ // the extension icon overlaps the checkbox otherwise.
+ maxCount = 0;
+ }
+
+ if (children.length > maxCount) {
+ // Move excess items into submenu.
+ let rootElement = this.buildSingleElement(root, contextData);
+ rootElement.setAttribute("ext-type", "top-level-menu");
+ rootElement.firstElementChild.append(...children.splice(maxCount - 1));
+ children.push(rootElement);
+ }
+
+ if (forceManifestIcons) {
+ for (let rootElement of children) {
+ // Display the extension icon on the root element.
+ if (
+ root.extension.manifest.icons &&
+ rootElement.getAttribute("type") !== "checkbox"
+ ) {
+ this.setMenuItemIcon(
+ rootElement,
+ root.extension,
+ contextData,
+ root.extension.manifest.icons
+ );
+ } else {
+ this.removeMenuItemIcon(rootElement);
+ }
+ }
+ }
+ return children;
+ },
+
+ removeSeparatorIfNoTopLevelItems() {
+ // Extension menu items always have have a non-empty ID.
+ let isNonExtensionSeparator = item =>
+ item.nodeName === "menuseparator" && !item.id;
+
+ // itemsToCleanUp contains all top-level menu items. A separator should
+ // only be kept if it is next to an extension menu item.
+ let isExtensionMenuItemSibling = item =>
+ item && this.itemsToCleanUp.has(item) && !isNonExtensionSeparator(item);
+
+ for (let item of this.itemsToCleanUp) {
+ if (isNonExtensionSeparator(item)) {
+ if (
+ !isExtensionMenuItemSibling(item.previousElementSibling) &&
+ !isExtensionMenuItemSibling(item.nextElementSibling)
+ ) {
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+ }
+ },
+
+ buildSingleElement(item, contextData) {
+ let doc = contextData.menu.ownerDocument;
+ let element;
+ if (item.children.length) {
+ element = this.createMenuElement(doc, item);
+ } else if (item.type == "separator") {
+ element = doc.createXULElement("menuseparator");
+ } else {
+ element = doc.createXULElement("menuitem");
+ }
+
+ return this.customizeElement(element, item, contextData);
+ },
+
+ createMenuElement(doc, item) {
+ let element = doc.createXULElement("menu");
+ // Menu elements need to have a menupopup child for its menu items.
+ let menupopup = doc.createXULElement("menupopup");
+ element.appendChild(menupopup);
+ return element;
+ },
+
+ customizeElement(element, item, contextData) {
+ let label = item.title;
+ if (label) {
+ let accessKey;
+ label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
+ if (nextChar === "&") {
+ return "&";
+ }
+ if (accessKey === undefined) {
+ if (nextChar === "%" && label.charAt(i + 2) === "s") {
+ accessKey = "";
+ } else {
+ accessKey = nextChar;
+ }
+ }
+ return nextChar;
+ });
+ element.setAttribute("accesskey", accessKey || "");
+
+ if (contextData.isTextSelected && label.includes("%s")) {
+ let selection = contextData.selectionText.trim();
+ // The rendering engine will truncate the title if it's longer than 64 characters.
+ // But if it makes sense let's try truncate selection text only, to handle cases like
+ // 'look up "%s" in MyDictionary' more elegantly.
+
+ let codePointsToRemove = 0;
+
+ let selectionArray = Array.from(selection);
+
+ let completeLabelLength = label.length - 2 + selectionArray.length;
+ if (completeLabelLength > gMaxLabelLength) {
+ codePointsToRemove = completeLabelLength - gMaxLabelLength;
+ }
+
+ if (codePointsToRemove) {
+ let ellipsis = "\u2026";
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ codePointsToRemove += 1;
+ selection =
+ selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
+ }
+
+ label = label.replace(/%s/g, selection);
+ }
+
+ element.setAttribute("label", label);
+ }
+
+ element.setAttribute("id", item.elementId);
+
+ if ("icons" in item) {
+ if (item.icons) {
+ this.setMenuItemIcon(element, item.extension, contextData, item.icons);
+ } else {
+ this.removeMenuItemIcon(element);
+ }
+ }
+
+ if (item.type == "checkbox") {
+ element.setAttribute("type", "checkbox");
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ } else if (item.type == "radio") {
+ element.setAttribute("type", "radio");
+ element.setAttribute("name", item.groupName);
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ }
+
+ if (!item.enabled) {
+ element.setAttribute("disabled", "true");
+ }
+
+ let button;
+
+ element.addEventListener(
+ "command",
+ async event => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ const wasChecked = item.checked;
+ if (item.type == "checkbox") {
+ item.checked = !item.checked;
+ } else if (item.type == "radio") {
+ // Deselect all radio items in the current radio group.
+ for (let child of item.parent.children) {
+ if (child.type == "radio" && child.groupName == item.groupName) {
+ child.checked = false;
+ }
+ }
+ // Select the clicked radio item.
+ item.checked = true;
+ }
+
+ let { webExtContextData } = contextData;
+ if (
+ contextData.tab &&
+ // If the menu context was overridden by the extension, do not grant
+ // activeTab since the extension also controls the tabId.
+ (!webExtContextData ||
+ webExtContextData.extensionId !== item.extension.id)
+ ) {
+ item.tabManager.addActiveTabPermission(contextData.tab);
+ }
+
+ let info = await item.getClickInfo(contextData, wasChecked);
+ info.modifiers = clickModifiersFromEvent(event);
+
+ info.button = button;
+ let _execute_action =
+ item.extension.manifestVersion < 3
+ ? "_execute_browser_action"
+ : "_execute_action";
+
+ // Allow menus to open various actions supported in webext prior
+ // to notifying onclicked.
+ let actionFor = {
+ [_execute_action]: global.browserActionFor,
+ _execute_compose_action: global.composeActionFor,
+ _execute_message_display_action: global.messageDisplayActionFor,
+ }[item.command];
+ if (actionFor) {
+ let win = event.target.ownerGlobal;
+ actionFor(item.extension).triggerAction(win);
+ return;
+ }
+
+ item.extension.emit(
+ "webext-menu-menuitem-click",
+ info,
+ contextData.tab
+ );
+ },
+ { once: true }
+ );
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ element.addEventListener("click", event => {
+ if (
+ event.target !== event.currentTarget ||
+ // Ignore menu items that are usually not clickeable,
+ // such as separators and parents of submenus and disabled items.
+ element.localName !== "menuitem" ||
+ element.disabled
+ ) {
+ return;
+ }
+
+ button = event.button;
+ if (event.button) {
+ element.doCommand();
+ contextData.menu.hidePopup();
+ }
+ });
+
+ // Don't publish the ID of the root because the root element is
+ // auto-generated.
+ if (item.parent) {
+ gShownMenuItems.get(item.extension).push(item.id);
+ }
+
+ return element;
+ },
+
+ setMenuItemIcon(element, extension, contextData, icons) {
+ let parentWindow = contextData.menu.ownerGlobal;
+
+ let { icon } = IconDetails.getPreferredIcon(
+ icons,
+ extension,
+ 16 * parentWindow.devicePixelRatio
+ );
+
+ // The extension icons in the manifest are not pre-resolved, since
+ // they're sometimes used by the add-on manager when the extension is
+ // not enabled, and its URLs are not resolvable.
+ let resolvedURL = extension.baseURI.resolve(icon);
+
+ if (element.localName == "menu") {
+ element.setAttribute("class", "menu-iconic");
+ } else if (element.localName == "menuitem") {
+ element.setAttribute("class", "menuitem-iconic");
+ }
+
+ element.setAttribute("image", resolvedURL);
+ },
+
+ // Undo changes from setMenuItemIcon.
+ removeMenuItemIcon(element) {
+ element.removeAttribute("class");
+ element.removeAttribute("image");
+ },
+
+ rebuildMenu(extension) {
+ let { contextData } = this;
+ if (!contextData) {
+ // This happens if the menu is not visible.
+ return;
+ }
+
+ // Find the group of existing top-level items (usually 0 or 1 items)
+ // and remember its position for when the new items are inserted.
+ let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+ let nextSibling = null;
+ for (let item of this.itemsToCleanUp) {
+ if (item.id && item.id.startsWith(elementIdPrefix)) {
+ nextSibling = item.nextSibling;
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+
+ let root = gRootItems.get(extension);
+ if (root) {
+ this.createAndInsertTopLevelElements(root, contextData, nextSibling);
+ }
+ this.removeSeparatorIfNoTopLevelItems();
+ },
+
+ // This should be called once, after constructing the top-level menus, if any.
+ afterBuildingMenu(contextData) {
+ function dispatchOnShownEvent(extension) {
+ // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
+ // extension to be stored in the map even if there are currently no
+ // shown menu items. This ensures that the onHidden event can be fired
+ // when the menu is closed.
+ let menuIds = gShownMenuItems.get(extension);
+ extension.emit("webext-menu-shown", menuIds, contextData);
+ }
+
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onComposeAction ||
+ contextData.onMessageDisplayAction
+ ) {
+ dispatchOnShownEvent(contextData.extension);
+ } else {
+ for (const extension of gOnShownSubscribers.keys()) {
+ dispatchOnShownEvent(extension);
+ }
+ }
+
+ this.contextData = contextData;
+ },
+
+ hideDefaultMenuItems() {
+ for (let item of this.xulMenu.children) {
+ if (!this.itemsToCleanUp.has(item)) {
+ item.hidden = true;
+ }
+ }
+ },
+
+ handleEvent(event) {
+ if (this.xulMenu != event.target || event.type != "popuphidden") {
+ return;
+ }
+
+ delete this.xulMenu;
+ delete this.contextData;
+
+ let target = event.target;
+ target.removeEventListener("popuphidden", this);
+ for (let item of this.itemsToCleanUp) {
+ item.remove();
+ }
+ this.itemsToCleanUp.clear();
+ for (let extension of gShownMenuItems.keys()) {
+ extension.emit("webext-menu-hidden");
+ }
+ gShownMenuItems.clear();
+ },
+
+ itemsToCleanUp: new Set(),
+};
+
+// Called from different action popups.
+global.actionContextMenu = function (contextData) {
+ contextData.originalViewType = "tab";
+ gMenuBuilder.build(contextData);
+};
+
+const contextsMap = {
+ onAudio: "audio",
+ onEditable: "editable",
+ inFrame: "frame",
+ onImage: "image",
+ onLink: "link",
+ onPassword: "password",
+ isTextSelected: "selection",
+ onVideo: "video",
+
+ onAction: "action",
+ onBrowserAction: "browser_action",
+ onComposeAction: "compose_action",
+ onMessageDisplayAction: "message_display_action",
+ inActionMenu: "action_menu",
+ inBrowserActionMenu: "browser_action_menu",
+ inComposeActionMenu: "compose_action_menu",
+ inMessageDisplayActionMenu: "message_display_action_menu",
+
+ onComposeBody: "compose_body",
+ onTab: "tab",
+ inToolsMenu: "tools_menu",
+ selectedMessages: "message_list",
+ selectedFolder: "folder_pane",
+ selectedComposeAttachments: "compose_attachments",
+ selectedMessageAttachments: "message_attachments",
+ allMessageAttachments: "all_message_attachments",
+};
+
+const chromeElementsMap = {
+ msgSubject: "composeSubject",
+ toAddrInput: "composeTo",
+ ccAddrInput: "composeCc",
+ bccAddrInput: "composeBcc",
+ replyAddrInput: "composeReplyTo",
+ newsgroupsAddrInput: "composeNewsgroupTo",
+ followupAddrInput: "composeFollowupTo",
+};
+
+const getMenuContexts = contextData => {
+ let contexts = new Set();
+
+ for (const [key, value] of Object.entries(contextsMap)) {
+ if (contextData[key]) {
+ contexts.add(value);
+ }
+ }
+
+ if (contexts.size === 0) {
+ contexts.add("page");
+ }
+
+ // New non-content contexts supported in Thunderbird are not part of "all".
+ if (!contextData.onTab && !contextData.inToolsMenu) {
+ contexts.add("all");
+ }
+
+ return contexts;
+};
+
+function getContextViewType(contextData) {
+ if ("originalViewType" in contextData) {
+ return contextData.originalViewType;
+ }
+ if (
+ contextData.webExtBrowserType === "popup" ||
+ contextData.webExtBrowserType === "sidebar"
+ ) {
+ return contextData.webExtBrowserType;
+ }
+ if (contextData.tab && contextData.menu.id === "browserContext") {
+ return "tab";
+ }
+ return undefined;
+}
+
+async function addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+) {
+ info.viewType = getContextViewType(contextData);
+ if (contextData.onVideo) {
+ info.mediaType = "video";
+ } else if (contextData.onAudio) {
+ info.mediaType = "audio";
+ } else if (contextData.onImage) {
+ info.mediaType = "image";
+ }
+ if (contextData.frameId !== undefined) {
+ info.frameId = contextData.frameId;
+ }
+ info.editable = contextData.onEditable || false;
+ if (includeSensitiveData) {
+ if (contextData.timeStamp) {
+ // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+ info.targetElementId = Math.floor(contextData.timeStamp);
+ }
+ if (contextData.onLink) {
+ info.linkText = contextData.linkText;
+ info.linkUrl = contextData.linkUrl;
+ }
+ if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+ info.srcUrl = contextData.srcUrl;
+ }
+ info.pageUrl = contextData.pageUrl;
+ if (contextData.inFrame) {
+ info.frameUrl = contextData.frameUrl;
+ }
+ if (contextData.isTextSelected) {
+ info.selectionText = contextData.selectionText;
+ }
+ }
+ // If the context was overridden, then frameUrl should be the URL of the
+ // document in which the menu was opened (instead of undefined, even if that
+ // document is not in a frame).
+ if (contextData.originalViewUrl) {
+ info.frameUrl = contextData.originalViewUrl;
+ }
+
+ if (contextData.fieldId) {
+ info.fieldId = contextData.fieldId;
+ }
+
+ if (contextData.selectedMessages && extension.hasPermission("messagesRead")) {
+ info.selectedMessages = await messageListTracker.startList(
+ contextData.selectedMessages,
+ extension
+ );
+ }
+ if (extension.hasPermission("accountsRead")) {
+ for (let folderType of ["displayedFolder", "selectedFolder"]) {
+ if (contextData[folderType]) {
+ let folder = convertFolder(contextData[folderType]);
+ // If the context menu click in the folder pane occurred on a root folder
+ // representing an account, do not include a selectedFolder object, but
+ // the corresponding selectedAccount object.
+ if (folderType == "selectedFolder" && folder.path == "/") {
+ info.selectedAccount = convertAccount(
+ MailServices.accounts.getAccount(folder.accountId)
+ );
+ } else {
+ info[folderType] = traverseSubfolders(
+ contextData[folderType],
+ folder.accountId
+ );
+ }
+ }
+ }
+ }
+ if (
+ (contextData.selectedMessageAttachments ||
+ contextData.allMessageAttachments) &&
+ extension.hasPermission("messagesRead")
+ ) {
+ let attachments =
+ contextData.selectedMessageAttachments ||
+ contextData.allMessageAttachments;
+ info.attachments = attachments.map(attachment => {
+ return {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partID,
+ };
+ });
+ }
+ if (
+ contextData.selectedComposeAttachments &&
+ extension.hasPermission("compose")
+ ) {
+ if (!("composeAttachmentTracker" in global)) {
+ extensions.loadModule("compose");
+ }
+
+ info.attachments = contextData.selectedComposeAttachments.map(a =>
+ global.composeAttachmentTracker.convert(a, contextData.menu.ownerGlobal)
+ );
+ }
+}
+
+class MenuItem {
+ constructor(extension, createProperties, isRoot = false) {
+ this.extension = extension;
+ this.children = [];
+ this.parent = null;
+ this.tabManager = extension.tabManager;
+
+ this.setDefaults();
+ this.setProps(createProperties);
+
+ if (!this.hasOwnProperty("_id")) {
+ this.id = gNextMenuItemID++;
+ }
+ // If the item is not the root and has no parent
+ // it must be a child of the root.
+ if (!isRoot && !this.parent) {
+ this.root.addChild(this);
+ }
+ }
+
+ static mergeProps(obj, properties) {
+ for (let propName in properties) {
+ if (properties[propName] === null) {
+ // Omitted optional argument.
+ continue;
+ }
+ obj[propName] = properties[propName];
+ }
+
+ if ("icons" in properties) {
+ if (properties.icons === null) {
+ obj.icons = null;
+ } else if (typeof properties.icons == "string") {
+ obj.icons = { 16: properties.icons };
+ }
+ }
+ }
+
+ setProps(createProperties) {
+ MenuItem.mergeProps(this, createProperties);
+
+ if (createProperties.documentUrlPatterns != null) {
+ this.documentUrlMatchPattern = new MatchPatternSet(
+ this.documentUrlPatterns,
+ {
+ restrictSchemes: this.extension.restrictSchemes,
+ }
+ );
+ }
+
+ if (createProperties.targetUrlPatterns != null) {
+ this.targetUrlMatchPattern = new MatchPatternSet(this.targetUrlPatterns, {
+ // restrictSchemes default to false when matching links instead of pages
+ // (see Bug 1280370 for a rationale).
+ restrictSchemes: false,
+ });
+ }
+
+ // If a child MenuItem does not specify any contexts, then it should
+ // inherit the contexts specified from its parent.
+ if (createProperties.parentId && !createProperties.contexts) {
+ this.contexts = this.parent.contexts;
+ }
+ }
+
+ setDefaults() {
+ this.setProps({
+ type: "normal",
+ checked: false,
+ contexts: ["all"],
+ enabled: true,
+ visible: true,
+ });
+ }
+
+ set id(id) {
+ if (this.hasOwnProperty("_id")) {
+ throw new ExtensionError("ID of a MenuItem cannot be changed");
+ }
+ let isIdUsed = gMenuMap.get(this.extension).has(id);
+ if (isIdUsed) {
+ throw new ExtensionError(`ID already exists: ${id}`);
+ }
+ this._id = id;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get elementId() {
+ let id = this.id;
+ // If the ID is an integer, it is auto-generated and globally unique.
+ // If the ID is a string, it is only unique within one extension and the
+ // ID needs to be concatenated with the extension ID.
+ if (typeof id !== "number") {
+ // To avoid collisions with numeric IDs, add a prefix to string IDs.
+ id = `_${id}`;
+ }
+ return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
+ }
+
+ ensureValidParentId(parentId) {
+ if (parentId === undefined) {
+ return;
+ }
+ let menuMap = gMenuMap.get(this.extension);
+ if (!menuMap.has(parentId)) {
+ throw new ExtensionError(
+ `Could not find any MenuItem with id: ${parentId}`
+ );
+ }
+ for (let item = menuMap.get(parentId); item; item = item.parent) {
+ if (item === this) {
+ throw new ExtensionError(
+ "MenuItem cannot be an ancestor (or self) of its new parent."
+ );
+ }
+ }
+ }
+
+ /**
+ * When updating menu properties we need to ensure parents exist
+ * in the cache map before children. That allows the menus to be
+ * created in the correct sequence on startup. This reparents the
+ * tree starting from this instance of MenuItem.
+ */
+ reparentInCache() {
+ let { id, extension } = this;
+ let cachedMap = gStartupCache.get(extension);
+ let createProperties = cachedMap.get(id);
+ cachedMap.delete(id);
+ cachedMap.set(id, createProperties);
+
+ for (let child of this.children) {
+ child.reparentInCache();
+ }
+ }
+
+ set parentId(parentId) {
+ this.ensureValidParentId(parentId);
+
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+
+ if (parentId === undefined) {
+ this.root.addChild(this);
+ } else {
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.get(parentId).addChild(this);
+ }
+ }
+
+ get parentId() {
+ return this.parent ? this.parent.id : undefined;
+ }
+
+ addChild(child) {
+ if (child.parent) {
+ throw new ExtensionError("Child MenuItem already has a parent.");
+ }
+ this.children.push(child);
+ child.parent = this;
+ }
+
+ detachChild(child) {
+ let idx = this.children.indexOf(child);
+ if (idx < 0) {
+ throw new ExtensionError(
+ "Child MenuItem not found, it cannot be removed."
+ );
+ }
+ this.children.splice(idx, 1);
+ child.parent = null;
+ }
+
+ get root() {
+ let extension = this.extension;
+ if (!gRootItems.has(extension)) {
+ let root = new MenuItem(
+ extension,
+ { title: extension.name },
+ /* isRoot = */ true
+ );
+ gRootItems.set(extension, root);
+ }
+
+ return gRootItems.get(extension);
+ }
+
+ remove() {
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+ let children = this.children.slice(0);
+ for (let child of children) {
+ child.remove();
+ }
+
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.delete(this.id);
+ // Menu items are saved if !extension.persistentBackground.
+ if (gStartupCache.get(this.extension)?.delete(this.id)) {
+ StartupCache.save();
+ }
+ if (this.root == this) {
+ gRootItems.delete(this.extension);
+ }
+ }
+
+ async getClickInfo(contextData, wasChecked) {
+ let info = {
+ menuItemId: this.id,
+ };
+ if (this.parent) {
+ info.parentMenuItemId = this.parentId;
+ }
+
+ await addMenuEventInfo(info, contextData, this.extension, true);
+
+ if (this.type === "checkbox" || this.type === "radio") {
+ info.checked = this.checked;
+ info.wasChecked = wasChecked;
+ }
+
+ return info;
+ }
+
+ enabledForContext(contextData) {
+ if (!this.visible) {
+ return false;
+ }
+ let contexts = getMenuContexts(contextData);
+ if (!this.contexts.some(n => contexts.has(n))) {
+ return false;
+ }
+
+ if (
+ this.viewTypes &&
+ !this.viewTypes.includes(getContextViewType(contextData))
+ ) {
+ return false;
+ }
+
+ let docPattern = this.documentUrlMatchPattern;
+ // When viewTypes is specified, the menu item is expected to be restricted
+ // to documents. So let documentUrlPatterns always apply to the URL of the
+ // document in which the menu was opened. When maybeOverrideContextData
+ // changes the context, contextData.pageUrl does not reflect that URL any
+ // more, so use contextData.originalViewUrl instead.
+ if (docPattern && this.viewTypes && contextData.originalViewUrl) {
+ if (
+ !docPattern.matches(Services.io.newURI(contextData.originalViewUrl))
+ ) {
+ return false;
+ }
+ docPattern = null; // Null it so that it won't be used with pageURI below.
+ }
+
+ let pageURI = contextData[contextData.inFrame ? "frameUrl" : "pageUrl"];
+ if (pageURI) {
+ pageURI = Services.io.newURI(pageURI);
+ if (docPattern && !docPattern.matches(pageURI)) {
+ return false;
+ }
+ }
+
+ let targetPattern = this.targetUrlMatchPattern;
+ if (targetPattern) {
+ let targetUrls = [];
+ if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+ // TODO: Double check if srcUrl is always set when we need it.
+ targetUrls.push(contextData.srcUrl);
+ }
+ if (contextData.onLink) {
+ targetUrls.push(contextData.linkUrl);
+ }
+ if (
+ !targetUrls.some(targetUrl =>
+ targetPattern.matches(Services.io.newURI(targetUrl))
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for menu events from both Tools and context menus, both content and chrome.
+const menuTracker = {
+ menuIds: [
+ "tabContextMenu",
+ "folderPaneContext",
+ "msgComposeAttachmentItemContext",
+ "taskPopup",
+ ],
+
+ register() {
+ Services.obs.addObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.onWindowOpen(window);
+ }
+ windowTracker.addOpenListener(this.onWindowOpen);
+ },
+
+ unregister() {
+ Services.obs.removeObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.cleanupWindow(window);
+ }
+ windowTracker.removeOpenListener(this.onWindowOpen);
+ },
+
+ observe(subject, topic, data) {
+ subject = subject.wrappedJSObject;
+ gMenuBuilder.build(subject);
+ },
+
+ onWindowOpen(window) {
+ // Register the event listener on the window, as some menus we are
+ // interested in are dynamically created:
+ // https://hg.mozilla.org/mozilla-central/file/83a21ab93aff939d348468e69249a3a33ccfca88/toolkit/content/editMenuOverlay.js#l96
+ window.addEventListener("popupshowing", menuTracker);
+ },
+
+ cleanupWindow(window) {
+ window.removeEventListener("popupshowing", this);
+ },
+
+ handleEvent(event) {
+ const menu = event.target;
+ const trigger = menu.triggerNode;
+ const win = menu.ownerGlobal;
+ switch (menu.id) {
+ case "taskPopup": {
+ let info = { menu, inToolsMenu: true };
+ if (
+ win.document.location.href ==
+ "chrome://messenger/content/messenger.xhtml"
+ ) {
+ info.tab = tabTracker.activeTab;
+ // Calendar and Task view do not have a browser/URL.
+ info.pageUrl = info.tab.linkedBrowser?.currentURI?.spec;
+ } else {
+ info.tab = win;
+ }
+ gMenuBuilder.build(info);
+ break;
+ }
+ case "tabContextMenu": {
+ let triggerTab = trigger.closest("tab");
+ const tab = triggerTab || tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser?.currentURI?.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, onTab: true });
+ break;
+ }
+ case "folderPaneContext": {
+ const tab = tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser?.currentURI?.spec;
+ gMenuBuilder.build({
+ menu,
+ tab,
+ pageUrl,
+ selectedFolder: win.folderPaneContextMenu.activeFolder,
+ });
+ break;
+ }
+ case "attachmentListContext": {
+ let attachmentList =
+ menu.ownerGlobal.document.getElementById("attachmentList");
+ let allMessageAttachments = [...attachmentList.children].map(
+ item => item.attachment
+ );
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ allMessageAttachments,
+ });
+ break;
+ }
+ case "attachmentItemContext": {
+ let attachmentList =
+ menu.ownerGlobal.document.getElementById("attachmentList");
+ let attachmentInfo =
+ menu.ownerGlobal.document.getElementById("attachmentInfo");
+
+ // If we opened the context menu from the attachment info area (the paperclip,
+ // "1 attachment" label, filename, or file size, just grab the first (and
+ // only) attachment as our "selected" attachments.
+ let selectedMessageAttachments;
+ if (
+ menu.triggerNode == attachmentInfo ||
+ menu.triggerNode.parentNode == attachmentInfo
+ ) {
+ selectedMessageAttachments = [
+ attachmentList.getItemAtIndex(0).attachment,
+ ];
+ } else {
+ selectedMessageAttachments = [...attachmentList.selectedItems].map(
+ item => item.attachment
+ );
+ }
+
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ selectedMessageAttachments,
+ });
+ break;
+ }
+ case "msgComposeAttachmentItemContext": {
+ let bucket = menu.ownerDocument.getElementById("attachmentBucket");
+ let selectedComposeAttachments = [];
+ for (let item of bucket.itemChildren) {
+ if (item.selected) {
+ selectedComposeAttachments.push(item.attachment);
+ }
+ }
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ selectedComposeAttachments,
+ });
+ break;
+ }
+ default:
+ // Fall back to the triggerNode. Make sure we are not re-triggered by a
+ // sub-menu.
+ if (menu.parentNode.localName == "menu") {
+ return;
+ }
+ if (Object.keys(chromeElementsMap).includes(trigger?.id)) {
+ let selectionInfo = SelectionUtils.getSelectionDetails(win);
+ let isContentSelected = !selectionInfo.docSelectionIsCollapsed;
+ let textSelected = selectionInfo.text;
+ let isTextSelected = !!textSelected.length;
+ gMenuBuilder.build({
+ menu,
+ tab: win,
+ pageUrl: win.browser.currentURI.spec,
+ onEditable: true,
+ isContentSelected,
+ isTextSelected,
+ onTextInput: true,
+ originalViewType: "tab",
+ fieldId: chromeElementsMap[trigger.id],
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ });
+ }
+ break;
+ }
+ },
+};
+
+this.menus = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ if (!gMenuMap.size) {
+ menuTracker.register();
+ }
+ gMenuMap.set(extension, new Map());
+ }
+
+ restoreFromCache() {
+ let { extension } = this;
+ // ensure extension has not shutdown
+ if (!this.extension) {
+ return;
+ }
+ for (let createProperties of gStartupCache.get(extension).values()) {
+ // The order of menu creation is significant, see reparentInCache.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ }
+ // Used for testing
+ extension.emit("webext-menus-created", gMenuMap.get(extension));
+ }
+
+ async onStartup() {
+ let { extension } = this;
+ if (extension.persistentBackground) {
+ return;
+ }
+ // Using the map retains insertion order.
+ let cachedMenus = await StartupCache.menus.get(extension.id, () => {
+ return new Map();
+ });
+ gStartupCache.set(extension, cachedMenus);
+ if (!cachedMenus.size) {
+ return;
+ }
+
+ this.restoreFromCache();
+ }
+
+ onShutdown() {
+ let { extension } = this;
+
+ if (gMenuMap.has(extension)) {
+ gMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
+ gStartupCache.delete(extension);
+ gOnShownSubscribers.delete(extension);
+ if (!gMenuMap.size) {
+ menuTracker.unregister();
+ }
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onShown({ fire }) {
+ let { extension } = this;
+ let listener = async (event, menuIds, contextData) => {
+ let info = {
+ menuIds,
+ contexts: Array.from(getMenuContexts(contextData)),
+ };
+
+ let nativeTab = contextData.tab;
+
+ // The menus.onShown event is fired before the user has consciously
+ // interacted with an extension, so we require permissions before
+ // exposing sensitive contextual data.
+ let contextUrl = contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl;
+
+ let ownerDocumentUrl = contextData.menu.ownerDocument.location.href;
+
+ let contextScheme;
+ if (contextUrl) {
+ contextScheme = Services.io.newURI(contextUrl).scheme;
+ }
+
+ let includeSensitiveData =
+ (nativeTab &&
+ extension.tabManager.hasActiveTabPermission(nativeTab)) ||
+ (contextUrl && extension.allowedOrigins.matches(contextUrl)) ||
+ (MESSAGE_PROTOCOLS.includes(contextScheme) &&
+ extension.hasPermission("messagesRead")) ||
+ (ownerDocumentUrl ==
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml" &&
+ extension.hasPermission("compose"));
+
+ await addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+ );
+
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ fire.sync(info, tab);
+ };
+ gOnShownSubscribers.get(extension).add(listener);
+ extension.on("webext-menu-shown", listener);
+ return {
+ unregister() {
+ const listeners = gOnShownSubscribers.get(extension);
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ gOnShownSubscribers.delete(extension);
+ }
+ extension.off("webext-menu-shown", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onHidden({ fire }) {
+ let { extension } = this;
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-hidden", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onClicked({ context, fire }) {
+ let { extension } = this;
+ let listener = async (event, info, nativeTab) => {
+ let { linkedBrowser } = nativeTab || tabTracker.activeTab;
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ if (fire.wakeup) {
+ // force the wakeup, thus the call to convert to get the context.
+ await fire.wakeup();
+ // If while waiting the tab disappeared we bail out.
+ if (
+ !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser)
+ ) {
+ console.error(
+ `menus.onClicked: target tab closed during background startup.`
+ );
+ return;
+ }
+ }
+ context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab));
+ };
+
+ extension.on("webext-menu-menuitem-click", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-menuitem-click", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ menus: {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
+ onShown: new EventManager({
+ context,
+ module: "menus",
+ event: "onShown",
+ extensionApi: this,
+ }).api(),
+ onHidden: new EventManager({
+ context,
+ module: "menus",
+ event: "onHidden",
+ extensionApi: this,
+ }).api(),
+ onClicked: new EventManager({
+ context,
+ module: "menus",
+ event: "onClicked",
+ extensionApi: this,
+ }).api(),
+
+ create(createProperties) {
+ // event pages require id
+ if (!extension.persistentBackground) {
+ if (!createProperties.id) {
+ throw new ExtensionError(
+ "menus.create requires an id for non-persistent background scripts."
+ );
+ }
+ if (gMenuMap.get(extension).has(createProperties.id)) {
+ throw new ExtensionError(
+ `The menu id ${createProperties.id} already exists in menus.create.`
+ );
+ }
+ }
+
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of menus.create in the child will add it for
+ // extensions with persistent backgrounds, but not otherwise.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ if (!extension.persistentBackground) {
+ // Only cache properties that are necessary.
+ let cached = {};
+ MenuItem.mergeProps(cached, createProperties);
+ gStartupCache.get(extension).set(menuItem.id, cached);
+ StartupCache.save();
+ }
+ },
+
+ update(id, updateProperties) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (!menuItem) {
+ return;
+ }
+ menuItem.setProps(updateProperties);
+
+ // Update the startup cache for non-persistent extensions.
+ if (extension.persistentBackground) {
+ return;
+ }
+
+ let cached = gStartupCache.get(extension).get(id);
+ let reparent =
+ updateProperties.parentId != null &&
+ cached.parentId != updateProperties.parentId;
+ MenuItem.mergeProps(cached, updateProperties);
+ if (reparent) {
+ // The order of menu creation is significant, see reparentInCache.
+ menuItem.reparentInCache();
+ }
+ StartupCache.save();
+ },
+
+ remove(id) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ // Should be empty, just extra assurance.
+ if (!extension.persistentBackground) {
+ let cached = gStartupCache.get(extension);
+ if (cached.size) {
+ cached.clear();
+ StartupCache.save();
+ }
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-messageDisplay.js b/comm/mail/components/extensions/parent/ext-messageDisplay.js
new file mode 100644
index 0000000000..98ba2dc75c
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messageDisplay.js
@@ -0,0 +1,348 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+/**
+ * Returns the currently displayed messages in the given tab.
+ *
+ * @param {Tab} tab
+ * @returns {nsIMsgHdr[]} Array of nsIMsgHdr
+ */
+function getDisplayedMessages(tab) {
+ let nativeTab = tab.nativeTab;
+ if (tab instanceof TabmailTab) {
+ if (nativeTab.mode.name == "mail3PaneTab") {
+ return nativeTab.chromeBrowser.contentWindow.gDBView.getSelectedMsgHdrs();
+ } else if (nativeTab.mode.name == "mailMessageTab") {
+ return [nativeTab.chromeBrowser.contentWindow.gMessage];
+ }
+ } else if (nativeTab?.messageBrowser) {
+ return [nativeTab.messageBrowser.contentWindow.gMessage];
+ }
+ return [];
+}
+
+/**
+ * Wrapper to convert multiple nsIMsgHdr to MessageHeader objects.
+ *
+ * @param {nsIMsgHdr[]} Array of nsIMsgHdr
+ * @param {ExtensionData} extension
+ * @returns {MessageHeader[]} Array of MessageHeader objects
+ *
+ * @see /mail/components/extensions/schemas/messages.json
+ */
+function convertMessages(messages, extension) {
+ let result = [];
+ for (let msg of messages) {
+ let hdr = convertMessage(msg, extension);
+ if (hdr) {
+ result.push(hdr);
+ }
+ }
+ return result;
+}
+
+/**
+ * Check the users preference on opening new messages in tabs or windows.
+ *
+ * @returns {string} - either "tab" or "window"
+ */
+function getDefaultMessageOpenLocation() {
+ let pref = Services.prefs.getIntPref("mail.openMessageBehavior");
+ return pref == MailConsts.OpenMessageBehavior.NEW_TAB ? "tab" : "window";
+}
+
+/**
+ * Return the msgHdr of the message specified in the properties object. Message
+ * can be specified via properties.headerMessageId or properties.messageId.
+ *
+ * @param {object} properties - @see mail/components/extensions/schemas/messageDisplay.json
+ * @throws ExtensionError if an unknown message has been specified
+ * @returns {nsIMsgHdr} the requested msgHdr
+ */
+function getMsgHdr(properties) {
+ if (properties.headerMessageId) {
+ let msgHdr = MailUtils.getMsgHdrForMsgId(properties.headerMessageId);
+ if (!msgHdr) {
+ throw new ExtensionError(
+ `Unknown or invalid headerMessageId: ${properties.headerMessageId}.`
+ );
+ }
+ return msgHdr;
+ }
+ let msgHdr = messageTracker.getMessage(properties.messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(
+ `Unknown or invalid messageId: ${properties.messageId}.`
+ );
+ }
+ return msgHdr;
+}
+
+this.messageDisplay = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onMessageDisplayed({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ let listener = {
+ async handleEvent(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // `event.target` is an about:message window.
+ let nativeTab = event.target.tabOrWindow;
+ let tab = tabManager.wrapTab(nativeTab);
+ let msg = convertMessage(event.detail, extension);
+ fire.async(tab.convert(), msg);
+ },
+ };
+ windowTracker.addListener("MsgLoaded", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("MsgLoaded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMessagesDisplayed({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ let listener = {
+ async handleEvent(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // `event.target` is an about:message or about:3pane window.
+ let nativeTab = event.target.tabOrWindow;
+ let tab = tabManager.wrapTab(nativeTab);
+ let msgs = getDisplayedMessages(tab);
+ fire.async(tab.convert(), convertMessages(msgs, extension));
+ },
+ };
+ windowTracker.addListener("MsgsLoaded", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("MsgsLoaded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ /**
+ * Guard to make sure the API waits until the message tab has been fully loaded,
+ * to cope with tabs.onCreated returning tabs very early.
+ *
+ * @param {integer} tabId
+ * @returns {Tab} the fully loaded message tab identified by the given tabId,
+ * or null, if invalid
+ */
+ async function getMessageDisplayTab(tabId) {
+ let msgContentWindow;
+ let tab = tabManager.get(tabId);
+ if (tab?.type == "mail") {
+ // In about:3pane only the messageBrowser needs to be checked for its
+ // load state. The webBrowser is invalid, the multiMessageBrowser can
+ // bypass.
+ if (!tab.nativeTab.chromeBrowser.contentWindow.webBrowser.hidden) {
+ return null;
+ }
+ if (
+ !tab.nativeTab.chromeBrowser.contentWindow.multiMessageBrowser.hidden
+ ) {
+ return tab;
+ }
+ msgContentWindow =
+ tab.nativeTab.chromeBrowser.contentWindow.messageBrowser
+ .contentWindow;
+ } else if (tab?.type == "messageDisplay") {
+ msgContentWindow =
+ tab instanceof TabmailTab
+ ? tab.nativeTab.chromeBrowser.contentWindow
+ : tab.nativeTab.messageBrowser.contentWindow;
+ } else {
+ return null;
+ }
+
+ // Make sure the content window has been fully loaded.
+ await new Promise(resolve => {
+ if (msgContentWindow.document.readyState == "complete") {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "load",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // Wait until the message display process has been initiated.
+ await new Promise(resolve => {
+ if (msgContentWindow.msgLoading || msgContentWindow.msgLoaded) {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "messageURIChanged",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // Wait until the message display process has been finished.
+ await new Promise(resolve => {
+ if (msgContentWindow.msgLoaded) {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "MsgLoaded",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // If there is no gMessage, then the display has been cleared.
+ return msgContentWindow.gMessage ? tab : null;
+ }
+
+ let { extension } = context;
+ let { tabManager } = extension;
+ return {
+ messageDisplay: {
+ onMessageDisplayed: new EventManager({
+ context,
+ module: "messageDisplay",
+ event: "onMessageDisplayed",
+ extensionApi: this,
+ }).api(),
+ onMessagesDisplayed: new EventManager({
+ context,
+ module: "messageDisplay",
+ event: "onMessagesDisplayed",
+ extensionApi: this,
+ }).api(),
+ async getDisplayedMessage(tabId) {
+ let tab = await getMessageDisplayTab(tabId);
+ if (!tab) {
+ return null;
+ }
+ let messages = getDisplayedMessages(tab);
+ if (messages.length != 1) {
+ return null;
+ }
+ return convertMessage(messages[0], extension);
+ },
+ async getDisplayedMessages(tabId) {
+ let tab = await getMessageDisplayTab(tabId);
+ if (!tab) {
+ return [];
+ }
+ let messages = getDisplayedMessages(tab);
+ return convertMessages(messages, extension);
+ },
+ async open(properties) {
+ if (
+ ["messageId", "headerMessageId", "file"].reduce(
+ (count, value) => (properties[value] ? count + 1 : count),
+ 0
+ ) != 1
+ ) {
+ throw new ExtensionError(
+ "Exactly one of messageId, headerMessageId or file must be specified."
+ );
+ }
+
+ let messageURI;
+ if (properties.file) {
+ let realFile = await getRealFileForFile(properties.file);
+ messageURI = Services.io
+ .newFileURI(realFile)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize().spec;
+ } else {
+ let msgHdr = getMsgHdr(properties);
+ if (msgHdr.folder) {
+ messageURI = msgHdr.folder.getUriForMsg(msgHdr);
+ } else {
+ // Add the application/x-message-display type to the url, if missing.
+ // The slash is escaped when setting the type via searchParams, but
+ // core code needs it unescaped.
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+ url.searchParams.delete("type");
+ messageURI = `${url.href}${
+ url.searchParams.toString() ? "&" : "?"
+ }type=application/x-message-display`;
+ }
+ }
+
+ let tab;
+ switch (properties.location || getDefaultMessageOpenLocation()) {
+ case "tab":
+ {
+ let normalWindow = await getNormalWindowReady(
+ context,
+ properties.windowId
+ );
+ let active = properties.active ?? true;
+ let tabmail = normalWindow.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = tabmail.openTab("mailMessageTab", {
+ messageURI,
+ background: !active,
+ });
+ await new Promise(resolve =>
+ nativeTabInfo.chromeBrowser.addEventListener(
+ "MsgLoaded",
+ resolve,
+ { once: true }
+ )
+ );
+ tab = tabManager.convert(nativeTabInfo, currentTab);
+ }
+ break;
+
+ case "window":
+ {
+ // Handle window location.
+ let topNormalWindow = await getNormalWindowReady();
+ let messageWindow = topNormalWindow.MsgOpenNewWindowForMessage(
+ Services.io.newURI(messageURI)
+ );
+ await new Promise(resolve =>
+ messageWindow.addEventListener("MsgLoaded", resolve, {
+ once: true,
+ })
+ );
+ tab = tabManager.convert(messageWindow);
+ }
+ break;
+ }
+ return tab;
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-messageDisplayAction.js b/comm/mail/components/extensions/parent/ext-messageDisplayAction.js
new file mode 100644
index 0000000000..026ddfc736
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messageDisplayAction.js
@@ -0,0 +1,251 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ToolbarButtonAPI",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+const messageDisplayActionMap = new WeakMap();
+
+this.messageDisplayAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return messageDisplayActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ messageDisplayActionMap.set(this.extension, this);
+ }
+
+ close() {
+ super.close();
+ messageDisplayActionMap.delete(this.extension);
+ windowTracker.removeListener("TabSelect", this);
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name = "message_display_action";
+ this.manifestName = "messageDisplayAction";
+ this.manifest = extension.manifest[this.manifest_name];
+ this.moduleName = this.manifestName;
+
+ this.windowURLs = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ ];
+ this.toolboxId = "header-view-toolbox";
+ this.toolbarId = "header-view-toolbar";
+
+ windowTracker.addListener("TabSelect", this);
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-messageDisplayAction-toolbarbutton`;
+ let toolbar = "header-view-toolbar";
+
+ // Check all possible windows and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let windowURLs = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ ];
+ for (let windowURL of windowURLs) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(windowURL, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ paint(window) {
+ window.addEventListener("aboutMessageLoaded", this);
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.paint(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ unpaint(window) {
+ window.removeEventListener("aboutMessageLoaded", this);
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.unpaint(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ async updateWindow(window) {
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.updateWindow(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class where `target` is a tab, to update
+ * about:message instead of the window.
+ */
+ async updateOnChange(target) {
+ if (!target) {
+ await super.updateOnChange(target);
+ return;
+ }
+
+ let window = Cu.getGlobalForObject(target);
+ if (window == target) {
+ await super.updateOnChange(target);
+ return;
+ }
+
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (!tabmail || target != tabmail.selectedTab) {
+ return;
+ }
+
+ switch (target.mode.name) {
+ case "mail3PaneTab":
+ await this.updateWindow(
+ target.chromeBrowser.contentWindow.messageBrowser.contentWindow
+ );
+ break;
+ case "mailMessageTab":
+ await this.updateWindow(target.chromeBrowser.contentWindow);
+ break;
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "aboutMessageLoaded":
+ // Add the toolbar button to any about:message that comes along.
+ super.paint(event.target);
+ break;
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = ["header-toolbar-context-menu"];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ tab: window.tabOrWindow,
+ pageUrl: window.getMessagePaneBrowser().currentURI.spec,
+ extension: this.extension,
+ onMessageDisplayAction: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == "messageDisplayAction" &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ global.actionContextMenu({
+ tab: window.tabOrWindow,
+ pageUrl: window.getMessagePaneBrowser().currentURI.spec,
+ extension: this.extension,
+ inMessageDisplayActionMenu: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+
+ /**
+ * Overrides the super class to trigger the action in the current about:message.
+ */
+ async triggerAction(window, options) {
+ // Supported message browsers:
+ // - in mail tab (browser could be hidden)
+ // - in message tab
+ // - in message window
+
+ // The passed in window could be the window of one of the supported message
+ // browsers already. To know if the browser is hidden, always re-search the
+ // message window and start at the top.
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (tabmail) {
+ // A mail tab or a message tab.
+ let isHidden =
+ tabmail.currentAbout3Pane &&
+ tabmail.currentAbout3Pane.messageBrowser.hidden;
+
+ if (tabmail.currentAboutMessage && !isHidden) {
+ return super.triggerAction(tabmail.currentAboutMessage, options);
+ }
+ } else if (window.top.messageBrowser) {
+ // A message window.
+ return super.triggerAction(
+ window.top.messageBrowser.contentWindow,
+ options
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ return toolbar.querySelector("#otherActionsButton");
+ }
+
+ makeButton(window) {
+ let button = super.makeButton(window);
+ button.classList.add("message-header-view-button");
+ // The header toolbar has no associated context menu. Add one directly to
+ // this button.
+ button.setAttribute("context", "header-toolbar-context-menu");
+ return button;
+ }
+};
+
+global.messageDisplayActionFor = this.messageDisplayAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-messages.js b/comm/mail/components/extensions/parent/ext-messages.js
new file mode 100644
index 0000000000..7d03b3fa62
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messages.js
@@ -0,0 +1,1563 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+});
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MessageArchiver",
+ "resource:///modules/MessageArchiver.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MsgHdrToMimeMessage",
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "jsmime",
+ "resource:///modules/jsmime.jsm"
+);
+
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "IOUtils", "PathUtils"]);
+
+var { DefaultMap } = ExtensionUtils;
+
+let messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+/**
+ * Takes a part of a MIME message (as retrieved with MsgHdrToMimeMessage) and
+ * filters out the properties we don't want to send to extensions.
+ */
+function convertMessagePart(part) {
+ let partObject = {};
+ for (let key of ["body", "contentType", "name", "partName", "size"]) {
+ if (key in part) {
+ partObject[key] = part[key];
+ }
+ }
+
+ // Decode headers. This also takes care of headers, which still include
+ // encoded words and need to be RFC 2047 decoded.
+ if ("headers" in part) {
+ partObject.headers = {};
+ for (let header of Object.keys(part.headers)) {
+ partObject.headers[header] = part.headers[header].map(h =>
+ MailServices.mimeConverter.decodeMimeHeader(
+ h,
+ null,
+ false /* override_charset */,
+ true /* eatContinuations */
+ )
+ );
+ }
+ }
+
+ if ("parts" in part && Array.isArray(part.parts) && part.parts.length > 0) {
+ partObject.parts = part.parts.map(convertMessagePart);
+ }
+ return partObject;
+}
+
+async function convertAttachment(attachment) {
+ let rv = {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partName,
+ };
+
+ if (attachment.contentType.startsWith("message/")) {
+ // The attached message may not have been seen/opened yet, create a dummy
+ // msgHdr.
+ let attachedMsgHdr = new nsDummyMsgHeader();
+
+ attachedMsgHdr.setStringProperty("dummyMsgUrl", attachment.url);
+ attachedMsgHdr.recipients = attachment.headers.to;
+ attachedMsgHdr.ccList = attachment.headers.cc;
+ attachedMsgHdr.bccList = attachment.headers.bcc;
+ attachedMsgHdr.author = attachment.headers.from?.[0] || "";
+ attachedMsgHdr.subject = attachment.headers.subject?.[0] || "";
+
+ let hdrDate = attachment.headers.date?.[0];
+ attachedMsgHdr.date = hdrDate ? Date.parse(hdrDate) * 1000 : 0;
+
+ let hdrId = attachment.headers["message-id"]?.[0];
+ attachedMsgHdr.messageId = hdrId ? hdrId.replace(/^<|>$/g, "") : "";
+
+ rv.message = convertMessage(attachedMsgHdr);
+ }
+
+ return rv;
+}
+
+/**
+ * @typedef MimeMessagePart
+ * @property {MimeMessagePart[]} [attachments] - flat list of attachment parts
+ * found in any of the nested mime parts
+ * @property {string} [body] - the body of the part
+ * @property {Uint8Array} [raw] - the raw binary content of the part
+ * @property {string} [contentType]
+ * @property {string} headers - key-value object with key being a header name
+ * and value an array with all header values found
+ * @property {string} [name] - filename, if part is an attachment
+ * @property {string} partName - name of the mime part (e.g: "1.2")
+ * @property {MimeMessagePart[]} [parts] - nested mime parts
+ * @property {string} [size] - size of the part
+ * @property {string} [url] - message url
+ */
+
+/**
+ * Returns attachments found in the message belonging to the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {boolean} includeNestedAttachments - Whether to return all attachments,
+ * including attachments from nested mime parts.
+ * @returns {Promise<MimeMessagePart[]>}
+ */
+async function getAttachments(msgHdr, includeNestedAttachments = false) {
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // Reduce returned attachments according to includeNestedAttachments.
+ let level = mimeMsg.partName ? mimeMsg.partName.split(".").length : 0;
+ return mimeMsg.attachments.filter(
+ a => includeNestedAttachments || a.partName.split(".").length == level + 2
+ );
+}
+
+/**
+ * Returns the attachment identified by the provided partName.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName
+ * @param {object} [options={}] - If the includeRaw property is truthy the raw
+ * attachment contents are included.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getAttachment(msgHdr, partName, options = {}) {
+ // It's not ideal to have to call MsgHdrToMimeMessage here again, but we need
+ // the name of the attached file, plus this also gives us the URI without having
+ // to jump through a lot of hoops.
+ let attachment = await getMimeMessage(msgHdr, partName);
+ if (!attachment) {
+ return null;
+ }
+
+ if (options.includeRaw) {
+ let channel = Services.io.newChannelFromURI(
+ Services.io.newURI(attachment.url),
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ attachment.raw = await new Promise((resolve, reject) => {
+ let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+ listener.init({
+ onStreamComplete(loader, context, status, resultLength, result) {
+ if (Components.isSuccessCode(status)) {
+ resolve(Uint8Array.from(result));
+ } else {
+ reject(
+ new ExtensionError(
+ `Failed to read attachment ${attachment.url} content: ${status}`
+ )
+ );
+ }
+ },
+ });
+ channel.asyncOpen(listener, null);
+ });
+ }
+
+ return attachment;
+}
+
+/**
+ * Returns the <part> parameter of the dummyMsgUrl of the provided nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {string}
+ */
+function getSubMessagePartName(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return "";
+ }
+
+ return new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.get(
+ "part"
+ );
+}
+
+/**
+ * Returns the nsIMsgHdr of the outer message, if the provided nsIMsgHdr belongs
+ * to a message which is actually an attachment of another message. Returns null
+ * otherwise.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {nsIMsgHdr}
+ */
+function getParentMsgHdr(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return null;
+ }
+
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+
+ if (url.protocol == "news:") {
+ let newsUrl = `news-message://${url.hostname}/${url.searchParams.get(
+ "group"
+ )}#${url.searchParams.get("key")}`;
+ return messenger.msgHdrFromURI(newsUrl);
+ }
+
+ if (url.protocol == "mailbox:") {
+ // This could be a sub-message of a message opened from file.
+ let fileUrl = `file://${url.pathname}`;
+ let parentMsgHdr = messageTracker._dummyMessageHeaders.get(fileUrl);
+ if (parentMsgHdr) {
+ return parentMsgHdr;
+ }
+ }
+ // Everything else should be a mailbox:// or an imap:// url.
+ let params = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["number"].includes(p)
+ );
+ for (let param of params) {
+ url.searchParams.delete(param);
+ }
+ return Services.io.newURI(url.href).QueryInterface(Ci.nsIMsgMessageUrl)
+ .messageHeader;
+}
+
+/**
+ * Get the raw message for a given nsIMsgHdr.
+ *
+ * @param aMsgHdr - The message header to retrieve the raw message for.
+ * @returns {Promise<string>} - Binary string of the raw message.
+ */
+async function getRawMessage(msgHdr) {
+ // If this message is a sub-message (an attachment of another message), get it
+ // as an attachment from the parent message and return its raw content.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ let attachment = await getAttachment(parentMsgHdr, subMsgPartName, {
+ includeRaw: true,
+ });
+ return attachment.raw.reduce(
+ (prev, curr) => prev + String.fromCharCode(curr),
+ ""
+ );
+ }
+
+ // Messages opened from file do not have a folder property, but
+ // have their url stored as a string property.
+ let msgUri = msgHdr.folder
+ ? msgHdr.folder.generateMessageURI(msgHdr.messageKey)
+ : msgHdr.getStringProperty("dummyMsgUrl");
+
+ let service = MailServices.messageServiceFromURI(msgUri);
+ return new Promise((resolve, reject) => {
+ let streamlistener = {
+ _data: [],
+ _stream: null,
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ if (!this._stream) {
+ this._stream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ this._stream.init(aInputStream);
+ }
+ this._data.push(this._stream.read(aCount));
+ },
+ onStartRequest() {},
+ onStopRequest(request, status) {
+ if (Components.isSuccessCode(status)) {
+ resolve(this._data.join(""));
+ } else {
+ reject(
+ new ExtensionError(
+ `Error while streaming message <${msgUri}>: ${status}`
+ )
+ );
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ // This is not using aConvertData and therefore works for news:// messages.
+ service.streamMessage(
+ msgUri,
+ streamlistener,
+ null, // aMsgWindow
+ null, // aUrlListener
+ false, // aConvertData
+ "" //aAdditionalHeader
+ );
+ });
+}
+
+/**
+ * Returns MIME parts found in the message identified by the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName - Return only a specific mime part.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getMimeMessage(msgHdr, partName = "") {
+ // If this message is a sub-message (an attachment of another message), get the
+ // mime parts of the parent message and return the part of the sub-message.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ if (!parentMsgHdr) {
+ return null;
+ }
+
+ let mimeMsg = await getMimeMessage(parentMsgHdr, partName);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // If <partName> was specified, the returned mime message is just that part,
+ // no further processing needed. But prevent x-ray vision into the parent.
+ if (partName) {
+ if (partName.split(".").length > subMsgPartName.split(".").length) {
+ return mimeMsg;
+ }
+ return null;
+ }
+
+ // Limit mimeMsg and attachments to the requested <subMessagePart>.
+ let findSubPart = (parts, partName) => {
+ let match = parts.find(a => partName.startsWith(a.partName));
+ if (!match) {
+ throw new ExtensionError(
+ `Unexpected Error: Part ${partName} not found.`
+ );
+ }
+ return match.partName == partName
+ ? match
+ : findSubPart(match.parts, partName);
+ };
+ let subMimeMsg = findSubPart(mimeMsg.parts, subMsgPartName);
+
+ if (mimeMsg.attachments) {
+ subMimeMsg.attachments = mimeMsg.attachments.filter(
+ a =>
+ a.partName != subMsgPartName && a.partName.startsWith(subMsgPartName)
+ );
+ }
+ return subMimeMsg;
+ }
+
+ let mimeMsg = await new Promise(resolve => {
+ MsgHdrToMimeMessage(
+ msgHdr,
+ null,
+ (_msgHdr, mimeMsg) => {
+ mimeMsg.attachments = mimeMsg.allInlineAttachments;
+ resolve(mimeMsg);
+ },
+ true,
+ { examineEncryptedParts: true }
+ );
+ });
+
+ return partName
+ ? mimeMsg.attachments.find(a => a.partName == partName)
+ : mimeMsg;
+}
+
+this.messages = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onNewMailReceived({ context, fire }) {
+ let listener = async (event, folder, newMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let page = await messageListTracker.startList(newMessages, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertFolder(folder), page);
+ };
+ messageTracker.on("messages-received", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-received", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ let listener = async (event, message, properties) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let convertedMessage = convertMessage(message, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertedMessage, properties);
+ };
+ messageTracker.on("message-updated", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("message-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-moved", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-copied", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ let listener = async (event, deletedMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let deletedPage = await messageListTracker.startList(
+ deletedMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedPage);
+ };
+ messageTracker.on("messages-deleted", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = this;
+ const { tabManager } = extension;
+
+ function collectMessagesInFolders(messageIds) {
+ let folderMap = new DefaultMap(() => new Set());
+
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+
+ let msgHeaderSet = folderMap.get(msgHdr.folder);
+ msgHeaderSet.add(msgHdr);
+ }
+
+ return folderMap;
+ }
+
+ async function createTempFileMessage(msgHdr) {
+ let rawBinaryString = await getRawMessage(msgHdr);
+ let pathEmlFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ encodeURIComponent(msgHdr.messageId).replaceAll(/[/:*?\"<>|]/g, "_") +
+ ".eml",
+ 0o600
+ );
+
+ let emlFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ emlFile.initWithPath(pathEmlFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(emlFile);
+
+ let buffer = MailStringUtils.byteStringToUint8Array(rawBinaryString);
+ await IOUtils.write(pathEmlFile, buffer);
+ return emlFile;
+ }
+
+ async function moveOrCopyMessages(messageIds, { accountId, path }, isMove) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesMove")
+ ) {
+ throw new ExtensionError(
+ `Using messages.${
+ isMove ? "move" : "copy"
+ }() requires the "accountsRead" and the "messagesMove" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (sourceFolder == destinationFolder) {
+ continue;
+ }
+ let msgHeaders = [...msgHeaderSet];
+
+ // Special handling for external messages.
+ if (!sourceFolder) {
+ if (isMove) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ for (let msgHdr of msgHeaders) {
+ let file;
+ let fileUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ if (fileUrl.startsWith("file://")) {
+ file = Services.io
+ .newURI(fileUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } else {
+ file = await createTempFileMessage(msgHdr);
+ }
+
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyFileMessage(
+ file,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ Ci.nsMsgMessageFlags.Read,
+ /* aMsgKeywords */ "",
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ })
+ );
+ }
+ continue;
+ }
+
+ // Since the archiver falls back to copy if delete is not supported,
+ // lets do that here as well.
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ msgHeaders,
+ destinationFolder,
+ isMove && sourceFolder.canDeleteMessages,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null,
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(
+ `Error ${isMove ? "moving" : "copying"} message: ${ex.message}`
+ );
+ }
+ }
+
+ return {
+ messages: {
+ onNewMailReceived: new EventManager({
+ context,
+ module: "messages",
+ event: "onNewMailReceived",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "messages",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "messages",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "messages",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "messages",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ async list({ accountId, path }) {
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+
+ return messageListTracker.startList(
+ folder.messages,
+ context.extension
+ );
+ },
+ async continueList(messageListId) {
+ let messageList = messageListTracker.getList(
+ messageListId,
+ context.extension
+ );
+ return messageListTracker.getNextPage(messageList);
+ },
+ async get(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let messageHeader = convertMessage(msgHdr, context.extension);
+ if (messageHeader.id != messageId) {
+ throw new ExtensionError(
+ "Unexpected Error: Returned message does not equal requested message."
+ );
+ }
+ return messageHeader;
+ },
+ async getFull(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.Partial) {
+ // Do not include fake body.
+ mimeMsg.parts = [];
+ }
+ return convertMessagePart(mimeMsg);
+ },
+ async getRaw(messageId, options) {
+ let data_format = options?.data_format;
+ if (!["File", "BinaryString"].includes(data_format)) {
+ data_format =
+ extension.manifestVersion < 3 ? "BinaryString" : "File";
+ }
+
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ try {
+ let raw = await getRawMessage(msgHdr);
+ if (data_format == "File") {
+ // Convert binary string to Uint8Array and return a File.
+ let bytes = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i) & 0xff;
+ }
+ return new File([bytes], `message-${messageId}.eml`, {
+ type: "message/rfc822",
+ });
+ }
+ return raw;
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ },
+ async listAttachments(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachments = await getAttachments(msgHdr);
+ for (let i = 0; i < attachments.length; i++) {
+ attachments[i] = await convertAttachment(attachments[i]);
+ }
+ return attachments;
+ },
+ async getAttachmentFile(messageId, partName) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName, {
+ includeRaw: true,
+ });
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ return new File([attachment.raw], attachment.name, {
+ type: attachment.contentType,
+ });
+ },
+ async openAttachment(messageId, partName, tabId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName);
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ let attachmentInfo = new AttachmentInfo({
+ contentType: attachment.contentType,
+ url: attachment.url,
+ name: attachment.name,
+ uri: msgHdr.folder.getUriForMsg(msgHdr),
+ isExternalAttachment: attachment.isExternal,
+ message: msgHdr,
+ });
+ let tab = tabManager.get(tabId);
+ try {
+ // Content tabs or content windows use browser, while mail and message
+ // tabs use chromeBrowser.
+ let browser = tab.nativeTab.chromeBrowser || tab.nativeTab.browser;
+ await attachmentInfo.open(browser.browsingContext);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Part ${partName} could not be opened: ${ex}.`
+ );
+ }
+ },
+ async query(queryInfo) {
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ const includesContent = (folder, parts, searchTerm) => {
+ if (!parts || parts.length == 0) {
+ return false;
+ }
+ for (let part of parts) {
+ if (
+ coerceBodyToPlaintext(folder, part).includes(searchTerm) ||
+ includesContent(folder, part.parts, searchTerm)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const coerceBodyToPlaintext = (folder, part) => {
+ if (!part || !part.body) {
+ return "";
+ }
+ if (part.contentType == "text/plain") {
+ return part.body;
+ }
+ // text/enriched gets transformed into HTML by libmime
+ if (
+ part.contentType == "text/html" ||
+ part.contentType == "text/enriched"
+ ) {
+ return folder.convertMsgSnippetToPlainText(part.body);
+ }
+ return "";
+ };
+
+ /**
+ * Prepare name and email properties of the address object returned by
+ * MailServices.headerParser.makeFromDisplayAddress() to be lower case.
+ * Also fix the name being wrongly returned in the email property, if
+ * the address was just a single name.
+ */
+ const prepareAddress = displayAddr => {
+ let email = displayAddr.email?.toLocaleLowerCase();
+ let name = displayAddr.name?.toLocaleLowerCase();
+ if (email && !name && !email.includes("@")) {
+ name = email;
+ email = null;
+ }
+ return { name, email };
+ };
+
+ /**
+ * Check multiple addresses if they match the provided search address.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const searchInMultipleAddresses = (searchAddress, addresses) => {
+ // Return on first positive match.
+ for (let address of addresses) {
+ let nameMatched =
+ searchAddress.name &&
+ address.name &&
+ address.name.includes(searchAddress.name);
+
+ // Check for email match. Name match being required on top, if
+ // specified.
+ if (
+ (nameMatched || !searchAddress.name) &&
+ searchAddress.email &&
+ address.email &&
+ address.email == searchAddress.email
+ ) {
+ return true;
+ }
+
+ // If address match failed, name match may only be true if no
+ // email has been specified.
+ if (!searchAddress.email && nameMatched) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ /**
+ * Substring match on name and exact match on email. If searchTerm
+ * includes multiple addresses, all of them must match.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const isAddressMatch = (searchTerm, addressObjects) => {
+ let searchAddresses =
+ MailServices.headerParser.makeFromDisplayAddress(searchTerm);
+ if (!searchAddresses || searchAddresses.length == 0) {
+ return false;
+ }
+
+ // Prepare addresses.
+ let addresses = [];
+ for (let addressObject of addressObjects) {
+ let decodedAddressString = addressObject.doRfc2047
+ ? jsmime.headerparser.decodeRFC2047Words(addressObject.addr)
+ : addressObject.addr;
+ for (let address of MailServices.headerParser.makeFromDisplayAddress(
+ decodedAddressString
+ )) {
+ addresses.push(prepareAddress(address));
+ }
+ }
+ if (addresses.length == 0) {
+ return false;
+ }
+
+ let success = false;
+ for (let searchAddress of searchAddresses) {
+ // Exit early if this search was not successfully, but all search
+ // addresses have to be matched.
+ if (
+ !searchInMultipleAddresses(
+ prepareAddress(searchAddress),
+ addresses
+ )
+ ) {
+ return false;
+ }
+ success = true;
+ }
+
+ return success;
+ };
+
+ const checkSearchCriteria = async (folder, msg) => {
+ // Check date ranges.
+ if (
+ queryInfo.fromDate !== null &&
+ msg.dateInSeconds * 1000 < queryInfo.fromDate.getTime()
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.toDate !== null &&
+ msg.dateInSeconds * 1000 > queryInfo.toDate.getTime()
+ ) {
+ return false;
+ }
+
+ // Check headerMessageId.
+ if (
+ queryInfo.headerMessageId &&
+ msg.messageId != queryInfo.headerMessageId
+ ) {
+ return false;
+ }
+
+ // Check unread.
+ if (queryInfo.unread !== null && msg.isRead != !queryInfo.unread) {
+ return false;
+ }
+
+ // Check flagged.
+ if (
+ queryInfo.flagged !== null &&
+ msg.isFlagged != queryInfo.flagged
+ ) {
+ return false;
+ }
+
+ // Check subject (substring match).
+ if (
+ queryInfo.subject &&
+ !msg.mime2DecodedSubject.includes(queryInfo.subject)
+ ) {
+ return false;
+ }
+
+ // Check tags.
+ if (requiredTags || forbiddenTags) {
+ let messageTags = msg.getStringProperty("keywords").split(" ");
+ if (requiredTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ !requiredTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ !requiredTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ if (forbiddenTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ forbiddenTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ forbiddenTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ }
+
+ // Check toMe (case insensitive email address match).
+ if (queryInfo.toMe !== null) {
+ let recipients = [].concat(
+ composeFields.splitRecipients(msg.recipients, true),
+ composeFields.splitRecipients(msg.ccList, true),
+ composeFields.splitRecipients(msg.bccList, true)
+ );
+
+ if (
+ queryInfo.toMe !=
+ recipients.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check fromMe (case insensitive email address match).
+ if (queryInfo.fromMe !== null) {
+ let authors = composeFields.splitRecipients(
+ msg.mime2DecodedAuthor,
+ true
+ );
+ if (
+ queryInfo.fromMe !=
+ authors.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check author.
+ if (
+ queryInfo.author &&
+ !isAddressMatch(queryInfo.author, [
+ { addr: msg.mime2DecodedAuthor, doRfc2047: false },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check recipients.
+ if (
+ queryInfo.recipients &&
+ !isAddressMatch(queryInfo.recipients, [
+ { addr: msg.mime2DecodedRecipients, doRfc2047: false },
+ { addr: msg.ccList, doRfc2047: true },
+ { addr: msg.bccList, doRfc2047: true },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check if fullText is already partially fulfilled.
+ let fullTextBodySearchNeeded = false;
+ if (queryInfo.fullText) {
+ let subjectMatches = msg.mime2DecodedSubject.includes(
+ queryInfo.fullText
+ );
+ let authorMatches = msg.mime2DecodedAuthor.includes(
+ queryInfo.fullText
+ );
+ fullTextBodySearchNeeded = !(subjectMatches || authorMatches);
+ }
+
+ // Check body.
+ if (queryInfo.body || fullTextBodySearchNeeded) {
+ let mimeMsg = await getMimeMessage(msg);
+ if (
+ queryInfo.body &&
+ !includesContent(folder, [mimeMsg], queryInfo.body)
+ ) {
+ return false;
+ }
+ if (
+ fullTextBodySearchNeeded &&
+ !includesContent(folder, [mimeMsg], queryInfo.fullText)
+ ) {
+ return false;
+ }
+ }
+
+ // Check attachments.
+ if (queryInfo.attachment != null) {
+ let attachments = await getAttachments(
+ msg,
+ /* includeNestedAttachments */ true
+ );
+ return !!attachments.length == queryInfo.attachment;
+ }
+
+ return true;
+ };
+
+ const searchMessages = async (
+ folder,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ let messages = null;
+ try {
+ messages = folder.messages;
+ } catch (e) {
+ /* Some folders fail on message query, instead of returning empty */
+ }
+
+ if (messages) {
+ for (let msg of [...messages]) {
+ if (await checkSearchCriteria(folder, msg)) {
+ messageList.add(msg);
+ }
+ }
+ }
+
+ if (includeSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ await searchMessages(subFolder, messageList, true);
+ }
+ }
+ };
+
+ const searchFolders = async (
+ folders,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ for (let folder of folders) {
+ await searchMessages(folder, messageList, includeSubFolders);
+ }
+ return messageList.done();
+ };
+
+ // Prepare case insensitive me filtering.
+ let identities;
+ if (queryInfo.toMe !== null || queryInfo.fromMe !== null) {
+ identities = MailServices.accounts.allIdentities.map(i =>
+ i.email.toLocaleLowerCase()
+ );
+ }
+
+ // Prepare tag filtering.
+ let requiredTags;
+ let forbiddenTags;
+ if (queryInfo.tags) {
+ let availableTags = MailServices.tags.getAllTags();
+ requiredTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && queryInfo.tags.tags[tag.key]
+ );
+ forbiddenTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && !queryInfo.tags.tags[tag.key]
+ );
+ // If non-existing tags have been required, return immediately with
+ // an empty message list.
+ if (
+ requiredTags.length === 0 &&
+ Object.values(queryInfo.tags.tags).filter(v => v).length > 0
+ ) {
+ return messageListTracker.startList([], context.extension);
+ }
+ requiredTags = requiredTags.map(tag => tag.key);
+ forbiddenTags = forbiddenTags.map(tag => tag.key);
+ }
+
+ // Limit search to a given folder, or search all folders.
+ let folders = [];
+ let includeSubFolders = false;
+ if (queryInfo.folder) {
+ includeSubFolders = !!queryInfo.includeSubFolders;
+ if (!context.extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Querying by folder requires the "accountsRead" permission'
+ );
+ }
+ let folder = MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(queryInfo.folder.accountId, queryInfo.folder.path)
+ );
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder not found: ${queryInfo.folder.path}`
+ );
+ }
+ folders.push(folder);
+ } else {
+ includeSubFolders = true;
+ for (let account of MailServices.accounts.accounts) {
+ folders.push(account.incomingServer.rootFolder);
+ }
+ }
+
+ // The searchFolders() function searches the provided folders for
+ // messages matching the query and adds results to the messageList. It
+ // is an asynchronous function, but it is not awaited here. Instead,
+ // messageListTracker.getNextPage() returns a Promise, which will
+ // fulfill after enough messages for a full page have been added.
+ let messageList = messageListTracker.createList(context.extension);
+ searchFolders(folders, messageList, includeSubFolders);
+ return messageListTracker.getNextPage(messageList);
+ },
+ async update(messageId, newProperties) {
+ try {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ if (!msgHdr.folder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ let msgs = [msgHdr];
+ if (newProperties.read !== null) {
+ msgHdr.folder.markMessagesRead(msgs, newProperties.read);
+ }
+ if (newProperties.flagged !== null) {
+ msgHdr.folder.markMessagesFlagged(msgs, newProperties.flagged);
+ }
+ if (newProperties.junk !== null) {
+ let score = newProperties.junk
+ ? Ci.nsIJunkMailPlugin.IS_SPAM_SCORE
+ : Ci.nsIJunkMailPlugin.IS_HAM_SCORE;
+ msgHdr.folder.setJunkScoreForMessages(msgs, score);
+ // nsIFolderListener::OnFolderEvent is notified about changes through
+ // setJunkScoreForMessages(), but does not provide the actual message.
+ // nsIMsgFolderListener::msgsJunkStatusChanged is notified only by
+ // nsMsgDBView::ApplyCommandToIndices(). Since it only works on
+ // selected messages, we cannot use it here.
+ // Notify msgsJunkStatusChanged() manually.
+ MailServices.mfn.notifyMsgsJunkStatusChanged(msgs);
+ }
+ if (Array.isArray(newProperties.tags)) {
+ let currentTags = msgHdr.getStringProperty("keywords").split(" ");
+
+ for (let { key: tagKey } of MailServices.tags.getAllTags()) {
+ if (newProperties.tags.includes(tagKey)) {
+ if (!currentTags.includes(tagKey)) {
+ msgHdr.folder.addKeywordsToMessages(msgs, tagKey);
+ }
+ } else if (currentTags.includes(tagKey)) {
+ msgHdr.folder.removeKeywordsFromMessages(msgs, tagKey);
+ }
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error updating message: ${ex.message}`);
+ }
+ },
+ async move(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, true);
+ },
+ async copy(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, false);
+ },
+ async delete(messageIds, skipTrash) {
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ if (!sourceFolder.canDeleteMessages) {
+ throw new ExtensionError(
+ `Messages in "${sourceFolder.prettyName}" cannot be deleted`
+ );
+ }
+ promises.push(
+ new Promise((resolve, reject) => {
+ sourceFolder.deleteMessages(
+ [...msgHeaderSet],
+ /* msgWindow */ null,
+ /* deleteStorage */ skipTrash,
+ /* isMove */ false,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error deleting message: ${ex.message}`);
+ }
+ },
+ async import(file, { accountId, path }, properties) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesImport")
+ ) {
+ throw new ExtensionError(
+ `Using messages.import() requires the "accountsRead" and the "messagesImport" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ if (!destinationFolder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ if (!["none", "pop3"].includes(destinationFolder.server.type)) {
+ throw new ExtensionError(
+ `browser.messenger.import() is not supported for ${destinationFolder.server.type} accounts`
+ );
+ }
+ try {
+ let tempFile = await getRealFileForFile(file);
+ let msgHeader = await new Promise((resolve, reject) => {
+ let newKey = null;
+ let msgHdrs = new Map();
+
+ let folderListener = {
+ onMessageAdded(parentItem, msgHdr) {
+ if (destinationFolder.URI != msgHdr.folder.URI) {
+ return;
+ }
+ let key = msgHdr.messageKey;
+ msgHdrs.set(key, msgHdr);
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ onFolderAdded(parent, child) {},
+ };
+
+ // Note: Currently this API is not supported for IMAP. Once this gets added (Bug 1787104),
+ // please note that the MailServices.mfn.addListener will fire only when the IMAP message
+ // is visibly shown in the UI, while MailServices.mailSession.AddFolderListener fires as
+ // soon as it has been added to the database .
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.added
+ );
+
+ let finish = msgHdr => {
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ resolve(msgHdr);
+ };
+
+ let tags = "";
+ let flags = 0;
+ if (properties) {
+ if (properties.tags) {
+ let knownTags = MailServices.tags
+ .getAllTags()
+ .map(tag => tag.key);
+ tags = properties.tags
+ .filter(tag => knownTags.includes(tag))
+ .join(" ");
+ }
+ flags |= properties.new ? Ci.nsMsgMessageFlags.New : 0;
+ flags |= properties.read ? Ci.nsMsgMessageFlags.Read : 0;
+ flags |= properties.flagged ? Ci.nsMsgMessageFlags.Marked : 0;
+ }
+ MailServices.copy.copyFileMessage(
+ tempFile,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ flags,
+ /* aMsgKeywords */ tags,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(aKey) {
+ /* Note: Not fired for offline IMAP. Add missing
+ * if (aCopyState) {
+ * ((nsImapMailCopyState*)aCopyState)->m_listener->SetMessageKey(fakeKey);
+ * }
+ * before firing the OnStopRunningUrl listener in
+ * nsImapService::OfflineAppendFromFile
+ */
+ newKey = aKey;
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ if (newKey && msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ });
+
+ // Do not wait till the temp file is removed on app shutdown. However, skip deletion if
+ // the provided DOM File was already linked to a real file.
+ if (!file.mozFullPath) {
+ await IOUtils.remove(tempFile.path);
+ }
+ return convertMessage(msgHeader, context.extension);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error importing message: ${ex.message}`);
+ }
+ },
+ async archive(messageIds) {
+ try {
+ let messages = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ messages.push(...msgHeaderSet);
+ }
+ await new Promise(resolve => {
+ let archiver = new MessageArchiver();
+ archiver.oncomplete = resolve;
+ archiver.archiveMessages(messages);
+ });
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error archiving message: ${ex.message}`);
+ }
+ },
+ async listTags() {
+ return MailServices.tags
+ .getAllTags()
+ .map(({ key, tag, color, ordinal }) => {
+ return {
+ key,
+ tag,
+ color,
+ ordinal,
+ };
+ });
+ },
+ async createTag(key, tag, color) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key already exists: ${key}`);
+ }
+ if (tags.find(t => t.tag == tag)) {
+ throw new ExtensionError(`Specified tag already exists: ${tag}`);
+ }
+ MailServices.tags.addTagForKey(key, tag, color, "");
+ },
+ async updateTag(key, updateProperties) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ let tag = tags.find(t => t.key == key);
+ if (!tag) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ if (updateProperties.color && tag.color != updateProperties.color) {
+ MailServices.tags.setColorForKey(key, updateProperties.color);
+ }
+ if (updateProperties.tag && tag.tag != updateProperties.tag) {
+ // Don't let the user edit a tag to the name of another existing tag.
+ if (tags.find(t => t.tag == updateProperties.tag)) {
+ throw new ExtensionError(
+ `Specified tag already exists: ${updateProperties.tag}`
+ );
+ }
+ MailServices.tags.setTagForKey(key, updateProperties.tag);
+ }
+ },
+ async deleteTag(key) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (!tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ MailServices.tags.deleteKey(key);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-sessions.js b/comm/mail/components/extensions/parent/ext-sessions.js
new file mode 100644
index 0000000000..3abe652fe3
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-sessions.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+function getSessionData(tabId, extension) {
+ let nativeTab = tabTracker.getTab(tabId);
+ let widgetId = makeWidgetId(extension.id);
+
+ if (!nativeTab._ext.extensionSession) {
+ nativeTab._ext.extensionSession = {};
+ }
+ if (!nativeTab._ext.extensionSession[`${widgetId}`]) {
+ nativeTab._ext.extensionSession[`${widgetId}`] = {};
+ }
+ return nativeTab._ext.extensionSession[`${widgetId}`];
+}
+
+this.sessions = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ sessions: {
+ setTabValue(tabId, key, value) {
+ let sessionData = getSessionData(tabId, context.extension);
+ sessionData[key] = value;
+ },
+ getTabValue(tabId, key) {
+ let sessionData = getSessionData(tabId, context.extension);
+ return sessionData[key];
+ },
+ removeTabValue(tabId, key) {
+ let sessionData = getSessionData(tabId, context.extension);
+ delete sessionData[key];
+ },
+ },
+ };
+ }
+
+ static onUninstall(extensionId) {
+ // Remove session data.
+ let widgetId = makeWidgetId(extensionId);
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ for (let tabInfo of window.gTabmail.tabInfo) {
+ if (
+ tabInfo._ext.extensionSession &&
+ tabInfo._ext.extensionSession[`${widgetId}`]
+ ) {
+ delete tabInfo._ext.extensionSession[`${widgetId}`];
+ }
+ }
+ }
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-spaces.js b/comm/mail/components/extensions/parent/ext-spaces.js
new file mode 100644
index 0000000000..3f2ade0404
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-spaces.js
@@ -0,0 +1,364 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "getIconData",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var windowURLs = ["chrome://messenger/content/messenger.xhtml"];
+
+/**
+ * Return the paths to the 16px and 32px icons defined in the manifest of this
+ * extension, if any.
+ *
+ * @param {ExtensionData} extension - the extension to retrieve the path object for
+ */
+function getManifestIcons(extension) {
+ if (extension.manifest.icons) {
+ let { icon: icon16 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon32 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return {
+ 16: extension.baseURI.resolve(icon16),
+ 32: extension.baseURI.resolve(icon32),
+ };
+ }
+ return null;
+}
+
+/**
+ * Convert WebExtension SpaceButtonProperties into a NativeButtonProperties
+ * object required by the gSpacesToolbar.* functions.
+ *
+ * @param {SpaceData} spaceData - @see mail/components/extensions/parent/ext-mail.js
+ * @returns {NativeButtonProperties} - @see mail/base/content/spacesToolbar.js
+ */
+function getNativeButtonProperties({
+ extension,
+ defaultUrl,
+ buttonProperties,
+}) {
+ const normalizeColor = color => {
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(`Invalid color value: "${color}"`);
+ }
+ return [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ return color;
+ };
+
+ let hasThemeIcons =
+ buttonProperties.themeIcons && buttonProperties.themeIcons.length > 0;
+
+ // If themeIcons have been defined, ignore manifestIcons as fallback and use
+ // themeIcons for the default theme as well, following the behavior of
+ // WebExtension action buttons.
+ let fallbackManifestIcons = hasThemeIcons
+ ? null
+ : getManifestIcons(extension);
+
+ // Use _normalize() to bypass cache.
+ let icons = ExtensionParent.IconDetails._normalize(
+ {
+ path: buttonProperties.defaultIcons || fallbackManifestIcons,
+ themeIcons: hasThemeIcons ? buttonProperties.themeIcons : null,
+ },
+ extension
+ );
+ let iconStyles = new Map(getIconData(icons, extension).style);
+
+ let badgeStyles = new Map();
+ let bgColor = normalizeColor(buttonProperties.badgeBackgroundColor);
+ if (bgColor) {
+ badgeStyles.set(
+ "--spaces-button-badge-bg-color",
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ }
+
+ return {
+ title: buttonProperties.title || extension.name,
+ url: defaultUrl,
+ badgeText: buttonProperties.badgeText,
+ badgeStyles,
+ iconStyles,
+ };
+}
+
+ExtensionSupport.registerWindowListener("ext-spaces", {
+ chromeURLs: windowURLs,
+ onLoadWindow: async window => {
+ await new Promise(resolve => {
+ if (window.gSpacesToolbar.isLoaded) {
+ resolve();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", resolve, {
+ once: true,
+ });
+ }
+ });
+ // Add buttons of all extension spaces to the toolbar of each newly opened
+ // normal window.
+ for (let spaceData of spaceTracker.getAll()) {
+ if (!spaceData.extension) {
+ continue;
+ }
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ },
+});
+
+this.spaces = class extends ExtensionAPI {
+ /**
+ * Match a WebExtension Space object against the provided queryInfo.
+ *
+ * @param {Space} space - @see mail/components/extensions/schemas/spaces.json
+ * @param {QueryInfo} queryInfo - @see mail/components/extensions/schemas/spaces.json
+ * @returns {boolean}
+ */
+ matchSpace(space, queryInfo) {
+ if (queryInfo.id != null && space.id != queryInfo.id) {
+ return false;
+ }
+ if (queryInfo.name != null && space.name != queryInfo.name) {
+ return false;
+ }
+ if (queryInfo.isBuiltIn != null && space.isBuiltIn != queryInfo.isBuiltIn) {
+ return false;
+ }
+ if (
+ queryInfo.isSelfOwned != null &&
+ space.isSelfOwned != queryInfo.isSelfOwned
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.extensionId != null &&
+ space.extensionId != queryInfo.extensionId
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ async onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let extensionId = this.extension.id;
+ for (let spaceData of spaceTracker.getAll()) {
+ if (spaceData.extension?.id != extensionId) {
+ continue;
+ }
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ }
+ }
+
+ getAPI(context) {
+ let { tabManager } = context.extension;
+ let self = this;
+
+ return {
+ spaces: {
+ async create(name, defaultUrl, buttonProperties) {
+ if (spaceTracker.fromSpaceName(name, context.extension)) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: Space already exists for this extension.`
+ );
+ }
+
+ defaultUrl = context.uri.resolve(defaultUrl);
+ if (!/((^https:)|(^http:)|(^moz-extension:))/i.test(defaultUrl)) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: Invalid default url.`
+ );
+ }
+
+ try {
+ let spaceData = await spaceTracker.create(
+ name,
+ defaultUrl,
+ buttonProperties,
+ context.extension
+ );
+
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+
+ return spaceTracker.convert(spaceData, context.extension);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: ${error}`
+ );
+ }
+ },
+ async remove(spaceId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: Unknown id.`
+ );
+ }
+ if (spaceData.extension?.id != context.extension.id) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: Space does not belong to this extension.`
+ );
+ }
+
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: ${ex.message}`
+ );
+ }
+ },
+ async update(spaceId, updatedDefaultUrl, updatedButtonProperties) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Unknown id.`
+ );
+ }
+ if (spaceData.extension?.id != context.extension.id) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Space does not belong to this extension.`
+ );
+ }
+
+ let changes = false;
+ if (updatedDefaultUrl) {
+ updatedDefaultUrl = context.uri.resolve(updatedDefaultUrl);
+ if (
+ !/((^https:)|(^http:)|(^moz-extension:))/i.test(updatedDefaultUrl)
+ ) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Invalid default url.`
+ );
+ }
+ spaceData.defaultUrl = updatedDefaultUrl;
+ changes = true;
+ }
+
+ if (updatedButtonProperties) {
+ for (let [key, value] of Object.entries(updatedButtonProperties)) {
+ if (value != null) {
+ spaceData.buttonProperties[key] = value;
+ changes = true;
+ }
+ }
+ }
+
+ if (changes) {
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.updateToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+ spaceTracker.update(spaceData);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: ${error}`
+ );
+ }
+ }
+ },
+ async open(spaceId, windowId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to open space with id ${spaceId}: Unknown id.`
+ );
+ }
+
+ let window = await getNormalWindowReady(context, windowId);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.button.id == spaceData.spaceButtonId
+ );
+
+ let tabmail = window.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = window.gSpacesToolbar.openSpace(tabmail, space);
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+ async get(spaceId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to get space with id ${spaceId}: Unknown id.`
+ );
+ }
+ return spaceTracker.convert(spaceData, context.extension);
+ },
+ async query(queryInfo) {
+ let allSpaceData = [...spaceTracker.getAll()];
+ return allSpaceData
+ .map(spaceData =>
+ spaceTracker.convert(spaceData, context.extension)
+ )
+ .filter(space => self.matchSpace(space, queryInfo));
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-spacesToolbar.js b/comm/mail/components/extensions/parent/ext-spacesToolbar.js
new file mode 100644
index 0000000000..1a42aa0a6e
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-spacesToolbar.js
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "getIconData",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var { makeWidgetId } = ExtensionCommon;
+
+var windowURLs = ["chrome://messenger/content/messenger.xhtml"];
+
+/**
+ * Return the paths to the 16px and 32px icons defined in the manifest of this
+ * extension, if any.
+ *
+ * @param {ExtensionData} extension - the extension to retrieve the path object for
+ */
+function getManifestIcons(extension) {
+ if (extension.manifest.icons) {
+ let { icon: icon16 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon32 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return {
+ 16: extension.baseURI.resolve(icon16),
+ 32: extension.baseURI.resolve(icon32),
+ };
+ }
+ return null;
+}
+
+/**
+ * Convert WebExtension SpaceButtonProperties into a NativeButtonProperties
+ * object required by the gSpacesToolbar.* functions.
+ *
+ * @param {SpaceData} spaceData - @see mail/components/extensions/parent/ext-mail.js
+ * @returns {NativeButtonProperties} - @see mail/base/content/spacesToolbar.js
+ */
+function convertProperties({ extension, buttonProperties }) {
+ const normalizeColor = color => {
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(`Invalid color value: "${color}"`);
+ }
+ return [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ return color;
+ };
+
+ let hasThemeIcons =
+ buttonProperties.themeIcons && buttonProperties.themeIcons.length > 0;
+
+ // If themeIcons have been defined, ignore manifestIcons as fallback and use
+ // themeIcons for the default theme as well, following the behavior of
+ // WebExtension action buttons.
+ let fallbackManifestIcons = hasThemeIcons
+ ? null
+ : getManifestIcons(extension);
+
+ // Use _normalize() to bypass cache.
+ let icons = ExtensionParent.IconDetails._normalize(
+ {
+ path: buttonProperties.defaultIcons || fallbackManifestIcons,
+ themeIcons: hasThemeIcons ? buttonProperties.themeIcons : null,
+ },
+ extension
+ );
+ let iconStyles = new Map(getIconData(icons, extension).style);
+
+ let badgeStyles = new Map();
+ let bgColor = normalizeColor(buttonProperties.badgeBackgroundColor);
+ if (bgColor) {
+ badgeStyles.set(
+ "--spaces-button-badge-bg-color",
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ }
+
+ return {
+ title: buttonProperties.title || extension.name,
+ url: buttonProperties.url,
+ badgeText: buttonProperties.badgeText,
+ badgeStyles,
+ iconStyles,
+ };
+}
+
+ExtensionSupport.registerWindowListener("ext-spacesToolbar", {
+ chromeURLs: windowURLs,
+ onLoadWindow: async window => {
+ await new Promise(resolve => {
+ if (window.gSpacesToolbar.isLoaded) {
+ resolve();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", resolve, {
+ once: true,
+ });
+ }
+ });
+ // Add buttons of all extension spaces to the toolbar of each newly opened
+ // normal window.
+ for (let spaceData of spaceTracker.getAll()) {
+ if (!spaceData.extension) {
+ continue;
+ }
+ let nativeButtonProperties = convertProperties(spaceData);
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ },
+});
+
+this.spacesToolbar = class extends ExtensionAPI {
+ async onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let extensionId = this.extension.id;
+ for (let spaceData of spaceTracker.getAll()) {
+ if (spaceData.extension?.id != extensionId) {
+ continue;
+ }
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ }
+ }
+
+ getAPI(context) {
+ this.widgetId = makeWidgetId(context.extension.id);
+ let { tabManager } = context.extension;
+
+ return {
+ spacesToolbar: {
+ async addButton(name, properties) {
+ if (properties.url) {
+ properties.url = context.uri.resolve(properties.url);
+ }
+ let [protocol] = (properties.url || "").split("://");
+ if (
+ !protocol ||
+ !["https", "http", "moz-extension"].includes(protocol)
+ ) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: Invalid url.`
+ );
+ }
+
+ if (spaceTracker.fromSpaceName(name, context.extension)) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: The id ${name} is already used by this extension.`
+ );
+ }
+ try {
+ let spaceData = await spaceTracker.create(
+ name,
+ properties.url,
+ properties,
+ context.extension
+ );
+
+ let nativeButtonProperties = convertProperties(spaceData);
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+
+ return spaceData.spaceId;
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: ${error}`
+ );
+ }
+ },
+ async removeButton(name) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to remove button from the spaces toolbar: A button with id ${name} does not exist for this extension.`
+ );
+ }
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to remove button from the spaces toolbar: ${ex.message}`
+ );
+ }
+ },
+ async updateButton(name, updatedProperties) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: A button with id ${name} does not exist for this extension.`
+ );
+ }
+
+ if (updatedProperties.url != null) {
+ updatedProperties.url = context.uri.resolve(updatedProperties.url);
+ let [protocol] = updatedProperties.url.split("://");
+ if (
+ !protocol ||
+ !["https", "http", "moz-extension"].includes(protocol)
+ ) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: Invalid url.`
+ );
+ }
+ }
+
+ let changes = false;
+ for (let [key, value] of Object.entries(updatedProperties)) {
+ if (value != null) {
+ if (key == "url") {
+ spaceData.defaultUrl = value;
+ }
+ spaceData.buttonProperties[key] = value;
+ changes = true;
+ }
+ }
+
+ if (changes) {
+ let nativeButtonProperties = convertProperties(spaceData);
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.updateToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+ spaceTracker.update(spaceData);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: ${error}`
+ );
+ }
+ }
+ },
+ async clickButton(name, windowId) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to trigger a click on the spaces toolbar button: A button with id ${name} does not exist for this extension.`
+ );
+ }
+
+ let window = await getNormalWindowReady(context, windowId);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.button.id == spaceData.spaceButtonId
+ );
+
+ let tabmail = window.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = window.gSpacesToolbar.openSpace(tabmail, space);
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-tabs.js b/comm/mail/components/extensions/parent/ext-tabs.js
new file mode 100644
index 0000000000..6327743afa
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-tabs.js
@@ -0,0 +1,822 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailE10SUtils",
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * A listener that allows waiting until tabs are fully loaded, e.g. off of about:blank.
+ */
+let tabListener = {
+ tabReadyInitialized: false,
+ tabReadyPromises: new WeakMap(),
+ initializingTabs: new WeakSet(),
+
+ /**
+ * Initialize the progress listener for tab ready changes.
+ */
+ initTabReady() {
+ if (!this.tabReadyInitialized) {
+ windowTracker.addListener("progress", this);
+
+ this.tabReadyInitialized = true;
+ }
+ },
+
+ /**
+ * Web Progress listener method for the location change.
+ *
+ * @param {Element} browser - The browser element that caused the change
+ * @param {nsIWebProgress} webProgress - The web progress for the location change
+ * @param {nsIRequest} request - The xpcom request for this change
+ * @param {nsIURI} locationURI - The target uri
+ * @param {Integer} flags - The web progress flags for this change
+ */
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (webProgress && webProgress.isTopLevel) {
+ let window = browser.ownerGlobal.top;
+ let tabmail = window.document.getElementById("tabmail");
+ let nativeTabInfo = tabmail ? tabmail.getTabForBrowser(browser) : window;
+
+ // Now we are certain that the first page in the tab was loaded.
+ this.initializingTabs.delete(nativeTabInfo);
+
+ // browser.innerWindowID is now set, resolve the promises if any.
+ let deferred = this.tabReadyPromises.get(nativeTabInfo);
+ if (deferred) {
+ deferred.resolve(nativeTabInfo);
+ this.tabReadyPromises.delete(nativeTabInfo);
+ }
+ }
+ },
+
+ /**
+ * Promise that the given tab completes loading.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - the tabInfo describing the tab
+ * @returns {Promise<NativeTabInfo>} - resolves when the tab completes loading
+ */
+ awaitTabReady(nativeTabInfo) {
+ let deferred = this.tabReadyPromises.get(nativeTabInfo);
+ if (!deferred) {
+ deferred = PromiseUtils.defer();
+ let browser = getTabBrowser(nativeTabInfo);
+ if (
+ !this.initializingTabs.has(nativeTabInfo) &&
+ (browser.innerWindowID ||
+ ["about:blank", "about:blank?compose"].includes(
+ browser.currentURI.spec
+ ))
+ ) {
+ deferred.resolve(nativeTabInfo);
+ } else {
+ this.initTabReady();
+ this.tabReadyPromises.set(nativeTabInfo, deferred);
+ }
+ }
+ return deferred.promise;
+ },
+};
+
+let hasWebHandlerApp = protocol => {
+ let protoInfo = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .getProtocolHandlerInfo(protocol);
+ let appHandlers = protoInfo.possibleApplicationHandlers;
+ for (let i = 0; i < appHandlers.length; i++) {
+ let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+ if (handler instanceof Ci.nsIWebHandlerApp) {
+ return true;
+ }
+ }
+ return false;
+};
+
+// Attributes and properties used in the TabsUpdateFilterManager.
+const allAttrs = new Set(["favIconUrl", "title"]);
+const allProperties = new Set(["favIconUrl", "status", "title"]);
+const restricted = new Set(["url", "favIconUrl", "title"]);
+
+this.tabs = class extends ExtensionAPIPersistent {
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ let tabmail = window.document.getElementById("tabmail");
+ for (let i = tabmail.tabInfo.length; i > 0; i--) {
+ let nativeTabInfo = tabmail.tabInfo[i - 1];
+ let uri = nativeTabInfo.browser?.browsingContext.currentURI;
+ if (
+ uri &&
+ uri.scheme == "moz-extension" &&
+ uri.host == this.extension.uuid
+ ) {
+ tabmail.closeTab(nativeTabInfo);
+ }
+ }
+ }
+ }
+
+ tabEventRegistrar({ tabEvent, listener }) {
+ let { extension } = this;
+ let { tabManager } = extension;
+ return ({ context, fire }) => {
+ let listener2 = async (eventName, event, ...args) => {
+ if (!tabManager.canAccessTab(event.nativeTab)) {
+ return;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ listener({ context, fire, event }, ...args);
+ };
+ tabTracker.on(tabEvent, listener2);
+ return {
+ unregister() {
+ tabTracker.off(tabEvent, listener2);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called) (handled by tabEventRegistrar).
+
+ onActivated: this.tabEventRegistrar({
+ tabEvent: "tab-activated",
+ listener: ({ context, fire, event }) => {
+ let { tabId, windowId, previousTabId } = event;
+ fire.async({ tabId, windowId, previousTabId });
+ },
+ }),
+
+ onCreated: this.tabEventRegistrar({
+ tabEvent: "tab-created",
+ listener: ({ context, fire, event }) => {
+ let { extension } = this;
+ let { tabManager } = extension;
+ fire.async(tabManager.convert(event.nativeTabInfo, event.currentTab));
+ },
+ }),
+
+ onAttached: this.tabEventRegistrar({
+ tabEvent: "tab-attached",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ newWindowId: event.newWindowId,
+ newPosition: event.newPosition,
+ });
+ },
+ }),
+
+ onDetached: this.tabEventRegistrar({
+ tabEvent: "tab-detached",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ oldWindowId: event.oldWindowId,
+ oldPosition: event.oldPosition,
+ });
+ },
+ }),
+
+ onRemoved: this.tabEventRegistrar({
+ tabEvent: "tab-removed",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ windowId: event.windowId,
+ isWindowClosing: event.isWindowClosing,
+ });
+ },
+ }),
+
+ onMoved({ context, fire }) {
+ let { tabManager } = this.extension;
+ let moveListener = async event => {
+ let nativeTab = event.target;
+ let nativeTabInfo = event.detail.tabInfo;
+ let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+ if (tabManager.canAccessTab(nativeTab)) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(tabTracker.getId(nativeTabInfo), {
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ fromIndex: event.detail.idx,
+ toIndex: tabmail.tabInfo.indexOf(nativeTabInfo),
+ });
+ }
+ };
+
+ windowTracker.addListener("TabMove", moveListener);
+ return {
+ unregister() {
+ windowTracker.removeListener("TabMove", moveListener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onUpdated({ context, fire }, [filterProps]) {
+ let filter = { ...filterProps };
+ let scheduledEvents = [];
+
+ if (
+ filter &&
+ filter.urls &&
+ !this.extension.hasPermission("tabs") &&
+ !this.extension.hasPermission("activeTab")
+ ) {
+ console.error(
+ 'Url filtering in tabs.onUpdated requires "tabs" or "activeTab" permission.'
+ );
+ return false;
+ }
+
+ if (filter.urls) {
+ // TODO: Consider following M-C
+ // Use additional parameter { restrictSchemes: false }.
+ filter.urls = new MatchPatternSet(filter.urls);
+ }
+ let needsModified = true;
+ if (filter.properties) {
+ // Default is to listen for all events.
+ needsModified = filter.properties.some(prop => allAttrs.has(prop));
+ filter.properties = new Set(filter.properties);
+ } else {
+ filter.properties = allProperties;
+ }
+
+ function sanitize(tab, changeInfo) {
+ let result = {};
+ let nonempty = false;
+ for (let prop in changeInfo) {
+ // In practice, changeInfo contains at most one property from
+ // restricted. Therefore it is not necessary to cache the value
+ // of tab.hasTabPermission outside the loop.
+ // Unnecessarily accessing tab.hasTabPermission can cause bugs, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1694699#c21
+ if (tab.hasTabPermission || !restricted.has(prop)) {
+ nonempty = true;
+ result[prop] = changeInfo[prop];
+ }
+ }
+ return nonempty && result;
+ }
+
+ function getWindowID(windowId) {
+ if (windowId === WindowBase.WINDOW_ID_CURRENT) {
+ // TODO: Consider following M-C
+ // Use windowTracker.getTopWindow(context).
+ return windowTracker.getId(windowTracker.topWindow);
+ }
+ return windowId;
+ }
+
+ function matchFilters(tab, changed) {
+ if (!filterProps) {
+ return true;
+ }
+ if (filter.tabId != null && tab.id != filter.tabId) {
+ return false;
+ }
+ if (
+ filter.windowId != null &&
+ tab.windowId != getWindowID(filter.windowId)
+ ) {
+ return false;
+ }
+ if (filter.urls) {
+ // We check permission first because tab.uri is null if !hasTabPermission.
+ return tab.hasTabPermission && filter.urls.matches(tab.uri);
+ }
+ return true;
+ }
+
+ let fireForTab = async (tab, changed) => {
+ if (!matchFilters(tab, changed)) {
+ return;
+ }
+
+ let changeInfo = sanitize(tab, changed);
+ if (changeInfo) {
+ let tabInfo = tab.convert();
+ // TODO: Consider following M-C
+ // Use tabTracker.maybeWaitForTabOpen(nativeTab).then(() => {}).
+
+ // Using a FIFO to keep order of events, in case the last one
+ // gets through without being placed on the async callback stack.
+ scheduledEvents.push([tab.id, changeInfo, tabInfo]);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(...scheduledEvents.shift());
+ }
+ };
+
+ let listener = event => {
+ /* TODO: Consider following M-C
+ // Ignore any events prior to TabOpen and events that are triggered while
+ // tabs are swapped between windows.
+ if (event.originalTarget.initializingTab) {
+ return;
+ }
+ if (!extension.canAccessWindow(event.originalTarget.ownerGlobal)) {
+ return;
+ }
+ */
+
+ let changeInfo = {};
+ let { extension } = this;
+ let { tabManager } = extension;
+ let tab = tabManager.getWrapper(event.detail.tabInfo);
+ let changed = event.detail.changed;
+ if (
+ changed.includes("favIconUrl") &&
+ filter.properties.has("favIconUrl")
+ ) {
+ changeInfo.favIconUrl = tab.favIconUrl;
+ }
+ if (changed.includes("label") && filter.properties.has("title")) {
+ changeInfo.title = tab.title;
+ }
+
+ fireForTab(tab, changeInfo);
+ };
+
+ let statusListener = ({ browser, status, url }) => {
+ let { extension } = this;
+ let { tabManager } = extension;
+ let tabId = tabTracker.getBrowserTabId(browser);
+ if (tabId != -1) {
+ let changed = { status };
+ if (url) {
+ changed.url = url;
+ }
+ fireForTab(tabManager.get(tabId), changed);
+ }
+ };
+
+ if (needsModified) {
+ windowTracker.addListener("TabAttrModified", listener);
+ }
+
+ if (filter.properties.has("status")) {
+ windowTracker.addListener("status", statusListener);
+ }
+
+ return {
+ unregister() {
+ if (needsModified) {
+ windowTracker.removeListener("TabAttrModified", listener);
+ }
+ if (filter.properties.has("status")) {
+ windowTracker.removeListener("status", statusListener);
+ }
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ /**
+ * Gets the tab for the given tab id, or the active tab if the id is null.
+ *
+ * @param {?Integer} tabId - The tab id to get
+ * @returns {Tab} The matching tab, or the active tab
+ */
+ function getTabOrActive(tabId) {
+ if (tabId) {
+ return tabTracker.getTab(tabId);
+ }
+ return tabTracker.activeTab;
+ }
+
+ /**
+ * Promise that the tab with the given tab id is ready.
+ *
+ * @param {Integer} tabId - The tab id to check
+ * @returns {Promise<NativeTabInfo>} Resolved when the loading is complete
+ */
+ async function promiseTabWhenReady(tabId) {
+ let tab;
+ if (tabId === null) {
+ tab = tabManager.getWrapper(tabTracker.activeTab);
+ } else {
+ tab = tabManager.get(tabId);
+ }
+
+ await tabListener.awaitTabReady(tab.nativeTab);
+
+ return tab;
+ }
+
+ return {
+ tabs: {
+ onActivated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onActivated",
+ extensionApi: this,
+ }).api(),
+
+ onCreated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onAttached: new EventManager({
+ context,
+ module: "tabs",
+ event: "onAttached",
+ extensionApi: this,
+ }).api(),
+
+ onDetached: new EventManager({
+ context,
+ module: "tabs",
+ event: "onDetached",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "tabs",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ module: "tabs",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+
+ async create(createProperties) {
+ let window = await getNormalWindowReady(
+ context,
+ createProperties.windowId
+ );
+ let tabmail = window.document.getElementById("tabmail");
+ let url;
+ if (createProperties.url) {
+ url = context.uri.resolve(createProperties.url);
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ }
+
+ let userContextId =
+ Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ if (createProperties.cookieStoreId) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createProperties.cookieStoreId
+ );
+ }
+
+ let currentTab = tabmail.selectedTab;
+ let active = createProperties.active ?? true;
+ tabListener.initTabReady();
+
+ let nativeTabInfo = tabmail.openTab("contentTab", {
+ url: url || "about:blank",
+ linkHandler: "single-site",
+ background: !active,
+ initialBrowsingContextGroupId:
+ context.extension.policy.browsingContextGroupId,
+ principal: context.extension.principal,
+ duplicate: true,
+ userContextId,
+ });
+
+ if (createProperties.index) {
+ tabmail.moveTabTo(nativeTabInfo, createProperties.index);
+ tabmail.updateCurrentTab();
+ }
+
+ if (createProperties.url && createProperties.url !== "about:blank") {
+ // Mark tabs as initializing, so operations like `executeScript` wait until the
+ // requested URL is loaded.
+ tabListener.initializingTabs.add(nativeTabInfo);
+ }
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+
+ async remove(tabs) {
+ if (!Array.isArray(tabs)) {
+ tabs = [tabs];
+ }
+
+ for (let tabId of tabs) {
+ let nativeTabInfo = tabTracker.getTab(tabId);
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ nativeTabInfo.close();
+ continue;
+ }
+ let tabmail = getTabTabmail(nativeTabInfo);
+ tabmail.closeTab(nativeTabInfo);
+ }
+ },
+
+ async update(tabId, updateProperties) {
+ let nativeTabInfo = getTabOrActive(tabId);
+ let tab = tabManager.getWrapper(nativeTabInfo);
+ let tabmail = getTabTabmail(nativeTabInfo);
+
+ if (updateProperties.url) {
+ let url = context.uri.resolve(updateProperties.url);
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+
+ let uri;
+ try {
+ uri = Services.io.newURI(url);
+ } catch (e) {
+ throw new ExtensionError(`Url "${url}" seems to be malformed.`);
+ }
+
+ // http(s): urls, moz-extension: urls and self-registered protocol
+ // handlers are actually loaded into the tab (and change its url).
+ // All other urls are forwarded to the external protocol handler and
+ // do not change the current tab.
+ let isContentUrl =
+ /((^blob:)|(^https:)|(^http:)|(^moz-extension:))/i.test(url);
+ let isWebExtProtocolUrl =
+ /((^ext\+[a-z]+:)|(^web\+[a-z]+:))/i.test(url) &&
+ hasWebHandlerApp(uri.scheme);
+
+ if (isContentUrl || isWebExtProtocolUrl) {
+ if (tab.type != "content" && tab.type != "mail") {
+ throw new ExtensionError(
+ isContentUrl
+ ? "Loading a content url is only supported for content tabs and mail tabs."
+ : "Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs."
+ );
+ }
+
+ let options = {
+ flags: updateProperties.loadReplace
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ triggeringPrincipal: context.principal,
+ };
+
+ if (tab.type == "mail") {
+ // The content browser in about:3pane.
+ nativeTabInfo.chromeBrowser.contentWindow.messagePane.displayWebPage(
+ url,
+ options
+ );
+ } else {
+ let browser = getTabBrowser(nativeTabInfo);
+ if (!browser) {
+ throw new ExtensionError("Cannot set a URL for this tab.");
+ }
+ MailE10SUtils.loadURI(browser, url, options);
+ }
+ } else {
+ // Send unknown URLs schema to the external protocol handler.
+ // This does not change the current tab.
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ }
+ }
+
+ // A tab can only be set to be active. To set it inactive, another tab
+ // has to be set as active.
+ if (tabmail && updateProperties.active) {
+ tabmail.selectedTab = nativeTabInfo;
+ }
+
+ return tabManager.convert(nativeTabInfo);
+ },
+
+ async reload(tabId, reloadProperties) {
+ let nativeTabInfo = getTabOrActive(tabId);
+ let tab = tabManager.getWrapper(nativeTabInfo);
+
+ let isContentMailTab =
+ tab.type == "mail" &&
+ !nativeTabInfo.chromeBrowser.contentWindow.webBrowser.hidden;
+ if (tab.type != "content" && !isContentMailTab) {
+ throw new ExtensionError(
+ "Reloading is only supported for tabs displaying a content page."
+ );
+ }
+
+ let browser = getTabBrowser(nativeTabInfo);
+
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (reloadProperties && reloadProperties.bypassCache) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ browser.reloadWithFlags(flags);
+ },
+
+ async get(tabId) {
+ return tabManager.get(tabId).convert();
+ },
+
+ getCurrent() {
+ let tabData;
+ if (context.tabId) {
+ tabData = tabManager.get(context.tabId).convert();
+ }
+ return Promise.resolve(tabData);
+ },
+
+ async query(queryInfo) {
+ if (!extension.hasPermission("tabs")) {
+ if (queryInfo.url !== null || queryInfo.title !== null) {
+ return Promise.reject({
+ message:
+ 'The "tabs" permission is required to use the query API with the "url" or "title" parameters',
+ });
+ }
+ }
+
+ // Make ext-tabs-base happy since it does a strict check.
+ queryInfo.screen = null;
+
+ return Array.from(tabManager.query(queryInfo, context), tab =>
+ tab.convert()
+ );
+ },
+
+ async executeScript(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.executeScript(context, details);
+ },
+
+ async insertCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.insertCSS(context, details);
+ },
+
+ async removeCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.removeCSS(context, details);
+ },
+
+ async move(tabIds, moveProperties) {
+ let tabsMoved = [];
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+
+ let destinationWindow = null;
+ if (moveProperties.windowId !== null) {
+ destinationWindow = await getNormalWindowReady(
+ context,
+ moveProperties.windowId
+ );
+ }
+
+ /*
+ Indexes are maintained on a per window basis so that a call to
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 1 if tabA and tabB are in the same window
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 0 if tabA and tabB are in different windows
+ */
+ let indexMap = new Map();
+ let lastInsertion = new Map();
+
+ let tabs = tabIds.map(tabId => ({
+ nativeTabInfo: tabTracker.getTab(tabId),
+ tabId,
+ }));
+ for (let { nativeTabInfo, tabId } of tabs) {
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ return Promise.reject({
+ message: `Tab with ID ${tabId} does not belong to a normal window`,
+ });
+ }
+
+ // If the window is not specified, use the window from the tab.
+ let browser = getTabBrowser(nativeTabInfo);
+
+ let srcwindow = browser.ownerGlobal;
+ let tgtwindow = destinationWindow || browser.ownerGlobal;
+ let tgttabmail = tgtwindow.document.getElementById("tabmail");
+ let srctabmail = srcwindow.document.getElementById("tabmail");
+
+ // If we are not moving the tab to a different window, and the window
+ // only has one tab, do nothing.
+ if (srcwindow == tgtwindow && srctabmail.tabInfo.length === 1) {
+ continue;
+ }
+
+ let insertionPoint =
+ indexMap.get(tgtwindow) || moveProperties.index;
+ // If the index is -1 it should go to the end of the tabs.
+ if (insertionPoint == -1) {
+ insertionPoint = tgttabmail.tabInfo.length;
+ }
+
+ let tabPosition = srctabmail.tabInfo.indexOf(nativeTabInfo);
+
+ // If this is not the first tab to be inserted into this window and
+ // the insertion point is the same as the last insertion and
+ // the tab is further to the right than the current insertion point
+ // then you need to bump up the insertion point. See bug 1323311.
+ if (
+ lastInsertion.has(tgtwindow) &&
+ lastInsertion.get(tgtwindow) === insertionPoint &&
+ tabPosition > insertionPoint
+ ) {
+ insertionPoint++;
+ indexMap.set(tgtwindow, insertionPoint);
+ }
+
+ if (srcwindow == tgtwindow) {
+ // If the window we are moving is the same, just move the tab.
+ tgttabmail.moveTabTo(nativeTabInfo, insertionPoint);
+ } else {
+ // If the window we are moving the tab in is different, then move the tab
+ // to the new window.
+ srctabmail.replaceTabWithWindow(
+ nativeTabInfo,
+ tgtwindow,
+ insertionPoint
+ );
+ nativeTabInfo =
+ tgttabmail.tabInfo[insertionPoint] ||
+ tgttabmail.tabInfo[tgttabmail.tabInfo.length - 1];
+ }
+ lastInsertion.set(tgtwindow, tabPosition);
+ tabsMoved.push(nativeTabInfo);
+ }
+
+ return tabsMoved.map(nativeTabInfo =>
+ tabManager.convert(nativeTabInfo)
+ );
+ },
+
+ duplicate(tabId) {
+ let nativeTabInfo = tabTracker.getTab(tabId);
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ throw new ExtensionError(
+ "tabs.duplicate is not applicable to this tab."
+ );
+ }
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+
+ // This is our best approximation of duplicating tabs. It might produce unreliable results
+ let state = tabmail.persistTab(nativeTabInfo);
+ let mode = tabmail.tabModes[state.mode];
+ state.state.duplicate = true;
+
+ if (mode.tabs.length && mode.tabs.length == mode.maxTabs) {
+ throw new ExtensionError(
+ `Maximum number of ${state.mode} tabs reached.`
+ );
+ } else {
+ tabmail.restoreTab(state);
+ return tabManager.convert(mode.tabs[mode.tabs.length - 1]);
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-theme.js b/comm/mail/components/extensions/parent/ext-theme.js
new file mode 100644
index 0000000000..1de3501e84
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-theme.js
@@ -0,0 +1,543 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global windowTracker, EventManager, EventEmitter */
+
+/* eslint-disable complexity */
+
+ChromeUtils.defineESModuleGetters(this, {
+ LightweightThemeManager:
+ "resource://gre/modules/LightweightThemeManager.sys.mjs",
+});
+
+const onUpdatedEmitter = new EventEmitter();
+
+// Represents an empty theme for convenience of use
+const emptyTheme = {
+ details: { colors: null, images: null, properties: null },
+};
+
+let defaultTheme = emptyTheme;
+// Map[windowId -> Theme instance]
+let windowOverrides = new Map();
+
+/**
+ * Class representing either a global theme affecting all windows or an override on a specific window.
+ * Any extension updating the theme with a new global theme will replace the singleton defaultTheme.
+ */
+class Theme {
+ /**
+ * Creates a theme instance.
+ *
+ * @param {string} extension - Extension that created the theme.
+ * @param {Integer} windowId - The windowId where the theme is applied.
+ */
+ constructor({
+ extension,
+ details,
+ darkDetails,
+ windowId,
+ experiment,
+ startupData,
+ }) {
+ this.extension = extension;
+ this.details = details;
+ this.darkDetails = darkDetails;
+ this.windowId = windowId;
+
+ if (startupData && startupData.lwtData) {
+ Object.assign(this, startupData);
+ } else {
+ // TODO: Update this part after bug 1550090.
+ this.lwtStyles = {};
+ this.lwtDarkStyles = null;
+ if (darkDetails) {
+ this.lwtDarkStyles = {};
+ }
+
+ if (experiment) {
+ if (extension.canUseThemeExperiment()) {
+ this.lwtStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ if (this.lwtDarkStyles) {
+ this.lwtDarkStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ }
+
+ if (experiment.stylesheet) {
+ experiment.stylesheet = this.getFileUrl(experiment.stylesheet);
+ }
+ this.experiment = experiment;
+ } else {
+ const { logger } = this.extension;
+ logger.warn("This extension is not allowed to run theme experiments");
+ return;
+ }
+ }
+ }
+ this.load();
+ }
+
+ // The manifest has moz-extension:// urls. Switch to file:// urls to get around
+ // the skin limitation for moz-extension:// urls.
+ getFileUrl(url) {
+ if (url.startsWith("moz-extension://")) {
+ url = url.split("/").slice(3).join("/");
+ }
+ return this.extension.rootURI.resolve(url);
+ }
+
+ /**
+ * Loads a theme by reading the properties from the extension's manifest.
+ * This method will override any currently applied theme.
+ */
+ load() {
+ if (!this.lwtData) {
+ this.loadDetails(this.details, this.lwtStyles);
+ if (this.darkDetails) {
+ this.loadDetails(this.darkDetails, this.lwtDarkStyles);
+ }
+
+ this.lwtData = {
+ theme: this.lwtStyles,
+ darkTheme: this.lwtDarkStyles,
+ };
+
+ if (this.experiment) {
+ this.lwtData.experiment = this.experiment;
+ }
+
+ this.extension.startupData = {
+ lwtData: this.lwtData,
+ lwtStyles: this.lwtStyles,
+ lwtDarkStyles: this.lwtDarkStyles,
+ experiment: this.experiment,
+ };
+ this.extension.saveStartupData();
+ }
+
+ if (this.windowId) {
+ this.lwtData.window = windowTracker.getWindow(
+ this.windowId
+ ).docShell.outerWindowID;
+ windowOverrides.set(this.windowId, this);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = this;
+ LightweightThemeManager.fallbackThemeData = this.lwtData;
+ }
+ onUpdatedEmitter.emit("theme-updated", this.details, this.windowId);
+
+ Services.obs.notifyObservers(
+ this.lwtData,
+ "lightweight-theme-styling-update"
+ );
+ }
+
+ /**
+ * @param {object} details - Details
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadDetails(details, styles) {
+ if (details.colors) {
+ this.loadColors(details.colors, styles);
+ }
+
+ if (details.images) {
+ this.loadImages(details.images, styles);
+ }
+
+ if (details.properties) {
+ this.loadProperties(details.properties, styles);
+ }
+
+ this.loadMetadata(this.extension, styles);
+ }
+
+ /**
+ * Helper method for loading colors found in the extension's manifest.
+ *
+ * @param {object} colors - Dictionary mapping color properties to values.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadColors(colors, styles) {
+ for (let color of Object.keys(colors)) {
+ let val = colors[color];
+
+ if (!val) {
+ continue;
+ }
+
+ let cssColor = val;
+ if (Array.isArray(val)) {
+ cssColor =
+ "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")";
+ }
+
+ switch (color) {
+ case "frame":
+ styles.accentcolor = cssColor;
+ break;
+ case "frame_inactive":
+ styles.accentcolorInactive = cssColor;
+ break;
+ case "tab_background_text":
+ styles.textcolor = cssColor;
+ break;
+ case "toolbar":
+ styles.toolbarColor = cssColor;
+ break;
+ case "toolbar_text":
+ case "bookmark_text":
+ styles.toolbar_text = cssColor;
+ break;
+ case "icons":
+ styles.icon_color = cssColor;
+ break;
+ case "icons_attention":
+ styles.icon_attention_color = cssColor;
+ break;
+ case "tab_background_separator":
+ case "tab_loading":
+ case "tab_text":
+ case "tab_line":
+ case "tab_selected":
+ case "toolbar_field":
+ case "toolbar_field_text":
+ case "toolbar_field_border":
+ case "toolbar_field_focus":
+ case "toolbar_field_text_focus":
+ case "toolbar_field_border_focus":
+ case "toolbar_top_separator":
+ case "toolbar_bottom_separator":
+ case "toolbar_vertical_separator":
+ case "button_background_hover":
+ case "button_background_active":
+ case "popup":
+ case "popup_text":
+ case "popup_border":
+ case "popup_highlight":
+ case "popup_highlight_text":
+ case "ntp_background":
+ case "ntp_text":
+ case "sidebar":
+ case "sidebar_border":
+ case "sidebar_text":
+ case "sidebar_highlight":
+ case "sidebar_highlight_text":
+ case "sidebar_highlight_border":
+ case "toolbar_field_highlight":
+ case "toolbar_field_highlight_text":
+ styles[color] = cssColor;
+ break;
+ default:
+ if (
+ this.experiment &&
+ this.experiment.colors &&
+ color in this.experiment.colors
+ ) {
+ styles.experimental.colors[color] = cssColor;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(`Unrecognized theme property found: colors.${color}`);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading images found in the extension's manifest.
+ *
+ * @param {object} images - Dictionary mapping image properties to values.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadImages(images, styles) {
+ const { logger } = this.extension;
+
+ for (let image of Object.keys(images)) {
+ let val = images[image];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (image) {
+ case "additional_backgrounds": {
+ let backgroundImages = val.map(img => this.getFileUrl(img));
+ styles.additionalBackgrounds = backgroundImages;
+ break;
+ }
+ case "theme_frame": {
+ let resolvedURL = this.getFileUrl(val);
+ styles.headerURL = resolvedURL;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.images &&
+ image in this.experiment.images
+ ) {
+ styles.experimental.images[image] = this.getFileUrl(val);
+ } else {
+ logger.warn(`Unrecognized theme property found: images.${image}`);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for preparing properties found in the extension's manifest.
+ * Properties are commonly used to specify more advanced behavior of colors,
+ * images or icons.
+ *
+ * @param {object} - properties Dictionary mapping properties to values.
+ * @param {object} - styles Styles object in which to store the colors.
+ */
+ loadProperties(properties, styles) {
+ let additionalBackgroundsCount =
+ (styles.additionalBackgrounds && styles.additionalBackgrounds.length) ||
+ 0;
+ const assertValidAdditionalBackgrounds = (property, valueCount) => {
+ const { logger } = this.extension;
+ if (!additionalBackgroundsCount) {
+ logger.warn(
+ `The '${property}' property takes effect only when one ` +
+ `or more additional background images are specified using the 'additional_backgrounds' property.`
+ );
+ return false;
+ }
+ if (additionalBackgroundsCount !== valueCount) {
+ logger.warn(
+ `The amount of values specified for '${property}' ` +
+ `(${valueCount}) is not equal to the amount of additional background ` +
+ `images (${additionalBackgroundsCount}), which may lead to unexpected results.`
+ );
+ }
+ return true;
+ };
+
+ for (let property of Object.getOwnPropertyNames(properties)) {
+ let val = properties[property];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (property) {
+ case "additional_backgrounds_alignment": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ styles.backgroundsAlignment = val.join(",");
+ break;
+ }
+ case "additional_backgrounds_tiling": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ let tiling = [];
+ for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) {
+ tiling.push(val[i] || "no-repeat");
+ }
+ styles.backgroundsTiling = tiling.join(",");
+ break;
+ }
+ case "color_scheme":
+ case "content_color_scheme": {
+ styles[property] = val;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.properties &&
+ property in this.experiment.properties
+ ) {
+ styles.experimental.properties[property] = val;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(
+ `Unrecognized theme property found: properties.${property}`
+ );
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading extension metadata required by downstream
+ * consumers.
+ *
+ * @param {object} extension - Extension object.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadMetadata(extension, styles) {
+ styles.id = extension.id;
+ styles.version = extension.version;
+ }
+
+ static unload(windowId) {
+ let lwtData = {
+ theme: null,
+ };
+
+ if (windowId) {
+ lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID;
+ windowOverrides.delete(windowId);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = emptyTheme;
+ LightweightThemeManager.fallbackThemeData = null;
+ }
+ onUpdatedEmitter.emit("theme-updated", {}, windowId);
+
+ Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update");
+ }
+}
+
+this.theme = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onUpdated({ fire, context }) {
+ let callback = async (event, theme, windowId) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ if (windowId) {
+ // Force access validation for incognito mode by getting the window.
+ if (windowTracker.getWindow(windowId, context, false)) {
+ fire.async({ theme, windowId });
+ }
+ } else {
+ fire.async({ theme });
+ }
+ };
+
+ onUpdatedEmitter.on("theme-updated", callback);
+ return {
+ unregister() {
+ onUpdatedEmitter.off("theme-updated", callback);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ defaultTheme = new Theme({
+ extension,
+ details: manifest.theme,
+ darkDetails: manifest.dark_theme,
+ experiment: manifest.theme_experiment,
+ startupData: extension.startupData,
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let { extension } = this;
+ for (let [windowId, theme] of windowOverrides) {
+ if (theme.extension === extension) {
+ Theme.unload(windowId);
+ }
+ }
+
+ if (defaultTheme.extension === extension) {
+ Theme.unload();
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ theme: {
+ getCurrent: windowId => {
+ // Take last focused window when no ID is supplied.
+ if (!windowId) {
+ windowId = windowTracker.getId(windowTracker.topWindow);
+ }
+ // Force access validation for incognito mode by getting the window.
+ if (!windowTracker.getWindow(windowId, context)) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ if (windowOverrides.has(windowId)) {
+ return Promise.resolve(windowOverrides.get(windowId).details);
+ }
+ return Promise.resolve(defaultTheme.details);
+ },
+ update: (windowId, details) => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+ }
+
+ new Theme({
+ extension,
+ details,
+ windowId,
+ experiment: this.extension.manifest.theme_experiment,
+ });
+
+ return Promise.resolve();
+ },
+ reset: windowId => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ let theme = windowOverrides.get(windowId) || defaultTheme;
+ if (theme.extension !== extension) {
+ return Promise.resolve();
+ }
+ } else if (defaultTheme.extension !== extension) {
+ return Promise.resolve();
+ }
+
+ Theme.unload(windowId);
+ return Promise.resolve();
+ },
+ onUpdated: new EventManager({
+ context,
+ module: "theme",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-windows.js b/comm/mail/components/extensions/parent/ext-windows.js
new file mode 100644
index 0000000000..6a3078d7d3
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-windows.js
@@ -0,0 +1,555 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// The ext-* files are imported into the same scopes.
+/* import-globals-from ext-mail.js */
+
+function sanitizePositionParams(params, window = null, positionOffset = 0) {
+ if (params.left === null && params.top === null) {
+ return;
+ }
+
+ if (params.left === null) {
+ const baseLeft = window ? window.screenX : 0;
+ params.left = baseLeft + positionOffset;
+ }
+ if (params.top === null) {
+ const baseTop = window ? window.screenY : 0;
+ params.top = baseTop + positionOffset;
+ }
+
+ // boundary check: don't put window out of visible area
+ const baseWidth = window ? window.outerWidth : 0;
+ const baseHeight = window ? window.outerHeight : 0;
+ // Secure minimum size of an window should be same to the one
+ // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight.
+ const minWidth = 100;
+ const minHeight = 100;
+ const width = Math.max(
+ minWidth,
+ params.width !== null ? params.width : baseWidth
+ );
+ const height = Math.max(
+ minHeight,
+ params.height !== null ? params.height : baseHeight
+ );
+ const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ const screen = screenManager.screenForRect(
+ params.left,
+ params.top,
+ width,
+ height
+ );
+ const availDeviceLeft = {};
+ const availDeviceTop = {};
+ const availDeviceWidth = {};
+ const availDeviceHeight = {};
+ screen.GetAvailRect(
+ availDeviceLeft,
+ availDeviceTop,
+ availDeviceWidth,
+ availDeviceHeight
+ );
+ const factor = screen.defaultCSSScaleFactor;
+ const availLeft = Math.floor(availDeviceLeft.value / factor);
+ const availTop = Math.floor(availDeviceTop.value / factor);
+ const availWidth = Math.floor(availDeviceWidth.value / factor);
+ const availHeight = Math.floor(availDeviceHeight.value / factor);
+ params.left = Math.min(
+ availLeft + availWidth - width,
+ Math.max(availLeft, params.left)
+ );
+ params.top = Math.min(
+ availTop + availHeight - height,
+ Math.max(availTop, params.top)
+ );
+}
+
+/**
+ * Update the geometry of the mail window.
+ *
+ * @param {object} options
+ * An object containing new values for the window's geometry.
+ * @param {integer} [options.left]
+ * The new pixel distance of the left side of the mail window from
+ * the left of the screen.
+ * @param {integer} [options.top]
+ * The new pixel distance of the top side of the mail window from
+ * the top of the screen.
+ * @param {integer} [options.width]
+ * The new pixel width of the window.
+ * @param {integer} [options.height]
+ * The new pixel height of the window.
+ */
+function updateGeometry(window, options) {
+ if (options.left !== null || options.top !== null) {
+ let left = options.left === null ? window.screenX : options.left;
+ let top = options.top === null ? window.screenY : options.top;
+ window.moveTo(left, top);
+ }
+
+ if (options.width !== null || options.height !== null) {
+ let width = options.width === null ? window.outerWidth : options.width;
+ let height = options.height === null ? window.outerHeight : options.height;
+ window.resizeTo(width, height);
+ }
+}
+
+this.windows = class extends ExtensionAPIPersistent {
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ for (let window of Services.wm.getEnumerator("mail:extensionPopup")) {
+ let uri = window.browser.browsingContext.currentURI;
+ if (uri.scheme == "moz-extension" && uri.host == this.extension.uuid) {
+ window.close();
+ }
+ }
+ }
+
+ windowEventRegistrar({ windowEvent, listener }) {
+ let { extension } = this;
+ return ({ context, fire }) => {
+ let listener2 = async (window, ...args) => {
+ if (!extension.canAccessWindow(window)) {
+ return;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ listener({ context, fire, window }, ...args);
+ };
+ windowTracker.addListener(windowEvent, listener2);
+ return {
+ unregister() {
+ windowTracker.removeListener(windowEvent, listener2);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called) (handled by windowEventRegistrar).
+
+ onCreated: this.windowEventRegistrar({
+ windowEvent: "domwindowopened",
+ listener: async ({ context, fire, window }) => {
+ // Return the window only after it has been fully initialized.
+ if (window.webExtensionWindowCreatePending) {
+ await new Promise(resolve => {
+ window.addEventListener("webExtensionWindowCreateDone", resolve, {
+ once: true,
+ });
+ });
+ }
+ fire.async(this.extension.windowManager.convert(window));
+ },
+ }),
+
+ onRemoved: this.windowEventRegistrar({
+ windowEvent: "domwindowclosed",
+ listener: ({ context, fire, window }) => {
+ fire.async(windowTracker.getId(window));
+ },
+ }),
+
+ onFocusChanged({ context, fire }) {
+ let { extension } = this;
+ // Keep track of the last windowId used to fire an onFocusChanged event
+ let lastOnFocusChangedWindowId;
+ let scheduledEvents = [];
+
+ let listener = async event => {
+ // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE
+ // event when switching focus between two Thunderbird windows.
+ // Note: This is not working for Linux, where we still get the -1
+ await Promise.resolve();
+
+ let windowId = WindowBase.WINDOW_ID_NONE;
+ let window = Services.focus.activeWindow;
+ if (window) {
+ if (!extension.canAccessWindow(window)) {
+ return;
+ }
+ windowId = windowTracker.getId(window);
+ }
+
+ // Using a FIFO to keep order of events, in case the last one
+ // gets through without being placed on the async callback stack.
+ scheduledEvents.push(windowId);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let scheduledWindowId = scheduledEvents.shift();
+
+ if (scheduledWindowId !== lastOnFocusChangedWindowId) {
+ lastOnFocusChangedWindowId = scheduledWindowId;
+ fire.async(scheduledWindowId);
+ }
+ };
+ windowTracker.addListener("focus", listener);
+ windowTracker.addListener("blur", listener);
+ return {
+ unregister() {
+ windowTracker.removeListener("focus", listener);
+ windowTracker.removeListener("blur", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = context;
+ const { windowManager } = extension;
+
+ return {
+ windows: {
+ onCreated: new EventManager({
+ context,
+ module: "windows",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "windows",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onFocusChanged: new EventManager({
+ context,
+ module: "windows",
+ event: "onFocusChanged",
+ extensionApi: this,
+ }).api(),
+
+ get(windowId, getInfo) {
+ let window = windowTracker.getWindow(windowId, context);
+ if (!window) {
+ return Promise.reject({
+ message: `Invalid window ID: ${windowId}`,
+ });
+ }
+ return Promise.resolve(windowManager.convert(window, getInfo));
+ },
+
+ async getCurrent(getInfo) {
+ let window = context.currentWindow || windowTracker.topWindow;
+ if (window.document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ return windowManager.convert(window, getInfo);
+ },
+
+ async getLastFocused(getInfo) {
+ let window = windowTracker.topWindow;
+ if (window.document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ return windowManager.convert(window, getInfo);
+ },
+
+ getAll(getInfo) {
+ let doNotCheckTypes = !getInfo || !getInfo.windowTypes;
+
+ let windows = Array.from(windowManager.getAll(), win =>
+ win.convert(getInfo)
+ ).filter(
+ win => doNotCheckTypes || getInfo.windowTypes.includes(win.type)
+ );
+ return Promise.resolve(windows);
+ },
+
+ async create(createData) {
+ if (createData.incognito) {
+ throw new ExtensionError("`incognito` is not supported");
+ }
+
+ let needResize =
+ createData.left !== null ||
+ createData.top !== null ||
+ createData.width !== null ||
+ createData.height !== null;
+ if (needResize) {
+ if (createData.state !== null && createData.state != "normal") {
+ throw new ExtensionError(
+ `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+ createData.state = "normal";
+ }
+
+ // 10px offset is same to Chromium
+ sanitizePositionParams(createData, windowTracker.topNormalWindow, 10);
+
+ let userContextId =
+ Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ if (createData.cookieStoreId) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createData.cookieStoreId
+ );
+ }
+ let createWindowArgs = createData => {
+ let allowScriptsToClose = !!createData.allowScriptsToClose;
+ let url = createData.url || "about:blank";
+ let urls = Array.isArray(url) ? url : [url];
+
+ let args = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ let actionData = {
+ action: "open",
+ allowScriptsToClose,
+ tabs: urls.map(url => ({
+ tabType: "contentTab",
+ tabParams: { url, userContextId },
+ })),
+ };
+ actionData.wrappedJSObject = actionData;
+ args.appendElement(null);
+ args.appendElement(actionData);
+ return args;
+ };
+
+ let window;
+ let wantNormalWindow =
+ createData.type === null || createData.type == "normal";
+ let features = ["chrome"];
+ if (wantNormalWindow) {
+ features.push("dialog=no", "all", "status", "toolbar");
+ } else {
+ // All other types create "popup"-type windows by default.
+ // Use dialog=no to get minimize and maximize buttons (as chrome
+ // does) and to allow the API to actually maximize the popup in
+ // Linux.
+ features.push(
+ "dialog=no",
+ "resizable",
+ "minimizable",
+ "titlebar",
+ "close"
+ );
+ if (createData.left === null && createData.top === null) {
+ features.push("centerscreen");
+ }
+ }
+
+ let windowURL = wantNormalWindow
+ ? "chrome://messenger/content/messenger.xhtml"
+ : "chrome://messenger/content/extensionPopup.xhtml";
+ if (createData.tabId) {
+ if (createData.url) {
+ return Promise.reject({
+ message: "`tabId` may not be used in conjunction with `url`",
+ });
+ }
+
+ if (createData.allowScriptsToClose) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `allowScriptsToClose`",
+ });
+ }
+
+ if (createData.cookieStoreId) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `cookieStoreId`",
+ });
+ }
+
+ let nativeTabInfo = tabTracker.getTab(createData.tabId);
+ let tabmail =
+ getTabBrowser(nativeTabInfo).ownerDocument.getElementById(
+ "tabmail"
+ );
+ let targetType = wantNormalWindow ? null : "popup";
+ window = tabmail.replaceTabWithWindow(nativeTabInfo, targetType)[0];
+ } else {
+ window = Services.ww.openWindow(
+ null,
+ windowURL,
+ "_blank",
+ features.join(","),
+ wantNormalWindow ? null : createWindowArgs(createData)
+ );
+ }
+
+ window.webExtensionWindowCreatePending = true;
+
+ updateGeometry(window, createData);
+
+ // TODO: focused, type
+
+ // Wait till the newly created window is focused. On Linux the initial
+ // "normal" state has been set once the window has been fully focused.
+ // Setting a different state before the window is fully focused may cause
+ // the initial state to be erroneously applied after the custom state has
+ // been set.
+ let focusPromise = new Promise(resolve => {
+ if (Services.focus.activeWindow == window) {
+ resolve();
+ } else {
+ window.addEventListener("focus", resolve, { once: true });
+ }
+ });
+
+ let loadPromise = new Promise(resolve => {
+ window.addEventListener("load", resolve, { once: true });
+ });
+
+ let titlePromise = new Promise(resolve => {
+ window.addEventListener("pagetitlechanged", resolve, {
+ once: true,
+ });
+ });
+
+ await Promise.all([focusPromise, loadPromise, titlePromise]);
+
+ let win = windowManager.getWrapper(window);
+
+ if (
+ [
+ "minimized",
+ "fullscreen",
+ "docked",
+ "normal",
+ "maximized",
+ ].includes(createData.state)
+ ) {
+ await win.setState(createData.state);
+ }
+
+ if (createData.titlePreface !== null) {
+ win.setTitlePreface(createData.titlePreface);
+ }
+
+ // Update the title independently of a createData.titlePreface, to get
+ // the title of the loaded document into the window title.
+ if (win instanceof TabmailWindow) {
+ win.window.document.getElementById("tabmail").setDocumentTitle();
+ } else if (win.window.gBrowser?.updateTitlebar) {
+ await win.window.gBrowser.updateTitlebar();
+ }
+
+ delete window.webExtensionWindowCreatePending;
+ window.dispatchEvent(
+ new window.CustomEvent("webExtensionWindowCreateDone")
+ );
+ return win.convert({ populate: true });
+ },
+
+ async update(windowId, updateInfo) {
+ let needResize =
+ updateInfo.left !== null ||
+ updateInfo.top !== null ||
+ updateInfo.width !== null ||
+ updateInfo.height !== null;
+ if (
+ updateInfo.state !== null &&
+ updateInfo.state != "normal" &&
+ needResize
+ ) {
+ throw new ExtensionError(
+ `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+
+ let win = windowManager.get(windowId, context);
+ if (!win) {
+ throw new ExtensionError(`Invalid window ID: ${windowId}`);
+ }
+
+ // Update the window only after it has been fully initialized.
+ if (win.window.webExtensionWindowCreatePending) {
+ await new Promise(resolve => {
+ win.window.addEventListener(
+ "webExtensionWindowCreateDone",
+ resolve,
+ { once: true }
+ );
+ });
+ }
+
+ if (updateInfo.focused) {
+ win.window.focus();
+ }
+
+ if (updateInfo.state !== null) {
+ await win.setState(updateInfo.state);
+ }
+
+ if (updateInfo.drawAttention) {
+ // Bug 1257497 - Firefox can't cancel attention actions.
+ win.window.getAttention();
+ }
+
+ updateGeometry(win.window, updateInfo);
+
+ if (updateInfo.titlePreface !== null) {
+ win.setTitlePreface(updateInfo.titlePreface);
+ if (win instanceof TabmailWindow) {
+ win.window.document.getElementById("tabmail").setDocumentTitle();
+ } else if (win.window.gBrowser?.updateTitlebar) {
+ await win.window.gBrowser.updateTitlebar();
+ }
+ }
+
+ // TODO: All the other properties, focused=false...
+
+ return win.convert();
+ },
+
+ remove(windowId) {
+ let window = windowTracker.getWindow(windowId, context);
+ window.close();
+
+ return new Promise(resolve => {
+ let listener = () => {
+ windowTracker.removeListener("domwindowclosed", listener);
+ resolve();
+ };
+ windowTracker.addListener("domwindowclosed", listener);
+ });
+ },
+ openDefaultBrowser(url) {
+ let uri = null;
+ try {
+ uri = Services.io.newURI(url);
+ } catch (e) {
+ throw new ExtensionError(`Url "${url}" seems to be malformed.`);
+ }
+ if (!uri.schemeIs("http") && !uri.schemeIs("https")) {
+ throw new ExtensionError(
+ `Url scheme "${uri.scheme}" is not supported.`
+ );
+ }
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/processScript.js b/comm/mail/components/extensions/processScript.js
new file mode 100644
index 0000000000..4b71e651a8
--- /dev/null
+++ b/comm/mail/components/extensions/processScript.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Inject the |messenger| object as an alias to |browser| in all known contexts.
+// This script is injected into all processes.
+
+// This is a bit fragile since it uses monkeypatching. If a test fails, the best
+// way to debug is to search for Schemas.exportLazyGetter where it does the
+// injections, add |messenger| alias to those files until the test passes again,
+// and then find out why the monkeypatching is not catching it.
+
+const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+);
+const { ExtensionPageChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPageChild.sys.mjs"
+);
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+let getContext = ExtensionContent.getContext;
+let initExtensionContext = ExtensionContent.initExtensionContext;
+let initPageChildExtensionContext = ExtensionPageChild.initExtensionContext;
+
+// This patches constructor of ContentScriptContextChild adding the object to
+// the sandbox.
+ExtensionContent.getContext = function (extension, window) {
+ let context = getContext.apply(ExtensionContent, arguments);
+ if (!("messenger" in context.sandbox)) {
+ Schemas.exportLazyGetter(
+ context.sandbox,
+ "messenger",
+ () => context.chromeObj
+ );
+ }
+ return context;
+};
+
+// This patches extension content within unprivileged pages, so an iframe on a
+// web page that points to a moz-extension:// page exposed via
+// web_accessible_content.
+ExtensionContent.initExtensionContext = function (extension, window) {
+ let context = extension.getContext(window);
+ Schemas.exportLazyGetter(window, "messenger", () => context.chromeObj);
+
+ return initExtensionContext.apply(ExtensionContent, arguments);
+};
+
+// This patches privileged pages such as the background script.
+ExtensionPageChild.initExtensionContext = function (extension, window) {
+ let retval = initPageChildExtensionContext.apply(
+ ExtensionPageChild,
+ arguments
+ );
+
+ let windowId = ExtensionUtils.getInnerWindowID(window);
+ let context = ExtensionPageChild.extensionContexts.get(windowId);
+
+ Schemas.exportLazyGetter(window, "messenger", () => {
+ let messengerObj = Cu.createObjectIn(window);
+ context.childManager.inject(messengerObj);
+ return messengerObj;
+ });
+
+ return retval;
+};
diff --git a/comm/mail/components/extensions/schemas/LICENSE b/comm/mail/components/extensions/schemas/LICENSE
new file mode 100644
index 0000000000..9314092fdc
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/LICENSE
@@ -0,0 +1,27 @@
+// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following disclaimer
+// in the documentation and/or other materials provided with the
+// distribution.
+// * Neither the name of Google Inc. nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/comm/mail/components/extensions/schemas/accounts.json b/comm/mail/components/extensions/schemas/accounts.json
new file mode 100644
index 0000000000..fb325425b2
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/accounts.json
@@ -0,0 +1,235 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["accountsRead"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "accounts",
+ "permissions": ["accountsRead"],
+ "types": [
+ {
+ "id": "MailAccount",
+ "description": "An object describing a mail account, as returned for example by the :ref:`accounts.list` and :ref:`accounts.get` methods. The ``folders`` property is only included if requested.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for this account."
+ },
+ "name": {
+ "type": "string",
+ "description": "The human-friendly name of this account."
+ },
+ "type": {
+ "type": "string",
+ "description": "What sort of account this is, e.g. <value>imap</value>, <value>nntp</value>, or <value>pop3</value>."
+ },
+ "folders": {
+ "type": "array",
+ "optional": true,
+ "description": "The folders for this account are only included if requested.",
+ "items": {
+ "$ref": "folders.MailFolder"
+ }
+ },
+ "identities": {
+ "type": "array",
+ "description": "The identities associated with this account. The default identity is listed first, others in no particular order.",
+ "items": {
+ "$ref": "identities.MailIdentity"
+ }
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "description": "Returns all mail accounts. They will be returned in the same order as used in Thunderbird's folder pane.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "includeFolders",
+ "description": "Specifies whether the returned :ref:`accounts.MailAccount` objects should included their account's folders. Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "accounts.MailAccount"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Returns details of the requested account, or <value>null</value> if it doesn't exist.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "includeFolders",
+ "description": "Specifies whether the returned :ref:`accounts.MailAccount` object should included the account's folders. Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "accounts.MailAccount",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getDefault",
+ "type": "function",
+ "description": "Returns the default account, or <value>null</value> if it is not defined.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "includeFolders",
+ "description": "Specifies whether the returned :ref:`accounts.MailAccount` object should included the account's folders. Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "accounts.MailAccount",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setDefaultIdentity",
+ "type": "function",
+ "description": "Sets the default identity for an account.",
+ "async": true,
+ "deprecated": "This will be removed. Use :ref:`identities.setDefault` instead.",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "getDefaultIdentity",
+ "type": "function",
+ "description": "Returns the default identity for an account, or <value>null</value> if it is not defined.",
+ "async": "callback",
+ "deprecated": "This will be removed. Use :ref:`identities.getDefault` instead.",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a new account has been created.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "account",
+ "$ref": "MailAccount"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when an account has been removed.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a property of an account has been modified. Folders and identities of accounts are not monitored by this event, use the dedicated folder and identity events instead. A changed ``defaultIdentity`` is reported only after a different identity has been assigned as default identity, but not after a property of the default identity has been changed.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "changedValues",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The human-friendly name of this account."
+ },
+ "defaultIdentity": {
+ "$ref": "identities.MailIdentity",
+ "description": "The default identity of this account."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/addressBook.json b/comm/mail/components/extensions/schemas/addressBook.json
new file mode 100644
index 0000000000..40b0b477fc
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/addressBook.json
@@ -0,0 +1,977 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["addressBooks", "sensitiveDataUpload"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "addressBooks",
+ "permissions": ["addressBooks"],
+ "types": [
+ {
+ "id": "NodeType",
+ "type": "string",
+ "enum": ["addressBook", "contact", "mailingList"],
+ "description": "Indicates the type of a Node."
+ },
+ {
+ "id": "AddressBookNode",
+ "type": "object",
+ "description": "A node representing an address book.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+ },
+ "parentId": {
+ "type": "string",
+ "optional": true,
+ "description": "The ``id`` of the parent object."
+ },
+ "type": {
+ "$ref": "NodeType",
+ "description": "Always set to <value>addressBook</value>."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object is read-only."
+ },
+ "remote": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the address book is accessed via remote look-up."
+ },
+ "name": {
+ "type": "string"
+ },
+ "contacts": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "contacts.ContactNode"
+ },
+ "description": "A list of contacts held by this node's address book or mailing list."
+ },
+ "mailingLists": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "mailingLists.MailingListNode"
+ },
+ "description": "A list of mailingLists in this node's address book."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "openUI",
+ "type": "function",
+ "async": "callback",
+ "description": "Opens the address book user interface.",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "closeUI",
+ "type": "function",
+ "async": true,
+ "description": "Closes the address book user interface.",
+ "parameters": []
+ },
+ {
+ "name": "list",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "complete",
+ "type": "boolean",
+ "optional": true,
+ "default": false,
+ "description": "If set to true, results will include contacts and mailing lists for each address book."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "AddressBookNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets a list of the user's address books, optionally including all contacts and mailing lists."
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "complete",
+ "type": "boolean",
+ "optional": true,
+ "default": false,
+ "description": "If set to true, results will include contacts and mailing lists for this address book."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "AddressBookNode"
+ }
+ ]
+ }
+ ],
+ "description": "Gets a single address book, optionally including all contacts and mailing lists."
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The id of the new address book."
+ }
+ ]
+ }
+ ],
+ "description": "Creates a new, empty address book."
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "description": "Renames an address book."
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ],
+ "description": "Removes an address book, and all associated contacts and mailing lists."
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when an address book is created.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "AddressBookNode"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when an address book is renamed.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "AddressBookNode"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when an addressBook is deleted.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "addressBooks.provider",
+ "permissions": ["addressBooks"],
+ "events": [
+ {
+ "name": "onSearchRequest",
+ "type": "function",
+ "description": "Registering this listener will create and list a read-only address book in Thunderbird's address book window, similar to LDAP address books. When selecting this address book, users will first see no contacts, but they can search for them, which will fire this event. Contacts returned by the listener callback will be displayed as contact cards in the address book. Several listeners can be registered, to create multiple address books.\n\nThe event also fires for each registered listener (for each created read-only address book), when users type something into the mail composer's <em>To:</em> field, or into similar fields like the calendar meeting attendees field. Contacts returned by the listener callback will be added to the autocomplete results in the dropdown of that field.\n\nExample: <literalinclude>includes/addressBooks/onSearchRequest.js<lang>JavaScript</lang></literalinclude>",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "AddressBookNode"
+ },
+ {
+ "name": "searchString",
+ "description": "The search text that the user entered. Not available when invoked from the advanced address book search dialog.",
+ "type": "string",
+ "optional": true
+ },
+ {
+ "name": "query",
+ "type": "string",
+ "description": "The boolean query expression corresponding to the search. **Note:** This parameter may change in future releases of Thunderbird.",
+ "optional": true
+ }
+ ],
+ "extraParameters": [
+ {
+ "name": "parameters",
+ "description": "Descriptions for the address book created by registering this listener.",
+ "type": "object",
+ "properties": {
+ "addressBookName": {
+ "type": "string",
+ "optional": true,
+ "description": "The name of the created address book."
+ },
+ "isSecure": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the address book search queries are using encrypted protocols like HTTPS."
+ },
+ "id": {
+ "type": "string",
+ "optional": true,
+ "description": "The unique ID of the created address book. If several listeners have been added, the ``id`` allows to identify which address book initiated the search request. If not provided, a unique ID will be generated for you."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "contacts",
+ "permissions": ["addressBooks"],
+ "types": [
+ {
+ "id": "QueryInfo",
+ "description": "Object defining a query for :ref:`contacts.quickSearch`.",
+ "type": "object",
+ "properties": {
+ "searchString": {
+ "type": "string",
+ "optional": true,
+ "description": "One or more space-separated terms to search for."
+ },
+ "includeLocal": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from local address books. Defaults to true."
+ },
+ "includeRemote": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from remote address books. Defaults to true."
+ },
+ "includeReadOnly": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from read-only address books. Defaults to true."
+ },
+ "includeReadWrite": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from read-write address books. Defaults to true."
+ }
+ }
+ },
+ {
+ "id": "ContactNode",
+ "type": "object",
+ "description": "A node representing a contact in an address book.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+ },
+ "parentId": {
+ "type": "string",
+ "optional": true,
+ "description": "The ``id`` of the parent object."
+ },
+ "type": {
+ "$ref": "addressBooks.NodeType",
+ "description": "Always set to <value>contact</value>."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object is read-only."
+ },
+ "remote": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object came from a remote address book."
+ },
+ "properties": {
+ "$ref": "ContactProperties"
+ }
+ }
+ },
+ {
+ "id": "ContactProperties",
+ "type": "object",
+ "description": "A set of individual properties for a particular contact, and its vCard string. Further information can be found in :ref:`howto_contacts`.",
+ "patternProperties": {
+ "^\\w+$": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "PropertyChange",
+ "type": "object",
+ "description": "A dictionary of changed properties. Keys are the property name that changed, values are an object containing ``oldValue`` and ``newValue``. Values can be either a string or <value>null</value>.",
+ "patternProperties": {
+ "^\\w+$": {
+ "type": "object",
+ "properties": {
+ "oldValue": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "newValue": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ContactNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all the contacts in the address book with the id ``parentId``."
+ },
+ {
+ "name": "quickSearch",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string",
+ "optional": true,
+ "description": "The id of the address book to search. If not specified, all address books are searched."
+ },
+ {
+ "name": "queryInfo",
+ "description": "Either a <em>string</em> with one or more space-separated terms to search for, or a complex :ref:`contacts.QueryInfo` search query.",
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "QueryInfo"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ContactNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all contacts matching ``queryInfo`` in the address book with the id ``parentId``."
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ContactNode"
+ }
+ ]
+ }
+ ],
+ "description": "Gets a single contact."
+ },
+ {
+ "name": "getPhoto",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ],
+ "description": "Gets the photo associated with this contact, if any."
+ },
+ {
+ "name": "setPhoto",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "file",
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ],
+ "description": "Sets the photo associated with this contact."
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "description": "Assigns the contact an id. If an existing contact has this id, an exception is thrown. **Note:** Deprecated, the card's id should be specified in the vCard string instead.",
+ "optional": true
+ },
+ {
+ "name": "properties",
+ "$ref": "ContactProperties",
+ "description": "The properties object for the new contact. If it includes a ``vCard`` member, all specified `legacy properties <|link-legacy-properties|>`__ are ignored and the new contact will be based on the provided vCard string. If a UID is specified in the vCard string, which is already used by another contact, an exception is thrown. **Note:** Using individual properties is deprecated, use the ``vCard`` member instead."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The ID of the new contact."
+ }
+ ]
+ }
+ ],
+ "description": "Adds a new contact to the address book with the id ``parentId``."
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "$ref": "ContactProperties",
+ "description": "An object with properties to update the specified contact. Individual properties are removed, if they are set to <value>null</value>. If the provided object includes a ``vCard`` member, all specified `legacy properties <|link-legacy-properties|>`__ are ignored and the details of the contact will be replaced by the provided vCard. Changes to the UID will be ignored. **Note:** Using individual properties is deprecated, use the ``vCard`` member instead. "
+ }
+ ],
+ "description": "Updates a contact."
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ],
+ "description": "Removes a contact from the address book. The contact is also removed from any mailing lists it is a member of."
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a contact is created.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "ContactNode"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a contact is changed.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "ContactNode"
+ },
+ {
+ "name": "changedProperties",
+ "$ref": "PropertyChange"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when a contact is removed from an address book.",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "mailingLists",
+ "permissions": ["addressBooks"],
+ "types": [
+ {
+ "id": "MailingListNode",
+ "type": "object",
+ "description": "A node representing a mailing list.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+ },
+ "parentId": {
+ "type": "string",
+ "optional": true,
+ "description": "The ``id`` of the parent object."
+ },
+ "type": {
+ "$ref": "addressBooks.NodeType",
+ "description": "Always set to <value>mailingList</value>."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object is read-only."
+ },
+ "remote": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object came from a remote address book."
+ },
+ "name": {
+ "type": "string"
+ },
+ "nickName": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "contacts": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "contacts.ContactNode"
+ },
+ "description": "A list of contacts held by this node's address book or mailing list."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MailingListNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all the mailing lists in the address book with id ``parentId``."
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MailingListNode"
+ }
+ ]
+ }
+ ],
+ "description": "Gets a single mailing list."
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "nickName": {
+ "type": "string",
+ "optional": true
+ },
+ "description": {
+ "type": "string",
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The ID of the new mailing list."
+ }
+ ]
+ }
+ ],
+ "description": "Creates a new mailing list in the address book with id ``parentId``."
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "nickName": {
+ "type": "string",
+ "optional": true
+ },
+ "description": {
+ "type": "string",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "description": "Edits the properties of a mailing list."
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ],
+ "description": "Removes the mailing list."
+ },
+ {
+ "name": "addMember",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "contactId",
+ "type": "string"
+ }
+ ],
+ "description": "Adds a contact to the mailing list with id ``id``. If the contact and mailing list are in different address books, the contact will also be copied to the list's address book."
+ },
+ {
+ "name": "listMembers",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "contacts.ContactNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all contacts that are members of the mailing list with id ``id``."
+ },
+ {
+ "name": "removeMember",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "contactId",
+ "type": "string"
+ }
+ ],
+ "description": "Removes a contact from the mailing list with id ``id``. This does not delete the contact from the address book."
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a mailing list is created.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "MailingListNode"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a mailing list is changed.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "MailingListNode"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when a mailing list is deleted.",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onMemberAdded",
+ "type": "function",
+ "description": "Fired when a contact is added to the mailing list.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "contacts.ContactNode"
+ }
+ ]
+ },
+ {
+ "name": "onMemberRemoved",
+ "type": "function",
+ "description": "Fired when a contact is removed from the mailing list.",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/browserAction.json b/comm/mail/components/extensions/schemas/browserAction.json
new file mode 100644
index 0000000000..ed1900f1e0
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/browserAction.json
@@ -0,0 +1,848 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "action": {
+ "min_manifest_version": 3,
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the action button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the action button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the action button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the action button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_windows": {
+ "description": "Defines the windows, the action button should appear in. Defaults to showing it only in the <value>normal</value> Thunderbird window, but can also be shown in the <value>messageDisplay</value> window.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["normal", "messageDisplay"]
+ },
+ "default": ["normal"],
+ "optional": true
+ },
+ "allowed_spaces": {
+ "description": "Defines for which spaces the action button will be added to Thunderbird's unified toolbar. Defaults to only allowing the action in the <value>mail</value> space. The <value>default</value> space is for tabs that don't belong to any space. If this is an empty array, the action button is shown in all spaces.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default"
+ ]
+ },
+ "default": ["mail"],
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "browser_action": {
+ "max_manifest_version": 2,
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the browserAction button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the browserAction button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the browserAction button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the browserAction button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_area": {
+ "description": "Defines the location the browserAction button will appear. Deprecated and ignored. Replaced by ``allowed_spaces``",
+ "type": "string",
+ "enum": ["maintoolbar", "tabstoolbar"],
+ "optional": true
+ },
+ "default_windows": {
+ "description": "Defines the windows, the browserAction button should appear in. Defaults to showing it only in the <value>normal</value> Thunderbird window, but can also be shown in the <value>messageDisplay</value> window.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["normal", "messageDisplay"]
+ },
+ "default": ["normal"],
+ "optional": true
+ },
+ "allowed_spaces": {
+ "description": "Defines for which spaces the browserAction button will be added to Thunderbird's unified toolbar. Defaults to only allowing the browserAction in the <value>mail</value> space. The <value>default</value> space is for tabs that don't belong to any space. If this is an empty array, the browserAction button is shown in all spaces.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default"
+ ]
+ },
+ "default": ["mail"],
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "action",
+ "description": "Use the action API to add a button to Thunderbird's unified toolbar. In addition to its icon, an action button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:action", "manifest:browser_action"],
+ "min_manifest_version": 3,
+ "types": [
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "ImageDataType",
+ "type": "object",
+ "isInstanceOf": "ImageData",
+ "additionalProperties": {
+ "type": "any"
+ },
+ "postprocess": "convertImageDataToURL",
+ "description": "Pixel data for an image. Must be an |ImageData| object (for example, from a |Canvas| element)."
+ },
+ {
+ "id": "ImageDataDictionary",
+ "type": "object",
+ "description": "A <em>dictionary object</em> to specify multiple |ImageData| objects in different sizes, so the icon does not have to be scaled for a device with a different pixel density. Each entry is a <em>name-value</em> pair with <em>value</em> being an |ImageData| object, and <em>name</em> its size. Example: <literalinclude>includes/ImageDataDictionary.json<lang>JavaScript</lang></literalinclude>See the `MDN documentation about choosing icon sizes <|link-mdn-icon-size|>`__ for more information on this.",
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "$ref": "ImageDataType"
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when an action button is clicked.",
+ "properties": {
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "setTitle",
+ "type": "function",
+ "description": "Sets the title of the action button. Is used as tooltip and as the label.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "title": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the action button should display as its label and when moused over. Cleared by setting it to <value>null</value> or an empty string (title defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the title only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getTitle",
+ "type": "function",
+ "description": "Gets the title of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the title should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setLabel",
+ "type": "function",
+ "description": "Sets the label of the action button. Can be used to set different values for the tooltip (defined by the title) and the label. Additionally, the label can be set to an empty string, not showing any label at all.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "label": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the action button should use as its label, overriding the defined title. Can be set to an empty string to not display any label at all. If the containing toolbar is configured to display text only, its title will be used. Cleared by setting it to <value>null</value>."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the label only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getLabel",
+ "type": "function",
+ "description": "Gets the label of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the label should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setIcon",
+ "type": "function",
+ "description": "Sets the icon for the action button. Either the ``path`` or the ``imageData`` property must be specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "imageData": {
+ "choices": [
+ {
+ "$ref": "ImageDataType"
+ },
+ {
+ "$ref": "ImageDataDictionary"
+ }
+ ],
+ "optional": true,
+ "description": "The image data for one or more icons for the action button."
+ },
+ "path": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "The paths to one or more icons for the action button."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the icon only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "setPopup",
+ "type": "function",
+ "description": "Sets the html document to be opened as a popup when the user clicks on the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "popup": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The html file to show in a popup. Can be set to an empty string to not open a popup. Cleared by setting it to <value>null</value> (popup value defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the popup only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getPopup",
+ "type": "function",
+ "description": "Gets the html document set as the popup for this action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the popup document should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeText",
+ "type": "function",
+ "description": "Sets the badge text for the action button. The badge is displayed on top of the icon.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "text": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Any number of characters can be passed, but only about four can fit in the space. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the badge text only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeText",
+ "type": "function",
+ "description": "Gets the badge text of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge text should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeBackgroundColor",
+ "type": "function",
+ "description": "Sets the background color for the badge.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "color": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The color to use as background in the badge. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the background color for the badge only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeBackgroundColor",
+ "type": "function",
+ "description": "Gets the badge background color of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge background color should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "$ref": "ColorArray"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "enable",
+ "type": "function",
+ "description": "Enables the action button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well. By default, an action button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the action button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "disable",
+ "type": "function",
+ "description": "Disables the action button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the action button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "isEnabled",
+ "type": "function",
+ "description": "Checks whether the action button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the state should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openPopup",
+ "type": "function",
+ "description": "Opens the action's popup window in the specified window. Defaults to the current window. Returns false if the popup could not be opened because the action has no popup, is of type <value>menu</value>, is disabled or has been removed from the toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "optional": true,
+ "type": "object",
+ "description": "An object with information about the popup to open.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when an action button is clicked. This event will not fire if the action has a popup. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "browserAction",
+ "description": "Use the browserAction API to add a button to Thunderbird's unified toolbar. In addition to its icon, a browserAction button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:action", "manifest:browser_action"],
+ "max_manifest_version": 2,
+ "$import": "action"
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/chrome_settings_overrides.json b/comm/mail/components/extensions/schemas/chrome_settings_overrides.json
new file mode 100644
index 0000000000..4fe67050f3
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/chrome_settings_overrides.json
@@ -0,0 +1,194 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "chrome_settings_overrides": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "search_provider": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "name": {
+ "type": "string",
+ "preprocess": "localize"
+ },
+ "keyword": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "string",
+ "preprocess": "localize"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "preprocess": "localize"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "search_url": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https://.*$",
+ "preprocess": "localize"
+ },
+ "favicon_url": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "preprocess": "localize"
+ },
+ "suggest_url": {
+ "type": "string",
+ "optional": true,
+ "pattern": "^https://.*$|^$",
+ "preprocess": "localize"
+ },
+ "instant_url": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "image_url": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "search_url_get_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "GET parameters to the search_url as a query string."
+ },
+ "search_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "POST parameters to the search_url as a query string."
+ },
+ "suggest_url_get_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "GET parameters to the suggest_url as a query string."
+ },
+ "suggest_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "POST parameters to the suggest_url as a query string."
+ },
+ "instant_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "image_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "search_form": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "pattern": "^https://.*$",
+ "preprocess": "localize"
+ },
+ "alternate_urls": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "url",
+ "preprocess": "localize"
+ },
+ "optional": true,
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "prepopulated_id": {
+ "type": "integer",
+ "optional": true,
+ "deprecated": "Unsupported on Thunderbird."
+ },
+ "encoding": {
+ "type": "string",
+ "optional": true,
+ "description": "Encoding of the search term."
+ },
+ "is_default": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Sets the default engine to a built-in engine only."
+ },
+ "params": {
+ "optional": true,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A url parameter name"
+ },
+ "condition": {
+ "type": "string",
+ "optional": true,
+ "enum": ["purpose", "pref"],
+ "description": "The type of param can be either \"purpose\" or \"pref\"."
+ },
+ "pref": {
+ "type": "string",
+ "optional": true,
+ "description": "The preference to retrieve the value from."
+ },
+ "purpose": {
+ "type": "string",
+ "optional": true,
+ "enum": [
+ "contextmenu",
+ "searchbar",
+ "homepage",
+ "keyword",
+ "newtab"
+ ],
+ "description": "The context that initiates a search, required if condition is \"purpose\"."
+ },
+ "value": {
+ "type": "string",
+ "optional": true,
+ "description": "A url parameter value.",
+ "preprocess": "localize"
+ }
+ }
+ },
+ "description": "A list of optional search url parameters. This allows the addition of search url parameters based on how the search is performed in Thunderbird."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/cloudFile.json b/comm/mail/components/extensions/schemas/cloudFile.json
new file mode 100644
index 0000000000..41c587881d
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/cloudFile.json
@@ -0,0 +1,501 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "cloud_file": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "browser_style": {
+ "type": "boolean",
+ "description": "Enable browser styles in the ``management_url`` page. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "optional": true,
+ "default": false
+ },
+ "data_format": {
+ "type": "string",
+ "optional": true,
+ "deprecated": true,
+ "description": "This property is no longer used. The only supported data format for the ``data`` argument in :ref:`cloudFile.onFileUpload` is |File|."
+ },
+ "reuse_uploads": {
+ "description": "If a previously uploaded cloud file attachment is reused at a later time in a different message, Thunderbird may use the already known ``url`` and ``templateInfo`` values without triggering the registered :ref:`cloudFile.onFileUpload` listener again. Setting this option to <value>false</value> will always trigger the registered listener, providing the already known values through the ``relatedFileInfo`` parameter of the :ref:`cloudFile.onFileUpload` event, to let the provider decide how to handle these cases.",
+ "type": "boolean",
+ "optional": true,
+ "default": true
+ },
+ "management_url": {
+ "type": "string",
+ "format": "relativeUrl",
+ "preprocess": "localize",
+ "description": "A page for configuring accounts, to be displayed in the preferences UI. **Note:** Within this UI only a limited subset of the WebExtension APIs is available: ``cloudFile``, ``extension``, ``i18n``, ``runtime``, ``storage``, ``test``."
+ },
+ "name": {
+ "type": "string",
+ "preprocess": "localize",
+ "description": "Name of the cloud file service."
+ },
+ "new_account_url": {
+ "type": "string",
+ "optional": true,
+ "deprecated": true,
+ "description": "This property was never used."
+ },
+ "service_url": {
+ "type": "string",
+ "optional": true,
+ "deprecated": true,
+ "description": "This property is no longer used. The ``service_url`` property of the :ref:`cloudFile.CloudFileTemplateInfo` object returned by the :ref:`cloudFile.onFileUpload` event can be used to add a <em>Learn more about</em> link to the footer of the cloud file attachment element."
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "cloudFile",
+ "permissions": ["manifest:cloud_file"],
+ "allowedContexts": ["content"],
+ "events": [
+ {
+ "name": "onFileUpload",
+ "type": "function",
+ "description": "Fired when a file should be uploaded to the cloud file provider.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "name": "fileInfo",
+ "$ref": "CloudFile",
+ "description": "The file to upload."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the upload was initiated. Currently only available for the message composer."
+ },
+ {
+ "$ref": "RelatedCloudFile",
+ "name": "relatedFileInfo",
+ "optional": true,
+ "description": "Information about an already uploaded file, which is related to this upload."
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "aborted": {
+ "type": "boolean",
+ "description": "Set this to <value>true</value> if the file upload was aborted by the user and an :ref:`cloudFile.onFileUploadAbort` event has been received. No error message will be shown to the user.",
+ "optional": true
+ },
+ "error": {
+ "choices": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "Report an error to the user. Set this to <value>true</value> for showing a generic error message, or set a specific error message.",
+ "optional": true
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL where the uploaded file can be accessed.",
+ "optional": true
+ },
+ "templateInfo": {
+ "$ref": "CloudFileTemplateInfo",
+ "description": "Additional file information used in the cloud file entry added to the message.",
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "onFileUploadAbort",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "type": "integer",
+ "name": "fileId",
+ "minimum": 1,
+ "description": "An identifier for this file."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the upload was initiated. Currently only available for the message composer."
+ }
+ ]
+ },
+ {
+ "name": "onFileRename",
+ "type": "function",
+ "description": "Fired when a previously uploaded file should be renamed.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "type": "integer",
+ "name": "fileId",
+ "minimum": 1,
+ "description": "An identifier for the file which should be renamed."
+ },
+ {
+ "type": "string",
+ "name": "newName",
+ "description": "The new name of the file."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the rename was initiated. Currently only available for the message composer."
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "choices": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "Report an error to the user. Set this to <value>true</value> for showing a generic error message, or set a specific error message.",
+ "optional": true
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL where the renamed file can be accessed.",
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "onFileDeleted",
+ "type": "function",
+ "description": "Fired when a previously uploaded file should be deleted.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "type": "integer",
+ "name": "fileId",
+ "minimum": 1,
+ "description": "An identifier for this file."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the upload was initiated. Currently only available for the message composer."
+ }
+ ]
+ },
+ {
+ "name": "onAccountAdded",
+ "type": "function",
+ "description": "Fired when a cloud file account of this add-on was created.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The created account."
+ }
+ ]
+ },
+ {
+ "name": "onAccountDeleted",
+ "type": "function",
+ "description": "Fired when a cloud file account of this add-on was deleted.",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "description": "The id of the removed account."
+ }
+ ]
+ }
+ ],
+ "types": [
+ {
+ "id": "CloudFileAccount",
+ "type": "object",
+ "description": "Information about a cloud file account.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the account."
+ },
+ "configured": {
+ "type": "boolean",
+ "description": "If true, the account is configured and ready to use. Only configured accounts are offered to the user."
+ },
+ "name": {
+ "type": "string",
+ "description": "A user-friendly name for this account."
+ },
+ "uploadSizeLimit": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The maximum size in bytes for a single file to upload. Set to <value>-1</value> if unlimited."
+ },
+ "spaceRemaining": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of remaining space on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "spaceUsed": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of space already used on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "managementUrl": {
+ "type": "string",
+ "format": "relativeUrl",
+ "description": "A page for configuring accounts, to be displayed in the preferences UI."
+ }
+ }
+ },
+ {
+ "id": "CloudFileTemplateInfo",
+ "type": "object",
+ "description": "Defines information to be used in the cloud file entry added to the message.",
+ "properties": {
+ "service_icon": {
+ "type": "string",
+ "optional": true,
+ "description": "A URL pointing to an icon to represent the used cloud file service. Defaults to the icon of the provider add-on."
+ },
+ "service_name": {
+ "type": "string",
+ "optional": true,
+ "description": "A name to represent the used cloud file service. Defaults to the associated cloud file account name."
+ },
+ "service_url": {
+ "type": "string",
+ "optional": true,
+ "description": "A URL pointing to a web page of the used cloud file service. Will be used in a <em>Learn more about</em> link in the footer of the cloud file attachment element."
+ },
+ "download_password_protected": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If set to true, the cloud file entry for this upload will include a hint, that the download link is password protected."
+ },
+ "download_limit": {
+ "type": "integer",
+ "optional": true,
+ "description": "If set, the cloud file entry for this upload will include a hint, that the file has a download limit."
+ },
+ "download_expiry_date": {
+ "type": "object",
+ "optional": true,
+ "description": "If set, the cloud file entry for this upload will include a hint, that the link will only be available for a limited time.",
+ "properties": {
+ "timestamp": {
+ "type": "integer",
+ "description": "The expiry date of the link as the number of milliseconds since the UNIX epoch."
+ },
+ "format": {
+ "optional": true,
+ "description": "A format options object as used by |DateTimeFormat|. Defaults to: <literalinclude>includes/cloudFile/defaultDateFormat.js<lang>JavaScript</lang></literalinclude>",
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ },
+ {
+ "id": "CloudFile",
+ "type": "object",
+ "description": "Information about a cloud file.",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "An identifier for this file."
+ },
+ "name": {
+ "type": "string",
+ "description": "Filename of the file to be transferred."
+ },
+ "data": {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true,
+ "description": "Contents of the file to be transferred."
+ }
+ }
+ },
+ {
+ "id": "RelatedCloudFile",
+ "type": "object",
+ "description": "Information about an already uploaded cloud file, which is related to a new upload. For example if the content of a cloud attachment is updated, if a repeatedly used cloud attachment is renamed (and therefore should be re-uploaded to not invalidate existing links) or if the provider has its manifest property ``reuse_uploads`` set to <value>false</value>.",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "minimum": 1,
+ "optional": true,
+ "description": "The identifier for the related file. In some circumstances, the id is unavailable."
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL where the upload of the related file can be accessed.",
+ "optional": true
+ },
+ "templateInfo": {
+ "$ref": "CloudFileTemplateInfo",
+ "description": "Additional information of the related file, used in the cloud file entry added to the message.",
+ "optional": true
+ },
+ "name": {
+ "type": "string",
+ "description": "Filename of the related file."
+ },
+ "dataChanged": {
+ "type": "boolean",
+ "description": "The content of the new upload differs from the related file."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "getAccount",
+ "type": "function",
+ "description": "Retrieve information about a single cloud file account.",
+ "allowedContexts": ["content"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "description": "Unique identifier of the account."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "CloudFileAccount"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAllAccounts",
+ "type": "function",
+ "description": "Retrieve all cloud file accounts for the current add-on.",
+ "allowedContexts": ["content"],
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "CloudFileAccount"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "updateAccount",
+ "type": "function",
+ "description": "Update a cloud file account.",
+ "allowedContexts": ["content"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "description": "Unique identifier of the account."
+ },
+ {
+ "name": "updateProperties",
+ "type": "object",
+ "properties": {
+ "configured": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, the account is configured and ready to use. Only configured accounts are offered to the user."
+ },
+ "uploadSizeLimit": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The maximum size in bytes for a single file to upload. Set to <value>-1</value> if unlimited."
+ },
+ "spaceRemaining": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of remaining space on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "spaceUsed": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of space already used on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "managementUrl": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "A page for configuring accounts, to be displayed in the preferences UI."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "CloudFileAccount"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/commands.json b/comm/mail/components/extensions/schemas/commands.json
new file mode 100644
index 0000000000..900e1df1a0
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/commands.json
@@ -0,0 +1,279 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "id": "KeyName",
+ "type": "string",
+ "format": "manifestShortcutKey",
+ "description": "Definition of a shortcut, for example <value>Alt+F5</value>. The string must match the shortcut format as defined by the `MDN page of the commands API <|link-commands-shortcuts|>`__."
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "commands": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "object",
+ "max_manifest_version": 2,
+ "description": "A <em>dictionary object</em> defining one or more commands as <em>name-value</em> pairs, the <em>name</em> being the name of the command and the <em>value</em> being a :ref:`commands.CommandsShortcut`. The <em>name</em> may also be one of the following built-in special shortcuts: \n * <value>_execute_browser_action</value> \n * <value>_execute_compose_action</value> \n * <value>_execute_message_display_action</value>\nExample: <literalinclude>includes/commands/manifest.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "suggested_key": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "default": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "mac": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "linux": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "windows": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "chromeos": {
+ "type": "string",
+ "optional": true
+ },
+ "android": {
+ "type": "string",
+ "optional": true
+ },
+ "ios": {
+ "type": "string",
+ "optional": true
+ },
+ "additionalProperties": {
+ "type": "string",
+ "deprecated": "Unknown platform name",
+ "optional": true
+ }
+ }
+ },
+ "description": {
+ "type": "string",
+ "preprocess": "localize",
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "min_manifest_version": 3,
+ "description": "A <em>dictionary object</em> defining one or more commands as <em>name-value</em> pairs, the <em>name</em> being the name of the command and the <em>value</em> being a :ref:`commands.CommandsShortcut`. The <em>name</em> may also be one of the following built-in special shortcuts: \n * <value>_execute_action</value> \n * <value>_execute_compose_action</value> \n * <value>_execute_message_display_action</value>\nExample: <literalinclude>includes/commands/manifest.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "suggested_key": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "default": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "mac": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "linux": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "windows": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "chromeos": {
+ "type": "string",
+ "optional": true
+ },
+ "android": {
+ "type": "string",
+ "optional": true
+ },
+ "ios": {
+ "type": "string",
+ "optional": true
+ },
+ "additionalProperties": {
+ "type": "string",
+ "deprecated": "Unknown platform name",
+ "optional": true
+ }
+ }
+ },
+ "description": {
+ "type": "string",
+ "preprocess": "localize",
+ "optional": true
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "commands",
+ "description": "Use the commands API to add keyboard shortcuts that trigger actions in your extension, for example opening one of the action popups or sending a command to the extension.",
+ "permissions": ["manifest:commands"],
+ "types": [
+ {
+ "id": "Command",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The name of the Extension Command"
+ },
+ "description": {
+ "type": "string",
+ "optional": true,
+ "description": "The Extension Command description"
+ },
+ "shortcut": {
+ "type": "string",
+ "optional": true,
+ "description": "The shortcut active for this command, or blank if not active."
+ }
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "onCommand",
+ "description": "Fired when a registered command is activated using a keyboard shortcut. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "command",
+ "type": "string"
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the active tab while the command occurred."
+ }
+ ]
+ },
+ {
+ "name": "onChanged",
+ "description": "Fired when a registered command's shortcut is changed.",
+ "type": "function",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "changeInfo",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the shortcut."
+ },
+ "newShortcut": {
+ "type": "string",
+ "description": "The new shortcut active for this command, or blank if not active."
+ },
+ "oldShortcut": {
+ "type": "string",
+ "description": "The old shortcut which is no longer active for this command, or blank if the shortcut was previously inactive."
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "description": "Update the details of an already defined command.",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "detail",
+ "description": "The new details for the command.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the command."
+ },
+ "description": {
+ "type": "string",
+ "optional": true,
+ "description": "The description for the command."
+ },
+ "shortcut": {
+ "type": "string",
+ "format": "manifestShortcutKeyOrEmpty",
+ "optional": true,
+ "description": "An empty string to clear the shortcut, or a string matching the format defined by the `MDN page of the commands API <|link-commands-shortcuts|>`__ to set a new shortcut key. If the string does not match this format, the function throws an error."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "reset",
+ "type": "function",
+ "async": true,
+ "description": "Reset a command's details to what is specified in the manifest.",
+ "parameters": [
+ {
+ "type": "string",
+ "name": "name",
+ "description": "The name of the command."
+ }
+ ]
+ },
+ {
+ "name": "getAll",
+ "type": "function",
+ "async": "callback",
+ "description": "Returns all the registered extension commands for this extension and their shortcut (if active).",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "commands",
+ "type": "array",
+ "items": {
+ "$ref": "Command"
+ }
+ }
+ ],
+ "description": "Called to return the registered commands."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/compose.json b/comm/mail/components/extensions/schemas/compose.json
new file mode 100644
index 0000000000..f6915fc363
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/compose.json
@@ -0,0 +1,937 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["compose", "compose.save", "compose.send"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "compose",
+ "types": [
+ {
+ "id": "ComposeRecipient",
+ "choices": [
+ {
+ "type": "string",
+ "description": "A name and email address in the format <value>Name <email@example.com></value>, or just an email address."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The ID of a contact or mailing list from the :doc:`contacts` and :doc:`mailingLists` APIs."
+ },
+ "type": {
+ "type": "string",
+ "description": "Which sort of object this ID is for.",
+ "enum": ["contact", "mailingList"]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "id": "ComposeRecipientList",
+ "choices": [
+ {
+ "$ref": "ComposeRecipient"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ComposeRecipient"
+ }
+ }
+ ]
+ },
+ {
+ "id": "ComposeState",
+ "type": "object",
+ "description": "Represent the state of the message composer.",
+ "properties": {
+ "canSendNow": {
+ "type": "boolean",
+ "description": "The message can be send now."
+ },
+ "canSendLater": {
+ "type": "boolean",
+ "description": "The message can be send later."
+ }
+ }
+ },
+ {
+ "id": "ComposeDetails",
+ "type": "object",
+ "description": "Used by various functions to represent the state of a message being composed. Note that functions using this type may have a partial implementation.",
+ "properties": {
+ "identityId": {
+ "type": "string",
+ "description": "The ID of an identity from the :doc:`accounts` API. The settings from the identity will be used in the composed message. If ``replyTo`` is also specified, the ``replyTo`` property of the identity is overridden. The permission <permission>accountsRead</permission> is required to include the ``identityId``.",
+ "optional": true
+ },
+ "from": {
+ "$ref": "ComposeRecipient",
+ "description": "*Caution*: Setting a value for ``from`` does not change the used identity, it overrides the FROM header. Many email servers do not accept emails where the FROM header does not match the sender identity. Must be set to exactly one valid email address.",
+ "optional": true
+ },
+ "to": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "cc": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "bcc": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "overrideDefaultFcc": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates whether the default fcc setting (defined by the used identity) is being overridden for this message. Setting <value>false</value> will clear the override. Setting <value>true</value> will throw an <em>ExtensionError</em>, if ``overrideDefaultFccFolder`` is not set as well."
+ },
+ "overrideDefaultFccFolder": {
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ],
+ "optional": true,
+ "description": " This value overrides the default fcc setting (defined by the used identity) for this message only. Either a :ref:`folders.MailFolder` specifying the folder for the copy of the sent message, or an empty string to not save a copy at all."
+ },
+ "additionalFccFolder": {
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ],
+ "description": "An additional fcc folder which can be selected while composing the message, an empty string if not used.",
+ "optional": true
+ },
+ "replyTo": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "followupTo": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "newsgroups": {
+ "description": "A single newsgroup name or an array of newsgroup names.",
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "optional": true
+ },
+ "relatedMessageId": {
+ "description": "The id of the original message (in case of draft, template, forward or reply). Read-only. Is <value>null</value> in all other cases or if the original message was opened from file.",
+ "type": "integer",
+ "optional": true
+ },
+ "subject": {
+ "type": "string",
+ "optional": true
+ },
+ "type": {
+ "type": "string",
+ "description": "Read-only. The type of the message being composed, depending on how the compose window was opened by the user.",
+ "enum": ["draft", "new", "redirect", "reply", "forward"],
+ "optional": true
+ },
+ "body": {
+ "type": "string",
+ "description": "The HTML content of the message.",
+ "optional": true
+ },
+ "plainTextBody": {
+ "type": "string",
+ "description": "The plain text content of the message.",
+ "optional": true
+ },
+ "isPlainText": {
+ "type": "boolean",
+ "description": "Whether the message is an HTML message or a plain text message.",
+ "optional": true
+ },
+ "deliveryFormat": {
+ "type": "string",
+ "enum": ["auto", "plaintext", "html", "both"],
+ "description": "Defines the mime format of the sent message (ignored on plain text messages). Defaults to <value>auto</value>, which will send html messages as plain text, if they do not include any formatting, and as <value>both</value> otherwise (a multipart/mixed message).",
+ "optional": true
+ },
+ "customHeaders": {
+ "type": "array",
+ "items": {
+ "$ref": "CustomHeader"
+ },
+ "description": "Array of custom headers. Headers will be returned in <em>Http-Header-Case</em> (a.k.a. <em>Train-Case</em>). Set an empty array to clear all custom headers.",
+ "optional": true
+ },
+ "priority": {
+ "type": "string",
+ "enum": ["lowest", "low", "normal", "high", "highest"],
+ "description": "The priority of the message.",
+ "optional": true
+ },
+ "returnReceipt": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Add the <em>Disposition-Notification-To</em> header to the message to requests the recipients email client to send a reply once the message has been received. Recipient server may strip the header and the recipient might ignore the request."
+ },
+ "deliveryStatusNotification": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Let the sender know when the recipient's server received the message. Not supported by all servers."
+ },
+ "attachVCard": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Wether or not the vCard of the used identity will be attached to the message during send. Note: If the value has not been modified, selecting a different identity will load the default value of the new identity."
+ },
+ "attachments": {
+ "type": "array",
+ "items": {
+ "choices": [
+ {
+ "$ref": "FileAttachment"
+ },
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ },
+ "description": "Only used in the begin* functions. Attachments to add to the message.",
+ "optional": true
+ }
+ }
+ },
+ {
+ "id": "FileAttachment",
+ "type": "object",
+ "description": "Object used to add, update or rename an attachment in a message being composed.",
+ "properties": {
+ "file": {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true,
+ "description": "The new content for the attachment.",
+ "optional": true
+ },
+ "name": {
+ "type": "string",
+ "description": "The new name for the attachment, as displayed to the user. If not specified, the name of the provided ``file`` object is used.",
+ "optional": true
+ }
+ }
+ },
+ {
+ "id": "ComposeAttachment",
+ "type": "object",
+ "description": "Represents an attachment in a message being composed.",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "A unique identifier for this attachment."
+ },
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The name of this attachment, as displayed to the user."
+ },
+ "size": {
+ "type": "integer",
+ "optional": true,
+ "description": "The size in bytes of this attachment. Read-only."
+ }
+ }
+ },
+ {
+ "id": "CustomHeader",
+ "type": "object",
+ "description": "A custom header definition.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of a custom header, must have a <value>X-</value> prefix.",
+ "pattern": "^X-.*$"
+ },
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "id": "ComposeDictionaries",
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ },
+ "description": "A <em>dictionary object</em> with entries for all installed dictionaries, having a language identifier as <em>key</em> (for example <value>en-US</value>) and a boolean expression as <em>value</em>, indicating whether that dictionary is enabled for spellchecking or not."
+ }
+ ],
+ "events": [
+ {
+ "name": "onBeforeSend",
+ "type": "function",
+ "description": "Fired when a message is about to be sent from the compose window. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "description": "The current state of the compose window. This is functionally the same as calling the :ref:`compose.getComposeDetails` function."
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "cancel": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Cancels the send."
+ },
+ "details": {
+ "$ref": "ComposeDetails",
+ "optional": true,
+ "description": "Updates the compose window. This is functionally the same as calling the :ref:`compose.setComposeDetails` function."
+ }
+ }
+ }
+ },
+ {
+ "name": "onAfterSend",
+ "type": "function",
+ "description": "Fired when sending a message succeeded or failed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "sendInfo",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used send mode.",
+ "enum": ["sendNow", "sendLater"]
+ },
+ "error": {
+ "type": "string",
+ "description": "An error description, if sending the message failed.",
+ "optional": true
+ },
+ "headerMessageId": {
+ "type": "string",
+ "description": "The header messageId of the outgoing message. Only included for actually sent messages.",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "Copies of the sent message. The number of created copies depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onAfterSave",
+ "type": "function",
+ "description": "Fired when saving a message as draft or template succeeded or failed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "saveInfo",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used save mode.",
+ "enum": ["draft", "template"]
+ },
+ "error": {
+ "type": "string",
+ "description": "An error description, if saving the message failed.",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "The saved message(s). The number of saved messages depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onAttachmentAdded",
+ "type": "function",
+ "description": "Fired when an attachment is added to a message being composed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "attachment",
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ },
+ {
+ "name": "onAttachmentRemoved",
+ "type": "function",
+ "description": "Fired when an attachment is removed from a message being composed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "attachmentId",
+ "type": "integer"
+ }
+ ]
+ },
+ {
+ "name": "onIdentityChanged",
+ "type": "function",
+ "description": "Fired when the user changes the identity that will be used to send a message being composed.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onComposeStateChanged",
+ "type": "function",
+ "description": "Fired when the state of the message composer changed.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "state",
+ "$ref": "ComposeState"
+ }
+ ]
+ },
+ {
+ "name": "onActiveDictionariesChanged",
+ "type": "function",
+ "description": "Fired when one or more dictionaries have been activated or deactivated.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "dictionaries",
+ "$ref": "ComposeDictionaries"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "beginNew",
+ "type": "function",
+ "description": "Open a new message compose window.\n\n**Note:** The compose format can be set by ``details.isPlainText`` or by specifying only one of ``details.body`` or ``details.plainTextBody``. Otherwise the default compose format of the selected identity is used.\n\n**Note:** Specifying ``details.body`` and ``details.plainTextBody`` without also specifying ``details.isPlainText`` threw an exception in Thunderbird up to version 97. Since Thunderbird 98, this combination creates a compose window with the compose format of the selected identity, using the matching ``details.body`` or ``details.plainTextBody`` value.\n\n**Note:** If no identity is specified, this function is using the default identity and not the identity of the referenced message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "description": "If specified, the message or template to edit as a new message.",
+ "type": "integer",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "beginReply",
+ "type": "function",
+ "description": "Open a new message compose window replying to a given message.\n\n**Note:** The compose format can be set by ``details.isPlainText`` or by specifying only one of ``details.body`` or ``details.plainTextBody``. Otherwise the default compose format of the selected identity is used.\n\n**Note:** Specifying ``details.body`` and ``details.plainTextBody`` without also specifying ``details.isPlainText`` threw an exception in Thunderbird up to version 97. Since Thunderbird 98, this combination creates a compose window with the compose format of the selected identity, using the matching ``details.body`` or ``details.plainTextBody`` value.\n\n**Note:** If no identity is specified, this function is using the default identity and not the identity of the referenced message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "description": "The message to reply to, as retrieved using other APIs.",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "name": "replyType",
+ "type": "string",
+ "enum": ["replyToSender", "replyToList", "replyToAll"],
+ "optional": true
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "beginForward",
+ "type": "function",
+ "description": "Open a new message compose window forwarding a given message.\n\n**Note:** The compose format can be set by ``details.isPlainText`` or by specifying only one of ``details.body`` or ``details.plainTextBody``. Otherwise the default compose format of the selected identity is used.\n\n**Note:** Specifying ``details.body`` and ``details.plainTextBody`` without also specifying ``details.isPlainText`` threw an exception in Thunderbird up to version 97. Since Thunderbird 98, this combination creates a compose window with the compose format of the selected identity, using the matching ``details.body`` or ``details.plainTextBody`` value.\n\n**Note:** If no identity is specified, this function is using the default identity and not the identity of the referenced message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "description": "The message to forward, as retrieved using other APIs.",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "name": "forwardType",
+ "type": "string",
+ "enum": ["forwardInline", "forwardAsAttachment"],
+ "optional": true
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getComposeDetails",
+ "type": "function",
+ "async": "callback",
+ "description": "Fetches the current state of a compose window. Currently only a limited amount of information is available, more will be added in later versions.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeDetails"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setComposeDetails",
+ "type": "function",
+ "async": true,
+ "description": "Updates the compose window. The properties of the given :ref:`compose.ComposeDetails` object will be used to overwrite the current values of the specified compose window, so only properties that are to be changed should be included.\n\nWhen updating any of the array properties (``customHeaders`` and most address fields), make sure to first get the current values to not accidentally remove all existing entries when setting the new value.\n\n**Note:** The compose format of an existing compose window cannot be changed. Since Thunderbird 98, setting conflicting values for ``details.body``, ``details.plainTextBody`` or ``details.isPlaintext`` no longer throws an exception, instead the compose window chooses the matching ``details.body`` or ``details.plainTextBody`` value and ignores the other.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails"
+ }
+ ]
+ },
+ {
+ "name": "getActiveDictionaries",
+ "type": "function",
+ "async": "callback",
+ "description": "Returns a :ref:`compose.ComposeDictionaries` object, listing all installed dictionaries, including the information whether they are currently enabled or not.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeDictionaries"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setActiveDictionaries",
+ "type": "function",
+ "async": true,
+ "description": "Updates the active dictionaries. Throws if the ``activeDictionaries`` array contains unknown or invalid language identifiers.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "name": "activeDictionaries"
+ }
+ ]
+ },
+ {
+ "name": "listAttachments",
+ "type": "function",
+ "description": "Lists all of the attachments of the message being composed in the specified tab.",
+ "permissions": ["compose"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ComposeAttachment"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAttachmentFile",
+ "type": "function",
+ "description": "Gets the content of a :ref:`compose.ComposeAttachment` as a |File| object.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "integer",
+ "description": "The unique identifier for the attachment."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "addAttachment",
+ "type": "function",
+ "description": "Adds an attachment to the message being composed in the specified tab.",
+ "permissions": ["compose"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "attachment",
+ "choices": [
+ {
+ "$ref": "FileAttachment"
+ },
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "updateAttachment",
+ "type": "function",
+ "description": "Updates the name and/or the content of an attachment in the message being composed in the specified tab. If the specified attachment is a cloud file attachment and the associated provider failed to update the attachment, the function will throw an <em>ExtensionError</em>.",
+ "permissions": ["compose"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "attachmentId",
+ "type": "integer"
+ },
+ {
+ "name": "attachment",
+ "$ref": "FileAttachment"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "removeAttachment",
+ "type": "function",
+ "description": "Removes an attachment from the message being composed in the specified tab.",
+ "permissions": ["compose"],
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "attachmentId",
+ "type": "integer"
+ }
+ ]
+ },
+ {
+ "name": "sendMessage",
+ "permissions": ["compose.send"],
+ "type": "function",
+ "description": "Sends the message currently being composed. If the send mode is not specified or set to <value>default</value>, the message will be send directly if the user is online and placed in the users outbox otherwise. The returned Promise fulfills once the message has been successfully sent or placed in the user's outbox. Throws when the send process has been aborted by the user, by an :ref:`compose.onBeforeSend` event or if there has been an error while sending the message to the outgoing mail server.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "options",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["default", "sendNow", "sendLater"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used send mode.",
+ "enum": ["sendNow", "sendLater"]
+ },
+ "headerMessageId": {
+ "type": "string",
+ "description": "The header messageId of the outgoing message. Only included for actually sent messages.",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "Copies of the sent message. The number of created copies depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "saveMessage",
+ "permissions": ["compose.save"],
+ "type": "function",
+ "description": "Saves the message currently being composed as a draft or as a template. If the save mode is not specified, the message will be saved as a draft. The returned Promise fulfills once the message has been successfully saved.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "options",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["draft", "template"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used save mode.",
+ "enum": ["draft", "template"]
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "The saved message(s). The number of saved messages depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getComposeState",
+ "type": "function",
+ "description": "Returns information about the current state of the message composer.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "ComposeState"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/composeAction.json b/comm/mail/components/extensions/schemas/composeAction.json
new file mode 100644
index 0000000000..4220d8d6f1
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/composeAction.json
@@ -0,0 +1,722 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "compose_action": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the composeAction button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the composeAction button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the composeAction button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the composeAction button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_area": {
+ "description": "Defines the location the composeAction button will appear. The default location is <value>maintoolbar</value>.",
+ "type": "string",
+ "enum": ["maintoolbar", "formattoolbar"],
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "composeAction",
+ "description": "Use a composeAction to put a button in the message composition toolbars. In addition to its icon, a composeAction button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:compose_action"],
+ "types": [
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "ImageDataType",
+ "type": "object",
+ "isInstanceOf": "ImageData",
+ "additionalProperties": {
+ "type": "any"
+ },
+ "postprocess": "convertImageDataToURL",
+ "description": "Pixel data for an image. Must be an |ImageData| object (for example, from a |Canvas| element)."
+ },
+ {
+ "id": "ImageDataDictionary",
+ "type": "object",
+ "description": "A <em>dictionary object</em> to specify multiple |ImageData| objects in different sizes, so the icon does not have to be scaled for a device with a different pixel density. Each entry is a <em>name-value</em> pair with <em>value</em> being an |ImageData| object, and <em>name</em> its size. Example: <literalinclude>includes/ImageDataDictionary.json<lang>JavaScript</lang></literalinclude>See the `MDN documentation about choosing icon sizes <|link-mdn-icon-size|>`__ for more information on this.",
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "$ref": "ImageDataType"
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when a composeAction button is clicked.",
+ "properties": {
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "setTitle",
+ "type": "function",
+ "description": "Sets the title of the composeAction button. Is used as tooltip and as the label.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "title": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the composeAction button should display as its label and when moused over. Cleared by setting it to <value>null</value> or an empty string (title defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the title only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getTitle",
+ "type": "function",
+ "description": "Gets the title of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the title should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setLabel",
+ "type": "function",
+ "description": "Sets the label of the composeAction button. Can be used to set different values for the tooltip (defined by the title) and the label. Additionally, the label can be set to an empty string, not showing any label at all.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "label": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the composeAction button should use as its label, overriding the defined title. Can be set to an empty string to not display any label at all. If the containing toolbar is configured to display text only, its title will be used. Cleared by setting it to <value>null</value>."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the label only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getLabel",
+ "type": "function",
+ "description": "Gets the label of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the label should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setIcon",
+ "type": "function",
+ "description": "Sets the icon for the composeAction button. Either the ``path`` or the ``imageData`` property must be specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "imageData": {
+ "choices": [
+ {
+ "$ref": "ImageDataType"
+ },
+ {
+ "$ref": "ImageDataDictionary"
+ }
+ ],
+ "optional": true,
+ "description": "The image data for one or more icons for the composeAction button."
+ },
+ "path": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "The paths to one or more icons for the composeAction button."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the icon only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "setPopup",
+ "type": "function",
+ "description": "Sets the html document to be opened as a popup when the user clicks on the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "popup": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The html file to show in a popup. Can be set to an empty string to not open a popup. Cleared by setting it to <value>null</value> (action will use the popup value defined in the manifest)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the popup only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getPopup",
+ "type": "function",
+ "description": "Gets the html document set as the popup for this composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the popup document should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeText",
+ "type": "function",
+ "description": "Sets the badge text for the composeAction button. The badge is displayed on top of the icon.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "text": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Any number of characters can be passed, but only about four can fit in the space. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the badge text only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeText",
+ "type": "function",
+ "description": "Gets the badge text of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge text should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeBackgroundColor",
+ "type": "function",
+ "description": "Sets the background color for the badge.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "color": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The color to use as background in the badge. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the background color for the badge only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeBackgroundColor",
+ "type": "function",
+ "description": "Gets the badge background color of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge background color should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "$ref": "ColorArray"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "enable",
+ "type": "function",
+ "description": "Enables the composeAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well. By default, a composeAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the composeAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "disable",
+ "type": "function",
+ "description": "Disables the composeAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the composeAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "isEnabled",
+ "type": "function",
+ "description": "Checks whether the composeAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the state should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openPopup",
+ "type": "function",
+ "description": "Opens the action's popup window in the specified window. Defaults to the current window. Returns false if the popup could not be opened because the action has no popup, is of type <value>menu</value>, is disabled or has been removed from the toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "optional": true,
+ "type": "object",
+ "description": "An object with information about the popup to open.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when a composeAction button is clicked. This event will not fire if the composeAction has a popup. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/extensionScripts.json b/comm/mail/components/extensions/schemas/extensionScripts.json
new file mode 100644
index 0000000000..67a734b7fb
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/extensionScripts.json
@@ -0,0 +1,133 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, you can obtain one at http://mozilla.org/MPL/2.0/.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["messagesModify", "sensitiveDataUpload"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "composeScripts",
+ "permissions": ["compose"],
+ "types": [
+ {
+ "id": "RegisteredComposeScriptOptions",
+ "type": "object",
+ "description": "Details of a compose script registered programmatically",
+ "properties": {
+ "css": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of CSS files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ },
+ "js": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of JavaScript files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ }
+ }
+ },
+ {
+ "id": "RegisteredComposeScript",
+ "type": "object",
+ "description": "An object that represents a compose script registered programmatically",
+ "functions": [
+ {
+ "name": "unregister",
+ "type": "function",
+ "description": "Unregister a compose script registered programmatically",
+ "async": true,
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "register",
+ "type": "function",
+ "description": "Register a compose script programmatically",
+ "async": true,
+ "parameters": [
+ {
+ "name": "composeScriptOptions",
+ "$ref": "RegisteredComposeScriptOptions"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "messageDisplayScripts",
+ "permissions": ["messagesModify"],
+ "types": [
+ {
+ "id": "RegisteredMessageDisplayScriptOptions",
+ "type": "object",
+ "description": "Details of a message display script registered programmatically",
+ "properties": {
+ "css": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of CSS files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ },
+ "js": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of JavaScript files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ }
+ }
+ },
+ {
+ "id": "RegisteredMessageDisplayScript",
+ "type": "object",
+ "description": "An object that represents a message display script registered programmatically",
+ "functions": [
+ {
+ "name": "unregister",
+ "type": "function",
+ "description": "Unregister a message display script registered programmatically",
+ "async": true,
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "register",
+ "type": "function",
+ "description": "Register a message display script programmatically",
+ "async": true,
+ "parameters": [
+ {
+ "name": "messageDisplayScriptOptions",
+ "$ref": "RegisteredMessageDisplayScriptOptions"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/folders.json b/comm/mail/components/extensions/schemas/folders.json
new file mode 100644
index 0000000000..307cbcf789
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/folders.json
@@ -0,0 +1,408 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["accountsFolders"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "folders",
+ "permissions": ["accountsRead"],
+ "types": [
+ {
+ "id": "MailFolder",
+ "type": "object",
+ "description": "An object describing a mail folder, as returned for example by the :ref:`folders.getParentFolders` or :ref:`folders.getSubFolders` methods, or part of a :ref:`accounts.MailAccount` object, which is returned for example by the :ref:`accounts.list` and :ref:`accounts.get` methods. The ``subFolders`` property is only included if requested.",
+ "properties": {
+ "accountId": {
+ "type": "string",
+ "description": "The account this folder belongs to."
+ },
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The human-friendly name of this folder."
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to this folder in the account. Although paths look predictable, never guess a folder's path, as there are a number of reasons why it may not be what you think it is. Use :ref:`folders.getParentFolders` or :ref:`folders.getSubFolders` to obtain hierarchy information."
+ },
+ "subFolders": {
+ "type": "array",
+ "description": "Subfolders are only included if requested. They will be returned in the same order as used in Thunderbird's folder pane.",
+ "items": {
+ "$ref": "MailFolder"
+ },
+ "optional": true
+ },
+ "type": {
+ "type": "string",
+ "optional": true,
+ "description": "The type of folder, for several common types.",
+ "enum": [
+ "inbox",
+ "drafts",
+ "sent",
+ "trash",
+ "templates",
+ "archives",
+ "junk",
+ "outbox"
+ ]
+ }
+ }
+ },
+ {
+ "id": "MailFolderInfo",
+ "type": "object",
+ "description": "An object containing additional information about a mail folder.",
+ "properties": {
+ "favorite": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether this folder is a favorite folder."
+ },
+ "totalMessageCount": {
+ "type": "integer",
+ "optional": true,
+ "description": "Number of messages in this folder."
+ },
+ "unreadMessageCount": {
+ "type": "integer",
+ "optional": true,
+ "description": "Number of unread messages in this folder."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "create",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Creates a new subfolder in the specified folder or at the root of the specified account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parent",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "name": "childName",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "rename",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Renames a folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "newName",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "move",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Moves the given ``sourceFolder`` into the given ``destination``. Throws if the destination already contains a folder with the name of the source folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "sourceFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "destination",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "copy",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Copies the given ``sourceFolder`` into the given ``destination``. Throws if the destination already contains a folder with the name of the source folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "sourceFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "destination",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "delete",
+ "permissions": ["accountsFolders", "messagesDelete"],
+ "type": "function",
+ "description": "Deletes a folder.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "getFolderInfo",
+ "type": "function",
+ "description": "Get additional information about a mail folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolderInfo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getParentFolders",
+ "type": "function",
+ "description": "Get all parent folders as a flat ordered array. The first array entry is the direct parent.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "includeSubFolders",
+ "description": "Specifies whether the returned :ref:`folders.MailFolder` object for each parent folder should include its nested subfolders . Defaults to <value>false</value>.",
+ "optional": true,
+ "default": false,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "folders.MailFolder"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getSubFolders",
+ "type": "function",
+ "description": "Get the subfolders of the specified folder or account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folderOrAccount",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "name": "includeSubFolders",
+ "description": "Specifies whether the returned :ref:`folders.MailFolder` object for each direct subfolder should also include all its nested subfolders . Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "folders.MailFolder"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a folder has been created.",
+ "parameters": [
+ {
+ "name": "createdFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onRenamed",
+ "type": "function",
+ "description": "Fired when a folder has been renamed.",
+ "parameters": [
+ {
+ "name": "originalFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "renamedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onMoved",
+ "type": "function",
+ "description": "Fired when a folder has been moved.",
+ "parameters": [
+ {
+ "name": "originalFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "movedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onCopied",
+ "type": "function",
+ "description": "Fired when a folder has been copied.",
+ "parameters": [
+ {
+ "name": "originalFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "copiedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when a folder has been deleted.",
+ "parameters": [
+ {
+ "name": "deletedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onFolderInfoChanged",
+ "type": "function",
+ "description": "Fired when certain information of a folder have changed. Bursts of message count changes are collapsed to a single event.",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "folderInfo",
+ "$ref": "folders.MailFolderInfo"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/identities.json b/comm/mail/components/extensions/schemas/identities.json
new file mode 100644
index 0000000000..f22068abd8
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/identities.json
@@ -0,0 +1,277 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["accountsIdentities"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "identities",
+ "permissions": ["accountsRead"],
+ "types": [
+ {
+ "id": "MailIdentity",
+ "type": "object",
+ "properties": {
+ "accountId": {
+ "type": "string",
+ "optional": true,
+ "description": "The id of the :ref:`accounts.MailAccount` this identity belongs to. The ``accountId`` property is read-only."
+ },
+ "composeHtml": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If the identity uses HTML as the default compose format."
+ },
+ "email": {
+ "type": "string",
+ "optional": true,
+ "description": "The user's email address as used when messages are sent from this identity."
+ },
+ "id": {
+ "type": "string",
+ "optional": true,
+ "description": "A unique identifier for this identity. The ``id`` property is read-only."
+ },
+ "label": {
+ "type": "string",
+ "optional": true,
+ "description": "A user-defined label for this identity."
+ },
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The user's name as used when messages are sent from this identity."
+ },
+ "replyTo": {
+ "type": "string",
+ "optional": true,
+ "description": "The reply-to email address associated with this identity."
+ },
+ "organization": {
+ "type": "string",
+ "optional": true,
+ "description": "The organization associated with this identity."
+ },
+ "signature": {
+ "type": "string",
+ "optional": true,
+ "description": "The signature of the identity."
+ },
+ "signatureIsPlainText": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If the signature should be interpreted as plain text or as HTML."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "description": "Returns the identities of the specified account, or all identities if no account is specified. Do not expect the returned identities to be in any specific order. Use :ref:`identities.getDefault` to get the default identity of an account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "identities.MailIdentity"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Returns details of the requested identity, or <value>null</value> if it doesn't exist.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "create",
+ "permissions": ["accountsIdentities"],
+ "type": "function",
+ "description": "Create a new identity in the specified account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "details",
+ "$ref": "identities.MailIdentity"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "delete",
+ "permissions": ["accountsIdentities"],
+ "type": "function",
+ "description": "Attempts to delete the requested identity. Default identities cannot be deleted.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "permissions": ["accountsIdentities"],
+ "type": "function",
+ "description": "Updates the details of an identity.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "name": "details",
+ "$ref": "identities.MailIdentity"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getDefault",
+ "type": "function",
+ "description": "Returns the default identity for the requested account, or <value>null</value> if it is not defined.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setDefault",
+ "type": "function",
+ "description": "Sets the default identity for the requested account.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a new identity has been created and added to an account. The event also fires for default identities that are created when a new account is added.",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "name": "identity",
+ "$ref": "MailIdentity"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when an identity has been removed from an account.",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when one or more properties of an identity have been modified. The returned :ref:`identities.MailIdentity` includes only the changed values.",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "name": "changedValues",
+ "$ref": "MailIdentity"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/mailTabs.json b/comm/mail/components/extensions/schemas/mailTabs.json
new file mode 100644
index 0000000000..6346d614be
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/mailTabs.json
@@ -0,0 +1,428 @@
+[
+ {
+ "namespace": "mailTabs",
+ "types": [
+ {
+ "id": "MailTab",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "windowId": {
+ "type": "integer"
+ },
+ "active": {
+ "type": "boolean"
+ },
+ "sortType": {
+ "type": "string",
+ "description": "**Note:** ``sortType`` and ``sortOrder`` depend on each other, so both should be present, or neither.",
+ "optional": true,
+ "enum": [
+ "none",
+ "date",
+ "subject",
+ "author",
+ "id",
+ "thread",
+ "priority",
+ "status",
+ "size",
+ "flagged",
+ "unread",
+ "recipient",
+ "location",
+ "tags",
+ "junkStatus",
+ "attachments",
+ "account",
+ "custom",
+ "received",
+ "correspondent"
+ ]
+ },
+ "sortOrder": {
+ "type": "string",
+ "description": "**Note:** ``sortType`` and ``sortOrder`` depend on each other, so both should be present, or neither.",
+ "optional": true,
+ "enum": ["none", "ascending", "descending"]
+ },
+ "viewType": {
+ "type": "string",
+ "optional": true,
+ "enum": ["ungrouped", "groupedByThread", "groupedBySortType"]
+ },
+ "layout": {
+ "type": "string",
+ "enum": ["standard", "wide", "vertical"]
+ },
+ "folderPaneVisible": {
+ "type": "boolean",
+ "optional": true
+ },
+ "messagePaneVisible": {
+ "type": "boolean",
+ "optional": true
+ },
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The <permission>accountsRead</permission> permission is required for this property to be included."
+ }
+ }
+ },
+ {
+ "id": "QuickFilterTextDetail",
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "String to match against the ``recipients``, ``author``, ``subject``, or ``body``."
+ },
+ "recipients": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the recipients.",
+ "optional": true
+ },
+ "author": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the author.",
+ "optional": true
+ },
+ "subject": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the subject.",
+ "optional": true
+ },
+ "body": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the message body.",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all mail tabs that have the specified properties, or all mail tabs if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are active in their windows."
+ },
+ "currentWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the current window."
+ },
+ "lastFocusedWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the last focused window."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "description": "The ID of the parent window, or :ref:`windows.WINDOW_ID_CURRENT` for the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MailTab"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Get the properties of a mail tab.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the requested mail tab. Throws if the requested tab is not a mail tab."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MailTab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "description": "Get the properties of the active mail tab, if the active tab is a mail tab. Returns undefined otherwise.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MailTab",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Modifies the properties of a mail tab. Properties that are not specified in ``updateProperties`` are not modified.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "updateProperties",
+ "type": "object",
+ "properties": {
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "description": "Sets the folder displayed in the tab. The extension must have the <permission>accountsRead</permission> permission to do this. The previous message selection in the given folder will be restored.",
+ "optional": true
+ },
+ "sortType": {
+ "type": "string",
+ "description": "Sorts the list of messages. ``sortOrder`` must also be given.",
+ "optional": true,
+ "enum": [
+ "none",
+ "date",
+ "subject",
+ "author",
+ "id",
+ "thread",
+ "priority",
+ "status",
+ "size",
+ "flagged",
+ "unread",
+ "recipient",
+ "location",
+ "tags",
+ "junkStatus",
+ "attachments",
+ "account",
+ "custom",
+ "received",
+ "correspondent"
+ ]
+ },
+ "sortOrder": {
+ "type": "string",
+ "description": "Sorts the list of messages. ``sortType`` must also be given.",
+ "optional": true,
+ "enum": ["none", "ascending", "descending"]
+ },
+ "viewType": {
+ "type": "string",
+ "optional": true,
+ "enum": ["ungrouped", "groupedByThread", "groupedBySortType"]
+ },
+ "layout": {
+ "type": "string",
+ "description": "Sets the arrangement of the folder pane, message list pane, and message display pane. Note that setting this applies it to all mail tabs.",
+ "optional": true,
+ "enum": ["standard", "wide", "vertical"]
+ },
+ "folderPaneVisible": {
+ "type": "boolean",
+ "description": "Shows or hides the folder pane.",
+ "optional": true
+ },
+ "messagePaneVisible": {
+ "type": "boolean",
+ "description": "Shows or hides the message display pane.",
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "getSelectedMessages",
+ "type": "function",
+ "description": "Lists the selected messages in the current folder.",
+ "permissions": ["messagesRead"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "messages.MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setSelectedMessages",
+ "type": "function",
+ "description": "Selects none, one or multiple messages.",
+ "permissions": ["messagesRead", "accountsRead"],
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages, which should be selected. The mailTab will switch to the folder of the selected messages. Throws if they belong to different folders. Array can be empty to deselect any currently selected message.",
+ "items": {
+ "type": "integer"
+ }
+ }
+ ]
+ },
+ {
+ "name": "setQuickFilter",
+ "type": "function",
+ "description": "Sets the Quick Filter user interface based on the options specified.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "show": {
+ "type": "boolean",
+ "description": "Shows or hides the Quick Filter bar.",
+ "optional": true
+ },
+ "unread": {
+ "type": "boolean",
+ "description": "Shows only unread messages.",
+ "optional": true
+ },
+ "flagged": {
+ "type": "boolean",
+ "description": "Shows only flagged messages.",
+ "optional": true
+ },
+ "contact": {
+ "type": "boolean",
+ "description": "Shows only messages from people in the address book.",
+ "optional": true
+ },
+ "tags": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "boolean"
+ },
+ {
+ "$ref": "messages.TagsDetail"
+ }
+ ],
+ "description": "Shows only messages with tags on them."
+ },
+ "attachment": {
+ "type": "boolean",
+ "description": "Shows only messages with attachments.",
+ "optional": true
+ },
+ "text": {
+ "$ref": "QuickFilterTextDetail",
+ "description": "Shows only messages matching the supplied text.",
+ "optional": true
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onDisplayedFolderChanged",
+ "type": "function",
+ "description": "Fired when the displayed folder changes in any mail tab.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "displayedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onSelectedMessagesChanged",
+ "type": "function",
+ "description": "Fired when the selected messages change in any mail tab.",
+ "permissions": ["messagesRead"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "selectedMessages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/menus.json b/comm/mail/components/extensions/schemas/menus.json
new file mode 100644
index 0000000000..34167a87d8
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/menus.json
@@ -0,0 +1,757 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "PermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["menus"]
+ }
+ ]
+ },
+ {
+ "$extend": "OptionalPermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["menus.overrideContext"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "menus",
+ "permissions": ["menus"],
+ "description": "The menus API allows to add items to Thunderbird's menus. You can choose what types of objects your context menu additions apply to, such as images, hyperlinks, and pages.",
+ "properties": {
+ "ACTION_MENU_TOP_LEVEL_LIMIT": {
+ "value": 6,
+ "description": "The maximum number of top level extension items that can be added to an extension action context menu. Any items beyond this limit will be ignored."
+ }
+ },
+ "types": [
+ {
+ "id": "ContextType",
+ "choices": [
+ {
+ "type": "string",
+ "enum": [
+ "all",
+ "all_message_attachments",
+ "audio",
+ "compose_action",
+ "compose_action_menu",
+ "compose_attachments",
+ "compose_body",
+ "editable",
+ "folder_pane",
+ "frame",
+ "image",
+ "link",
+ "message_attachments",
+ "message_display_action",
+ "message_display_action_menu",
+ "message_list",
+ "page",
+ "password",
+ "selection",
+ "tab",
+ "tools_menu",
+ "video"
+ ]
+ },
+ {
+ "type": "string",
+ "max_manifest_version": 2,
+ "enum": ["browser_action", "browser_action_menu"]
+ },
+ {
+ "type": "string",
+ "min_manifest_version": 3,
+ "enum": ["action", "action_menu"]
+ }
+ ],
+ "description": "The different contexts a menu can appear in. Specifying <value>all</value> is equivalent to the combination of all other contexts excluding <value>tab</value> and <value>tools_menu</value>. More information about each context can be found in the `Supported UI Elements <|link-ui-elements|>`__ article on developer.thunderbird.net."
+ },
+ {
+ "id": "ItemType",
+ "type": "string",
+ "enum": ["normal", "checkbox", "radio", "separator"],
+ "description": "The type of menu item."
+ },
+ {
+ "id": "OnShowData",
+ "type": "object",
+ "description": "Information sent when a context menu is being shown. Some properties are only included if the extension has host permission for the given context, for example :permission:`activeTab` for content tabs, :permission:`compose` for compose tabs and :permission:`messagesRead` for message display tabs.",
+ "properties": {
+ "menuIds": {
+ "description": "A list of IDs of the menu items that were shown.",
+ "type": "array",
+ "items": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ },
+ "contexts": {
+ "description": "A list of all contexts that apply to the menu.",
+ "type": "array",
+ "items": {
+ "$ref": "ContextType"
+ }
+ },
+ "editable": {
+ "type": "boolean",
+ "description": "A flag indicating whether the element is editable (text input, textarea, etc.)."
+ },
+ "mediaType": {
+ "type": "string",
+ "optional": true,
+ "description": "One of <value>image</value>, <value>video</value>, or <value>audio</value> if the context menu was activated on one of these types of elements."
+ },
+ "viewType": {
+ "$ref": "extension.ViewType",
+ "optional": true,
+ "description": "The type of view where the menu is shown. May be unset if the menu is not associated with a view."
+ },
+ "linkText": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the text of that link. **Note:** Host permission is required."
+ },
+ "linkUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the URL it points to. **Note:** Host permission is required."
+ },
+ "srcUrl": {
+ "type": "string",
+ "description": "Will be present for elements with a <em>src</em> URL. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "pageUrl": {
+ "type": "string",
+ "description": "The URL of the page where the menu item was clicked. This property is not set if the click occurred in a context where there is no current page, such as in a launcher context menu. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "frameUrl": {
+ "type": "string",
+ "description": "The URL of the frame of the element where the context menu was clicked, if it was in a frame. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "selectionText": {
+ "type": "string",
+ "description": "The text for the context selection, if any. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "targetElementId": {
+ "type": "integer",
+ "optional": true,
+ "description": "An identifier of the clicked content element, if any. Use :ref:`menus.getTargetElement` in the page to find the corresponding element."
+ },
+ "fieldId": {
+ "type": "string",
+ "optional": true,
+ "description": "An identifier of the clicked Thunderbird UI element, if any.",
+ "enum": [
+ "composeSubject",
+ "composeTo",
+ "composeCc",
+ "composeBcc",
+ "composeReplyTo",
+ "composeNewsgroupTo"
+ ]
+ },
+ "selectedMessages": {
+ "$ref": "messages.MessageList",
+ "optional": true,
+ "description": "The selected messages, if the context menu was opened in the message list. The <permission>messagesRead</permission> permission is required."
+ },
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The displayed folder, if the context menu was opened in the message list. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The selected folder, if the context menu was opened in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedAccount": {
+ "$ref": "accounts.MailAccount",
+ "optional": true,
+ "description": "The selected account, if the context menu was opened on an account entry in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "attachments": {
+ "type": "array",
+ "optional": true,
+ "description": "The selected attachments. The <permission>compose</permission> permission is required to return attachments of a message being composed. The <permission>messagesRead</permission> permission is required to return attachments of displayed messages.",
+ "items": {
+ "choices": [
+ {
+ "$ref": "compose.ComposeAttachment"
+ },
+ {
+ "$ref": "messages.MessageAttachment"
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when a context menu item is clicked.",
+ "properties": {
+ "menuItemId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "The ID of the menu item that was clicked."
+ },
+ "parentMenuItemId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "The parent ID, if any, for the item clicked."
+ },
+ "editable": {
+ "type": "boolean",
+ "description": "A flag indicating whether the element is editable (text input, textarea, etc.)."
+ },
+ "mediaType": {
+ "type": "string",
+ "optional": true,
+ "description": "One of <value>image</value>, <value>video</value>, or <value>audio</value> if the context menu was activated on one of these types of elements."
+ },
+ "viewType": {
+ "$ref": "extension.ViewType",
+ "optional": true,
+ "description": "The type of view where the menu is clicked. May be unset if the menu is not associated with a view."
+ },
+ "linkText": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the text of that link."
+ },
+ "linkUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the URL it points to."
+ },
+ "srcUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "Will be present for elements with a <em>src</em> URL."
+ },
+ "pageUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "The URL of the page where the menu item was clicked. This property is not set if the click occurred in a context where there is no current page, such as in a launcher context menu."
+ },
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The id of the frame of the element where the context menu was clicked."
+ },
+ "frameUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "The URL of the frame of the element where the context menu was clicked, if it was in a frame."
+ },
+ "selectionText": {
+ "type": "string",
+ "optional": true,
+ "description": "The text for the context selection, if any."
+ },
+ "wasChecked": {
+ "type": "boolean",
+ "optional": true,
+ "description": "A flag indicating the state of a checkbox or radio item before it was clicked."
+ },
+ "checked": {
+ "type": "boolean",
+ "optional": true,
+ "description": "A flag indicating the state of a checkbox or radio item after it is clicked."
+ },
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ },
+ "targetElementId": {
+ "type": "integer",
+ "optional": true,
+ "description": "An identifier of the clicked content element, if any. Use :ref:`menus.getTargetElement` in the page to find the corresponding element."
+ },
+ "fieldId": {
+ "type": "string",
+ "optional": true,
+ "description": "An identifier of the clicked Thunderbird UI element, if any.",
+ "enum": [
+ "composeSubject",
+ "composeTo",
+ "composeCc",
+ "composeBcc",
+ "composeReplyTo",
+ "composeNewsgroupTo"
+ ]
+ },
+ "selectedMessages": {
+ "$ref": "messages.MessageList",
+ "optional": true,
+ "description": "The selected messages, if the context menu was opened in the message list. The <permission>messagesRead</permission> permission is required."
+ },
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The displayed folder, if the context menu was opened in the message list. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The selected folder, if the context menu was opened in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedAccount": {
+ "$ref": "accounts.MailAccount",
+ "optional": true,
+ "description": "The selected account, if the context menu was opened on an account entry in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "attachments": {
+ "type": "array",
+ "optional": true,
+ "description": "The selected attachments. The <permission>compose</permission> permission is required to return attachments of a message being composed. The <permission>messagesRead</permission> permission is required to return attachments of displayed messages.",
+ "items": {
+ "choices": [
+ {
+ "$ref": "compose.ComposeAttachment"
+ },
+ {
+ "$ref": "messages.MessageAttachment"
+ }
+ ]
+ }
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates a new context menu item. Note that if an error occurs during creation, you may not find out until the creation callback fires (the details will be in `runtime.lastError <|link-runtime-last-error|>`__).",
+ "returns": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "The ID of the newly created item."
+ },
+ "parameters": [
+ {
+ "type": "object",
+ "name": "createProperties",
+ "properties": {
+ "type": {
+ "$ref": "ItemType",
+ "optional": true,
+ "description": "The type of menu item. Defaults to <value>normal</value> if not specified."
+ },
+ "id": {
+ "type": "string",
+ "optional": true,
+ "description": "The unique ID to assign to this item. Mandatory for event pages. Cannot be the same as another ID for this extension."
+ },
+ "icons": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "Custom icons to display next to the menu item. Custom icons can only be set for items appearing in submenus."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The text to be displayed in the item; this is <em>required</em> unless ``type`` is <value>separator</value>. When the context is <value>selection</value>, you can use <value>%s</value> within the string to show the selected text. For example, if this parameter's value is <value>Translate '%s' to Latin</value> and the user selects the word <value>cool</value>, the context menu item for the selection is <value>Translate 'cool' to Latin</value>. To specify an access key for the new menu entry, include a <value>&</value> before the desired letter in the title. For example <value>&Help</value>."
+ },
+ "checked": {
+ "type": "boolean",
+ "optional": true,
+ "description": "The initial state of a checkbox or radio item: <value>true</value> for selected and <value>false</value> for unselected. Only one radio item can be selected at a time in a given group of radio items."
+ },
+ "contexts": {
+ "type": "array",
+ "items": {
+ "$ref": "ContextType"
+ },
+ "minItems": 1,
+ "optional": true,
+ "description": "List of contexts this menu item will appear in. Defaults to <value>['page']</value> if not specified."
+ },
+ "viewTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "extension.ViewType"
+ },
+ "minItems": 1,
+ "optional": true,
+ "description": "List of view types where the menu item will be shown. Defaults to any view, including those without a viewType."
+ },
+ "visible": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the item is visible in the menu."
+ },
+ "onclick": {
+ "type": "function",
+ "optional": true,
+ "description": "A function that will be called back when the menu item is clicked. Event pages cannot use this.",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "description": "Information about the item clicked and the context where the click happened."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the click took place."
+ }
+ ]
+ },
+ "parentId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "The ID of a parent menu item; this makes the item a child of a previously added item."
+ },
+ "documentUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true,
+ "description": "Lets you restrict the item to apply only to documents whose URL matches one of the given patterns. (This applies to frames as well.) For details on the format of a pattern, see `Match Patterns <|link-match-patterns|>`__."
+ },
+ "targetUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true,
+ "description": "Similar to documentUrlPatterns, but lets you filter based on the src attribute of img/audio/video tags and the href of anchor tags."
+ },
+ "enabled": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether this context menu item is enabled or disabled. Defaults to true."
+ },
+ "command": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "string",
+ "max_manifest_version": 2,
+ "description": "Specifies a command to issue for the context click. Currently supports internal commands <value>_execute_browser_action</value>, <value>_execute_compose_action</value> and <value>_execute_message_display_action</value>."
+ },
+ {
+ "type": "string",
+ "min_manifest_version": 3,
+ "description": "Specifies a command to issue for the context click. Currently supports internal commands <value>_execute_action</value>, <value>_execute_compose_action</value> and <value>_execute_message_display_action</value>."
+ }
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called when the item has been created in the browser. If there were any problems creating the item, details will be available in `runtime.lastError <|link-runtime-last-error|>`__.",
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Updates a previously created context menu item.",
+ "async": "callback",
+ "parameters": [
+ {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "name": "id",
+ "description": "The ID of the item to update."
+ },
+ {
+ "type": "object",
+ "name": "updateProperties",
+ "description": "The properties to update. Accepts the same values as the create function.",
+ "properties": {
+ "type": {
+ "$ref": "ItemType",
+ "optional": true
+ },
+ "icons": {
+ "$ref": "manifest.IconPath",
+ "optional": "omit-key-if-missing"
+ },
+ "title": {
+ "type": "string",
+ "optional": true
+ },
+ "checked": {
+ "type": "boolean",
+ "optional": true
+ },
+ "contexts": {
+ "type": "array",
+ "items": {
+ "$ref": "ContextType"
+ },
+ "minItems": 1,
+ "optional": true
+ },
+ "viewTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "extension.ViewType"
+ },
+ "minItems": 1,
+ "optional": true
+ },
+ "visible": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the item is visible in the menu."
+ },
+ "onclick": {
+ "type": "function",
+ "optional": "omit-key-if-missing",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnClickData"
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the click took place. **Note:** this parameter only present for extensions."
+ }
+ ]
+ },
+ "parentId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "**Note:** You cannot change an item to be a child of one of its own descendants."
+ },
+ "documentUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "targetUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "enabled": {
+ "type": "boolean",
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [],
+ "description": "Called when the context menu has been updated."
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Removes a context menu item.",
+ "async": "callback",
+ "parameters": [
+ {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "name": "menuItemId",
+ "description": "The ID of the context menu item to remove."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [],
+ "description": "Called when the context menu has been removed."
+ }
+ ]
+ },
+ {
+ "name": "removeAll",
+ "type": "function",
+ "description": "Removes all context menu items added by this extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [],
+ "description": "Called when removal is complete."
+ }
+ ]
+ },
+ {
+ "name": "overrideContext",
+ "permissions": ["menus.overrideContext"],
+ "type": "function",
+ "description": "Show the matching menu items from this extension instead of the default menu. This should be called during a `contextmenu <|link-contextmenu-event|>`__ event handler, and only applies to the menu that opens after this event.",
+ "parameters": [
+ {
+ "name": "contextOptions",
+ "type": "object",
+ "properties": {
+ "showDefaults": {
+ "type": "boolean",
+ "optional": true,
+ "default": false,
+ "description": "Whether to also include default menu items in the menu."
+ },
+ "context": {
+ "type": "string",
+ "enum": ["tab"],
+ "optional": true,
+ "description": "ContextType to override, to allow menu items from other extensions in the menu. Currently only <value>tab</value> is supported. ``contextOptions.showDefaults`` cannot be used with this option."
+ },
+ "tabId": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "Required when context is <value>tab</value>. Requires the <permission>tabs</permission> permission."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "refresh",
+ "type": "function",
+ "description": "Updates the extension items in the shown menu, including changes that have been made since the menu was shown. Has no effect if the menu is hidden. Rebuilding a shown menu is an expensive operation, only invoke this method when necessary.",
+ "async": true,
+ "parameters": []
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when a context menu item is clicked. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "description": "Information about the item clicked and the context where the click happened."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the click took place. If the click did not take place in a tab, this parameter will be missing.",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "onShown",
+ "type": "function",
+ "description": "Fired when a menu is shown. The extension can add, modify or remove menu items and call :ref:`menus.refresh` to update the menu.",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnShowData",
+ "description": "Information about the context of the menu action and the created menu items."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the menu was opened."
+ }
+ ]
+ },
+ {
+ "name": "onHidden",
+ "type": "function",
+ "description": "Fired when a menu is hidden. This event is only fired if onShown has fired before.",
+ "parameters": []
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/menus_child.json b/comm/mail/components/extensions/schemas/menus_child.json
new file mode 100644
index 0000000000..9bcbbcc7d3
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/menus_child.json
@@ -0,0 +1,31 @@
+[
+ {
+ "namespace": "menus",
+ "permissions": ["menus"],
+ "allowedContexts": ["content", "devtools"],
+ "description": "The part of the menus API that is available in all extension contexts, including content scripts.",
+ "functions": [
+ {
+ "name": "getTargetElement",
+ "type": "function",
+ "allowedContexts": ["content", "devtools"],
+ "description": "Retrieve the element that was associated with a recent `contextmenu <|link-contextmenu-event|>`__ event.",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "The identifier of the clicked element, available as ``info.targetElementId`` in the :ref:`menus.onShown` and :ref:`menus.onClicked` events.",
+ "name": "targetElementId"
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "Element",
+ "additionalProperties": {
+ "type": "any"
+ }
+ }
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/messageDisplay.json b/comm/mail/components/extensions/schemas/messageDisplay.json
new file mode 100644
index 0000000000..f7e3d4ae6d
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/messageDisplay.json
@@ -0,0 +1,159 @@
+[
+ {
+ "namespace": "messageDisplay",
+ "permissions": ["messagesRead"],
+ "events": [
+ {
+ "name": "onMessageDisplayed",
+ "type": "function",
+ "description": "Fired when a message is displayed, whether in a 3-pane tab, a message tab, or a message window.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "message",
+ "$ref": "messages.MessageHeader"
+ }
+ ]
+ },
+ {
+ "name": "onMessagesDisplayed",
+ "type": "function",
+ "description": "Fired when either a single message is displayed or when multiple messages are displayed, whether in a 3-pane tab, a message tab, or a message window.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "messages",
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ }
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getDisplayedMessage",
+ "type": "function",
+ "description": "Gets the currently displayed message in the specified tab (even if the tab itself is currently not visible). It returns <value>null</value> if no messages are displayed, or if multiple messages are displayed.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "choices": [
+ {
+ "$ref": "messages.MessageHeader"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getDisplayedMessages",
+ "type": "function",
+ "description": "Gets an array of the currently displayed messages in the specified tab (even if the tab itself is currently not visible). The array is empty if no messages are displayed.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "open",
+ "type": "function",
+ "description": "Opens a message in a new tab or in a new window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "openProperties",
+ "type": "object",
+ "description": "Settings for opening the message. Exactly one of messageId, headerMessageId or file must be specified.",
+ "properties": {
+ "file": {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "File",
+ "additionalProperties": true,
+ "description": "The DOM file object of a message to be opened."
+ },
+ "messageId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 1,
+ "description": "The id of a message to be opened. Will throw an <em>ExtensionError</em>, if the provided ``messageId`` is unknown or invalid."
+ },
+ "headerMessageId": {
+ "type": "string",
+ "optional": true,
+ "description": "The headerMessageId of a message to be opened. Will throw an <em>ExtensionError</em>, if the provided ``headerMessageId`` is unknown or invalid. Not supported for external messages."
+ },
+ "location": {
+ "type": "string",
+ "enum": ["tab", "window"],
+ "optional": true,
+ "description": "Where to open the message. If not specified, the users preference is honoured."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the new tab should become the active tab in the window. Only applicable to messages opened in tabs."
+ },
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The id of the window, where the new tab should be created. Defaults to the current window. Only applicable to messages opened in tabs."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/messageDisplayAction.json b/comm/mail/components/extensions/schemas/messageDisplayAction.json
new file mode 100644
index 0000000000..9beda1c68e
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/messageDisplayAction.json
@@ -0,0 +1,721 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "message_display_action": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the messageDisplayAction button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the messageDisplayAction button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the messageDisplayAction button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the messageDisplayAction button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_area": {
+ "description": "Currently unused.",
+ "type": "string",
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "messageDisplayAction",
+ "description": "Use a messageDisplayAction to put a button in the message display toolbar. In addition to its icon, a messageDisplayAction button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:message_display_action"],
+ "types": [
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "ImageDataType",
+ "type": "object",
+ "isInstanceOf": "ImageData",
+ "additionalProperties": {
+ "type": "any"
+ },
+ "postprocess": "convertImageDataToURL",
+ "description": "Pixel data for an image. Must be an |ImageData| object (for example, from a |Canvas| element)."
+ },
+ {
+ "id": "ImageDataDictionary",
+ "type": "object",
+ "description": "A <em>dictionary object</em> to specify multiple |ImageData| objects in different sizes, so the icon does not have to be scaled for a device with a different pixel density. Each entry is a <em>name-value</em> pair with <em>value</em> being an |ImageData| object, and <em>name</em> its size. Example: <literalinclude>includes/ImageDataDictionary.json<lang>JavaScript</lang></literalinclude>See the `MDN documentation about choosing icon sizes <|link-mdn-icon-size|>`__ for more information on this.",
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "$ref": "ImageDataType"
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when a messageDisplayAction button is clicked.",
+ "properties": {
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "setTitle",
+ "type": "function",
+ "description": "Sets the title of the messageDisplayAction button. Is used as tooltip and as the label.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "title": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the messageDisplayAction button should display as its label and when moused over. Cleared by setting it to <value>null</value> or an empty string (title defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the title only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getTitle",
+ "type": "function",
+ "description": "Gets the title of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the title should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setLabel",
+ "type": "function",
+ "description": "Sets the label of the messageDisplayAction button. Can be used to set different values for the tooltip (defined by the title) and the label. Additionally, the label can be set to an empty string, not showing any label at all.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "label": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the messageDisplayAction button should use as its label, overriding the defined title. Can be set to an empty string to not display any label at all. If the containing toolbar is configured to display text only, its title will be used. Cleared by setting it to <value>null</value>."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the label only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getLabel",
+ "type": "function",
+ "description": "Gets the label of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the label should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setIcon",
+ "type": "function",
+ "description": "Sets the icon for the messageDisplayAction button. Either the ``path`` or the ``imageData`` property must be specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "imageData": {
+ "choices": [
+ {
+ "$ref": "ImageDataType"
+ },
+ {
+ "$ref": "ImageDataDictionary"
+ }
+ ],
+ "optional": true,
+ "description": "The image data for one or more icons for the composeAction button."
+ },
+ "path": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "The paths to one or more icons for the messageDisplayAction button."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the icon only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "setPopup",
+ "type": "function",
+ "description": "Sets the html document to be opened as a popup when the user clicks on the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "popup": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The html file to show in a popup. Can be set to an empty string to not open a popup. Cleared by setting it to <value>null</value> (action will use the popup value defined in the manifest)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the popup only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getPopup",
+ "type": "function",
+ "description": "Gets the html document set as the popup for this messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the popup document should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeText",
+ "type": "function",
+ "description": "Sets the badge text for the messageDisplayAction button. The badge is displayed on top of the icon.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "text": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Any number of characters can be passed, but only about four can fit in the space. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the badge text only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeText",
+ "type": "function",
+ "description": "Gets the badge text of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge text should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeBackgroundColor",
+ "type": "function",
+ "description": "Sets the background color for the badge.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "color": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The color to use as background in the badge. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the background color for the badge only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeBackgroundColor",
+ "type": "function",
+ "description": "Gets the badge background color of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge background color should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "$ref": "ColorArray"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "enable",
+ "type": "function",
+ "description": "Enables the messageDisplayAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well. By default, a messageDisplayAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the messageDisplayAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "disable",
+ "type": "function",
+ "description": "Disables the messageDisplayAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the messageDisplayAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "isEnabled",
+ "type": "function",
+ "description": "Checks whether the messageDisplayAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the state should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openPopup",
+ "type": "function",
+ "description": "Opens the action's popup window in the specified window. Defaults to the current window. Returns false if the popup could not be opened because the action has no popup, is of type <value>menu</value>, is disabled or has been removed from the toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "optional": true,
+ "type": "object",
+ "description": "An object with information about the popup to open.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when a messageDisplayAction button is clicked. This event will not fire if the messageDisplayAction has a popup. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/messages.json b/comm/mail/components/extensions/schemas/messages.json
new file mode 100644
index 0000000000..6a025763a1
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/messages.json
@@ -0,0 +1,933 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": [
+ "messagesDelete",
+ "messagesImport",
+ "messagesMove",
+ "messagesRead",
+ "messagesTags",
+ "sensitiveDataUpload"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "messages",
+ "permissions": ["messagesRead"],
+ "types": [
+ {
+ "id": "MessageHeader",
+ "type": "object",
+ "description": "Basic information about a message.",
+ "properties": {
+ "author": {
+ "type": "string"
+ },
+ "bccList": {
+ "description": "The Bcc recipients. Not populated for news/nntp messages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "ccList": {
+ "description": "The Cc recipients. Not populated for news/nntp messages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "date": {
+ "$ref": "extensionTypes.Date"
+ },
+ "external": {
+ "type": "boolean",
+ "description": "Whether this message is a real message or an external message (opened from a file or from an attachment)."
+ },
+ "flagged": {
+ "type": "boolean",
+ "description": "Whether this message is flagged (a.k.a. starred)."
+ },
+ "folder": {
+ "$ref": "folders.MailFolder",
+ "description": "The <permission>accountsRead</permission> permission is required for this property to be included. Not available for external or attached messages.",
+ "optional": true
+ },
+ "headerMessageId": {
+ "type": "string",
+ "description": "The message-id header of the message."
+ },
+ "headersOnly": {
+ "description": "Some account types (for example <value>pop3</value>) allow to download only the headers of the message, but not its body. The body of such messages will not be available.",
+ "type": "boolean"
+ },
+ "id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "junk": {
+ "description": "Whether the message has been marked as junk. Always <value>false</value> for news/nntp messages and external messages.",
+ "type": "boolean"
+ },
+ "junkScore": {
+ "type": "integer",
+ "description": "The junk score associated with the message. Always <value>0</value> for news/nntp messages and external messages.",
+ "minimum": 0,
+ "maximum": 100
+ },
+ "read": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the message has been marked as read. Not available for external or attached messages."
+ },
+ "new": {
+ "type": "boolean",
+ "description": "Whether the message has been received recently and is marked as new."
+ },
+ "recipients": {
+ "description": "The To recipients. Not populated for news/nntp messages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "size": {
+ "description": "The total size of the message in bytes.",
+ "type": "integer"
+ },
+ "subject": {
+ "type": "string",
+ "description": "The subject of the message."
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags associated with this message. For a list of available tags, call the listTags method."
+ }
+ }
+ },
+ {
+ "id": "MessageList",
+ "type": "object",
+ "description": "See :doc:`how-to/messageLists` for more information.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "MessageHeader"
+ }
+ }
+ }
+ },
+ {
+ "id": "MessagePart",
+ "type": "object",
+ "description": "Represents an email message \"part\", which could be the whole message",
+ "properties": {
+ "body": {
+ "type": "string",
+ "description": "The content of the part",
+ "optional": true
+ },
+ "contentType": {
+ "type": "string",
+ "optional": true
+ },
+ "headers": {
+ "type": "object",
+ "description": "A <em>dictionary object</em> of part headers as <em>key-value</em> pairs, with the header name as <em>key</em>, and an array of headers as <em>value</em>",
+ "optional": true,
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the part, if it is a file",
+ "optional": true
+ },
+ "partName": {
+ "type": "string",
+ "optional": true,
+ "description": "The identifier of this part, used in :ref:`messages.getAttachmentFile`"
+ },
+ "parts": {
+ "type": "array",
+ "items": {
+ "$ref": "MessagePart"
+ },
+ "description": "Any sub-parts of this part",
+ "optional": true
+ },
+ "size": {
+ "type": "integer",
+ "optional": true,
+ "description": "The size of this part. The size of <em>message/*</em> parts is not the actual message size (on disc), but the total size of its decoded body parts, excluding headers."
+ }
+ }
+ },
+ {
+ "id": "MessageProperties",
+ "type": "object",
+ "description": "Message properties used in :ref:`messages.update` and :ref:`messages.import`. They can also be monitored by :ref:`messages.onUpdated`.",
+ "properties": {
+ "flagged": {
+ "type": "boolean",
+ "description": "Whether the message is flagged (a.k.a starred).",
+ "optional": true
+ },
+ "junk": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the message is marked as junk. Only supported in :ref:`messages.update`"
+ },
+ "new": {
+ "type": "boolean",
+ "description": "Whether the message is marked as new. Only supported in :ref:`messages.import`",
+ "optional": true
+ },
+ "read": {
+ "type": "boolean",
+ "description": "Whether the message is marked as read.",
+ "optional": true
+ },
+ "tags": {
+ "type": "array",
+ "description": "Tags associated with this message. For a list of available tags, call the listTags method.",
+ "optional": true,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "id": "MessageTag",
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string",
+ "description": "Unique tag identifier."
+ },
+ "tag": {
+ "type": "string",
+ "description": "Human-readable tag name."
+ },
+ "color": {
+ "type": "string",
+ "description": "Tag color."
+ },
+ "ordinal": {
+ "type": "string",
+ "description": "Custom sort string (usually empty)."
+ }
+ }
+ },
+ {
+ "id": "TagsDetail",
+ "type": "object",
+ "description": "Used for filtering messages by tag in various methods. Note that functions using this type may have a partial implementation.",
+ "properties": {
+ "tags": {
+ "type": "object",
+ "description": "A <em>dictionary object</em> with one or more filter condition as <em>key-value</em> pairs, the <em>key</em> being the tag to filter on, and the <em>value</em> being a boolean expression, requesting whether a message must include (<value>true</value>) or exclude (<value>false</value>) the tag. For a list of available tags, call the :ref:`messages.listTags` method.",
+ "patternProperties": {
+ ".*": {
+ "type": "boolean"
+ }
+ }
+ },
+ "mode": {
+ "type": "string",
+ "description": "Whether all of the tag filters must apply, or any of them.",
+ "enum": ["all", "any"]
+ }
+ }
+ },
+ {
+ "id": "MessageAttachment",
+ "type": "object",
+ "description": "Represents an attachment in a message.",
+ "properties": {
+ "contentType": {
+ "type": "string",
+ "description": "The content type of the attachment."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name, as displayed to the user, of this attachment. This is usually but not always the filename of the attached file."
+ },
+ "partName": {
+ "type": "string",
+ "description": "Identifies the MIME part of the message associated with this attachment."
+ },
+ "size": {
+ "type": "integer",
+ "description": "The size in bytes of this attachment."
+ },
+ "message": {
+ "$ref": "messages.MessageHeader",
+ "optional": true,
+ "description": "A MessageHeader, if this attachment is a message."
+ }
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when one or more properties of a message have been updated.",
+ "parameters": [
+ {
+ "name": "message",
+ "$ref": "messages.MessageHeader"
+ },
+ {
+ "name": "changedProperties",
+ "$ref": "messages.MessageProperties"
+ }
+ ]
+ },
+ {
+ "name": "onMoved",
+ "type": "function",
+ "description": "Fired when messages have been moved.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "originalMessages",
+ "$ref": "messages.MessageList"
+ },
+ {
+ "name": "movedMessages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ },
+ {
+ "name": "onCopied",
+ "type": "function",
+ "description": "Fired when messages have been copied.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "originalMessages",
+ "$ref": "messages.MessageList"
+ },
+ {
+ "name": "copiedMessages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when messages have been permanently deleted.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "messages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ },
+ {
+ "name": "onNewMailReceived",
+ "type": "function",
+ "description": "Fired when a new message is received, and has been through junk classification and message filters.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "messages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "description": "Gets all messages in a folder.",
+ "async": "callback",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "continueList",
+ "type": "function",
+ "description": "Returns the next chunk of messages in a list. See :doc:`how-to/messageLists` for more information.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageListId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Returns a specified message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageHeader"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getFull",
+ "type": "function",
+ "description": "Returns a specified message, including all headers and MIME parts. Throws if the message could not be read, for example due to network issues.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessagePart"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getRaw",
+ "type": "function",
+ "description": "Returns the unmodified source of a message. Throws if the message could not be read, for example due to network issues.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "name": "options",
+ "type": "object",
+ "properties": {
+ "data_format": {
+ "choices": [
+ {
+ "max_manifest_version": 2,
+ "description": "The message can either be returned as a DOM File or as a `binary string <|link-binary-string|>`__. The historic default is to return a binary string (kept for backward compatibility). However, it is now recommended to use the ``File`` format, because the DOM File object can be used as-is with the downloads API and has useful methods to access the content, like `File.text() <|link-DOMFile-text|>`__ and `File.arrayBuffer() <|link-DOMFile-arrayBuffer|>`__. Working with binary strings is error prone and needs special handling: <literalinclude>includes/messages/decodeBinaryString.js<lang>JavaScript</lang></literalinclude> (see MDN for `supported input encodings <|link-input-encoding|>`__).",
+ "type": "string",
+ "enum": ["File", "BinaryString"]
+ },
+ {
+ "min_manifest_version": 3,
+ "description": "The message can either be returned as a DOM File (default) or as a `binary string <|link-binary-string|>`__. It is recommended to use the ``File`` format, because the DOM File object can be used as-is with the downloads API and has useful methods to access the content, like `File.text() <|link-DOMFile-text|>`__ and `File.arrayBuffer() <|link-DOMFile-arrayBuffer|>`__. Working with binary strings is error prone and needs special handling: <literalinclude>includes/messages/decodeBinaryString.js<lang>JavaScript</lang></literalinclude> (see MDN for `supported input encodings <|link-input-encoding|>`__).",
+ "type": "string",
+ "enum": ["File", "BinaryString"]
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "listAttachments",
+ "type": "function",
+ "description": "Lists the attachments of a message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MessageAttachment"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAttachmentFile",
+ "type": "function",
+ "description": "Gets the content of a :ref:`messages.MessageAttachment` as a |File| object.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "name": "partName",
+ "type": "string",
+ "pattern": "^\\d+(\\.\\d+)*$"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openAttachment",
+ "type": "function",
+ "description": "Opens the specified attachment",
+ "async": true,
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "name": "partName",
+ "type": "string",
+ "pattern": "^\\d+(\\.\\d+)*$"
+ },
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "The ID of the tab associated with the message opening."
+ }
+ ]
+ },
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all messages that have the specified properties, or all messages if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "attachment": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If specified, returns only messages with or without attachments."
+ },
+ "author": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value matching the author. The search value is a single email address, a name or a combination (e.g.: <value>Name <user@domain.org></value>). The address part of the search value (if provided) must match the author's address completely. The name part of the search value (if provided) must match the author's name partially. All matches are done case-insensitive."
+ },
+ "body": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value in the body of the mail."
+ },
+ "flagged": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only flagged (or unflagged if false) messages."
+ },
+ "folder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "Returns only messages from the specified folder. The <permission>accountsRead</permission> permission is required."
+ },
+ "fromDate": {
+ "$ref": "extensionTypes.Date",
+ "optional": true,
+ "description": "Returns only messages with a date after this value."
+ },
+ "fromMe": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only messages with the author's address matching any configured identity."
+ },
+ "fullText": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value somewhere in the mail (subject, body or author)."
+ },
+ "headerMessageId": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with a Message-ID header matching this value."
+ },
+ "includeSubFolders": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Search the folder specified by ``queryInfo.folder`` recursively."
+ },
+ "recipients": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages whose recipients match all specified addresses. The search value is a semicolon separated list of email addresses, names or combinations (e.g.: <value>Name <user@domain.org></value>). For a match, all specified addresses must equal a recipient's address completely and all specified names must match a recipient's name partially. All matches are done case-insensitive."
+ },
+ "subject": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value matching the subject."
+ },
+ "tags": {
+ "$ref": "TagsDetail",
+ "optional": true,
+ "description": "Returns only messages with the specified tags. For a list of available tags, call the :ref:`messages.listTags` method."
+ },
+ "toDate": {
+ "$ref": "extensionTypes.Date",
+ "optional": true,
+ "description": "Returns only messages with a date before this value."
+ },
+ "toMe": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only messages with at least one recipient address matching any configured identity."
+ },
+ "unread": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only unread (or read if false) messages."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Marks or unmarks a message as junk, read, flagged, or tagged. Updating external messages will throw an <em>ExtensionError</em>.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "name": "newProperties",
+ "$ref": "MessageProperties"
+ }
+ ]
+ },
+ {
+ "name": "move",
+ "type": "function",
+ "description": "Moves messages to a specified folder. If the messages cannot be removed from the source folder, they will be copied instead of moved. Moving external messages will throw an <em>ExtensionError</em>.",
+ "async": true,
+ "permissions": ["accountsRead", "messagesMove"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to move.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "destination",
+ "$ref": "folders.MailFolder",
+ "description": "The folder to move the messages to."
+ }
+ ]
+ },
+ {
+ "name": "copy",
+ "type": "function",
+ "description": "Copies messages to a specified folder.",
+ "async": true,
+ "permissions": ["accountsRead", "messagesMove"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to copy.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "destination",
+ "$ref": "folders.MailFolder",
+ "description": "The folder to copy the messages to."
+ }
+ ]
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "description": "Deletes messages permanently, or moves them to the trash folder (honoring the account's deletion behavior settings). Deleting external messages will throw an <em>ExtensionError</em>. The ``skipTrash`` parameter allows immediate permanent deletion, bypassing the trash folder.\n**Note**: Consider using :ref:`messages.move` to manually move messages to the account's trash folder, instead of requesting the overly powerful permission to actually delete messages. The account's trash folder can be extracted as follows: <literalinclude>includes/messages/getTrash.js<lang>JavaScript</lang></literalinclude>",
+ "async": true,
+ "permissions": ["messagesDelete"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to delete.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "skipTrash",
+ "type": "boolean",
+ "description": "If true, the message will be deleted permanently, regardless of the account's deletion behavior settings.",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "import",
+ "type": "function",
+ "description": "Imports a message into a local Thunderbird folder. To import a message into an IMAP folder, add it to a local folder first and then move it to the IMAP folder.",
+ "async": "callback",
+ "permissions": ["accountsRead", "messagesImport"],
+ "parameters": [
+ {
+ "name": "file",
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ },
+ {
+ "name": "destination",
+ "$ref": "folders.MailFolder",
+ "description": "The folder to import the messages into."
+ },
+ {
+ "name": "properties",
+ "$ref": "messages.MessageProperties",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "messages.MessageHeader"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "archive",
+ "type": "function",
+ "description": "Archives messages using the current settings. Archiving external messages will throw an <em>ExtensionError</em>.",
+ "async": true,
+ "permissions": ["messagesMove"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to archive.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ ]
+ },
+ {
+ "name": "listTags",
+ "type": "function",
+ "description": "Returns a list of tags that can be set on messages, and their human-friendly name, colour, and sort order.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MessageTag"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "createTag",
+ "type": "function",
+ "description": "Creates a new message tag. Tagging a message will store the tag's key in the user's message. Throws if the specified tag key is used already.",
+ "async": true,
+ "permissions": ["messagesTags"],
+ "parameters": [
+ {
+ "type": "string",
+ "name": "key",
+ "description": "Unique tag identifier (will be converted to lower case). Must not include <value>()<>{/%*\"</value> or spaces.",
+ "pattern": "^[^ ()/{%*<>\"]+$"
+ },
+ {
+ "type": "string",
+ "name": "tag",
+ "description": "Human-readable tag name."
+ },
+ {
+ "type": "string",
+ "name": "color",
+ "description": "Tag color in hex format (i.e.: #000080 for navy blue)",
+ "pattern": "^#[0-9a-f]{6}"
+ }
+ ]
+ },
+ {
+ "name": "updateTag",
+ "type": "function",
+ "description": "Updates a message tag.",
+ "async": true,
+ "permissions": ["messagesTags"],
+ "parameters": [
+ {
+ "type": "string",
+ "name": "key",
+ "description": "Unique tag identifier (will be converted to lower case). Must not include <value>()<>{/%*\"</value> or spaces.",
+ "pattern": "^[^ ()/{%*<>\"]+$"
+ },
+ {
+ "type": "object",
+ "name": "updateProperties",
+ "properties": {
+ "tag": {
+ "type": "string",
+ "optional": "true",
+ "description": "Human-readable tag name."
+ },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9a-f]{6}",
+ "description": "Tag color in hex format (i.e.: #000080 for navy blue).",
+ "optional": "true"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "deleteTag",
+ "type": "function",
+ "description": "Deletes a message tag, removing it from the list of known tags. Its key will not be removed from tagged messages, but they will appear untagged. Recreating a deleted tag, will make all former tagged messages appear tagged again.",
+ "async": true,
+ "permissions": ["messagesTags"],
+ "parameters": [
+ {
+ "type": "string",
+ "name": "key",
+ "description": "Unique tag identifier (will be converted to lower case). Must not include <value>()<>{/%*\"</value> or spaces.",
+ "pattern": "^[^ ()/{%*<>\"]+$"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/sessions.json b/comm/mail/components/extensions/schemas/sessions.json
new file mode 100644
index 0000000000..3c2fdff165
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/sessions.json
@@ -0,0 +1,76 @@
+[
+ {
+ "namespace": "sessions",
+ "functions": [
+ {
+ "name": "setTabValue",
+ "type": "function",
+ "description": "Store a key/value pair associated with a given tab.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the tab with which you want to associate the data. Error is thrown if ID is invalid."
+ },
+ {
+ "name": "key",
+ "type": "string",
+ "description": "Key that you can later use to retrieve this particular data value."
+ },
+ {
+ "name": "value",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "getTabValue",
+ "type": "function",
+ "description": "Retrieve a previously stored value for a given tab, given its key.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the tab whose data you are trying to retrieve. Error is thrown if ID is invalid."
+ },
+ {
+ "name": "key",
+ "type": "string",
+ "description": "Key identifying the particular value to retrieve."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "removeTabValue",
+ "type": "function",
+ "description": "Remove a key/value pair from a given tab.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the tab whose data you are trying to remove. Error is thrown if ID is invalid."
+ },
+ {
+ "name": "key",
+ "type": "string",
+ "description": "Key identifying the particular value to remove."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/spaces.json b/comm/mail/components/extensions/schemas/spaces.json
new file mode 100644
index 0000000000..e94731f810
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/spaces.json
@@ -0,0 +1,290 @@
+[
+ {
+ "namespace": "spaces",
+ "types": [
+ {
+ "id": "SpaceButtonProperties",
+ "type": "object",
+ "properties": {
+ "badgeBackgroundColor": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ }
+ ],
+ "optional": true,
+ "description": "Sets the background color of the badge. Can be specified as an array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <value>[255, 0, 0, 255]</value>. Can also be a string with an HTML color name (<value>red</value>) or a HEX color value (<value>#FF0000</value> or <value>#F00</value>). Reset when set to an empty string."
+ },
+ "badgeText": {
+ "type": "string",
+ "optional": true,
+ "description": "Sets the badge text for the button in the spaces toolbar. The badge is displayed on top of the icon. Any number of characters can be set, but only about four can fit in the space. Removed when set to an empty string."
+ },
+ "defaultIcons": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "manifest.IconPath"
+ }
+ ],
+ "optional": true,
+ "description": "The paths to one or more icons for the button in the spaces toolbar. Defaults to the extension icon, if set to an empty string."
+ },
+ "themeIcons": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "manifest.ThemeIcons"
+ },
+ "description": "Specifies dark and light icons for the button in the spaces toolbar to be used with themes: The ``light`` icons will be used on dark backgrounds and vice versa. At least the set for <em>16px</em> icons should be specified. The set for <em>32px</em> icons will be used on screens with a very high pixel density, if specified."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The title for the button in the spaces toolbar, used in the tooltip of the button and as the displayed name in the overflow menu. Defaults to the name of the extension, if set to an empty string."
+ }
+ }
+ },
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "Space",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The name of the space. Names are unique for a single extension, but different extensions may use the same name."
+ },
+ "isBuiltIn": {
+ "type": "boolean",
+ "description": "Whether this space is one of the default Thunderbird spaces, or an extension space."
+ },
+ "isSelfOwned": {
+ "type": "boolean",
+ "description": "Whether this space was created by this extension."
+ },
+ "extensionId": {
+ "type": "string",
+ "optional": true,
+ "description": "The id of the extension which owns the space. The <permission>management</permission> permission is required to include this property."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates a new space and adds its button to the spaces toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The name to assign to this space. May only contain alphanumeric characters and underscores. Must be unique for this extension."
+ },
+ {
+ "name": "defaultUrl",
+ "type": "string",
+ "description": "The default space url, loaded into a tab when the button in the spaces toolbar is clicked. Supported are <value>https://</value> and <value>http://</value> links, as well as links to WebExtension pages."
+ },
+ {
+ "name": "buttonProperties",
+ "description": "Properties of the button for the new space.",
+ "$ref": "spaces.SpaceButtonProperties",
+ "optional": true,
+ "default": {}
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "space",
+ "$ref": "spaces.Space"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Retrieves details about the specified space.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "space",
+ "$ref": "spaces.Space"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all spaces that have the specified properties, or all spaces if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The id of the space.",
+ "optional": true,
+ "minimum": 1
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "optional": true,
+ "description": "The name of the spaces (names are not unique)."
+ },
+ "isBuiltIn": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Spaces should be default Thunderbird spaces."
+ },
+ "isSelfOwned": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Spaces should have been created by this extension."
+ },
+ "extensionId": {
+ "type": "string",
+ "optional": true,
+ "description": "Id of the extension which should own the spaces. The <permission>management</permission> permission is required to be able to match against extension ids."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "array",
+ "items": {
+ "$ref": "spaces.Space"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Removes the specified space, closes all its tabs and removes its button from the spaces toolbar. Throws an exception if the requested space does not exist or was not created by this extension.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Updates the specified space. Throws an exception if the requested space does not exist or was not created by this extension.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ {
+ "name": "defaultUrl",
+ "type": "string",
+ "description": "The default space url, loaded into a tab when the button in the spaces toolbar is clicked. Supported are <value>https://</value> and <value>http://</value> links, as well as links to WebExtension pages.",
+ "optional": true
+ },
+ {
+ "name": "buttonProperties",
+ "description": "Only specified button properties will be updated.",
+ "$ref": "spaces.SpaceButtonProperties",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "open",
+ "type": "function",
+ "description": "Opens or switches to the specified space. Throws an exception if the requested space does not exist or was not created by this extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ {
+ "name": "windowId",
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The id of the normal window, where the space should be opened. Defaults to the most recent normal window."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "optional": true,
+ "description": "Details about the opened or activated space tab."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/spacesToolbar.json b/comm/mail/components/extensions/schemas/spacesToolbar.json
new file mode 100644
index 0000000000..50beab1367
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/spacesToolbar.json
@@ -0,0 +1,175 @@
+[
+ {
+ "namespace": "spacesToolbar",
+ "max_manifest_version": 2,
+ "types": [
+ {
+ "id": "ButtonProperties",
+ "type": "object",
+ "properties": {
+ "badgeBackgroundColor": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ }
+ ],
+ "optional": true,
+ "description": "Sets the background color of the badge. Can be specified as an array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <value>[255, 0, 0, 255]</value>. Can also be a string with an HTML color name (<value>red</value>) or a HEX color value (<value>#FF0000</value> or <value>#F00</value>). Reset when set to an empty string."
+ },
+ "badgeText": {
+ "type": "string",
+ "optional": true,
+ "description": "Sets the badge text for the spaces toolbar button. The badge is displayed on top of the icon. Any number of characters can be set, but only about four can fit in the space. Removed when set to an empty string."
+ },
+ "defaultIcons": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "manifest.IconPath"
+ }
+ ],
+ "optional": true,
+ "description": "The paths to one or more icons for the button in the spaces toolbar. Defaults to the extension icon, if set to an empty string."
+ },
+ "themeIcons": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "manifest.ThemeIcons"
+ },
+ "description": "Specifies dark and light icons for the spaces toolbar button to be used with themes: The ``light`` icons will be used on dark backgrounds and vice versa. At least the set for <em>16px</em> icons should be specified. The set for <em>32px</em> icons will be used on screens with a very high pixel density, if specified."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The title for the spaces toolbar button, used in the tooltip of the button and as the displayed name in the overflow menu. Defaults to the name of the extension, if set to an empty string."
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "The page url, loaded into a tab when the button is clicked. Supported are <value>https://</value> and <value>http://</value> links, as well as links to WebExtension pages."
+ }
+ }
+ },
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ }
+ ],
+ "functions": [
+ {
+ "name": "addButton",
+ "type": "function",
+ "description": "Adds a new button to the spaces toolbar. Throws an exception, if the used ``id`` is not unique within the extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The unique id to assign to this button. May only contain alphanumeric characters and underscores."
+ },
+ {
+ "name": "properties",
+ "description": "Properties of the new button. The ``url`` is mandatory.",
+ "$ref": "spacesToolbar.ButtonProperties"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space belonging to the newly created button, as used by the tabs API.",
+ "minimum": 1,
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "removeButton",
+ "type": "function",
+ "description": "Removes the specified button from the spaces toolbar. Throws an exception if the requested spaces toolbar button does not exist or was not created by this extension. If the tab of this button is currently open, it will be closed.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The id of the spaces toolbar button, which is to be removed. May only contain alphanumeric characters and underscores."
+ }
+ ]
+ },
+ {
+ "name": "updateButton",
+ "type": "function",
+ "description": "Updates properties of the specified spaces toolbar button. Throws an exception if the requested spaces toolbar button does not exist or was not created by this extension.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "description": "The id of the spaces toolbar button, which is to be updated. May only contain alphanumeric characters and underscores.",
+ "pattern": "^[a-zA-Z0-9_]+$"
+ },
+ {
+ "name": "properties",
+ "description": "Only specified properties will be updated.",
+ "$ref": "spacesToolbar.ButtonProperties"
+ }
+ ]
+ },
+ {
+ "name": "clickButton",
+ "type": "function",
+ "description": "Trigger a click on the specified spaces toolbar button. Throws an exception if the requested spaces toolbar button does not exist or was not created by this extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "description": "The id of the spaces toolbar button. May only contain alphanumeric characters and underscores.",
+ "pattern": "^[a-zA-Z0-9_]+$"
+ },
+ {
+ "name": "windowId",
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The id of the normal window, where the spaces toolbar button should be clicked. Defaults to the most recent normal window."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "optional": true,
+ "description": "Details about the opened or activated tab."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/tabs.json b/comm/mail/components/extensions/schemas/tabs.json
new file mode 100644
index 0000000000..7d68f01b32
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/tabs.json
@@ -0,0 +1,989 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["activeTab"]
+ }
+ ]
+ },
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["tabs", "tabHide"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "tabs",
+ "description": "The tabs API supports creating, modifying and interacting with tabs in Thunderbird windows.",
+ "types": [
+ {
+ "id": "Tab",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The ID of the tab. Tab IDs are unique within a session. Under some circumstances a Tab may not be assigned an ID. Tab ID can also be set to :ref:`tabs.TAB_ID_NONE` for apps and devtools windows."
+ },
+ "index": {
+ "type": "integer",
+ "minimum": -1,
+ "description": "The zero-based index of the tab within its window."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The ID of the window the tab is contained within."
+ },
+ "selected": {
+ "type": "boolean",
+ "description": "Whether the tab is selected.",
+ "deprecated": "Please use :ref:`tabs.Tab.highlighted`.",
+ "unsupported": true
+ },
+ "highlighted": {
+ "type": "boolean",
+ "description": "Whether the tab is highlighted. Works as an alias of active"
+ },
+ "active": {
+ "type": "boolean",
+ "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)"
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "permissions": ["tabs"],
+ "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <permission>tabs</permission> permission."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "permissions": ["tabs"],
+ "description": "The title of the tab. This property is only present if the extension's manifest includes the <permission>tabs</permission> permission."
+ },
+ "favIconUrl": {
+ "type": "string",
+ "optional": true,
+ "permissions": ["tabs"],
+ "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <permission>tabs</permission> permission. It may also be an empty string if the tab is loading."
+ },
+ "status": {
+ "type": "string",
+ "optional": true,
+ "description": "Either <value>loading</value> or <value>complete</value>."
+ },
+ "width": {
+ "type": "integer",
+ "optional": true,
+ "description": "The width of the tab in pixels."
+ },
+ "height": {
+ "type": "integer",
+ "optional": true,
+ "description": "The height of the tab in pixels."
+ },
+ "cookieStoreId": {
+ "type": "string",
+ "optional": true,
+ "description": "The `CookieStore <|link-cookieStore|>`__ id used by the tab. Either a custom id created using the `contextualIdentities API <|link-contextualIdentity|>`__, or a built-in one: <value>firefox-default</value>, <value>firefox-container-1</value>, <value>firefox-container-2</value>, <value>firefox-container-3</value>, <value>firefox-container-4</value>, <value>firefox-container-5</value>. **Note:** The naming pattern was deliberately not changed for Thunderbird, but kept for compatibility reasons."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "addressBook",
+ "calendar",
+ "calendarEvent",
+ "calendarTask",
+ "chat",
+ "content",
+ "mail",
+ "messageCompose",
+ "messageDisplay",
+ "special",
+ "tasks"
+ ],
+ "optional": true
+ },
+ "mailTab": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab is a 3-pane tab."
+ },
+ "spaceId": {
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1,
+ "optional": true
+ }
+ }
+ },
+ {
+ "id": "TabStatus",
+ "type": "string",
+ "enum": ["loading", "complete"],
+ "description": "Whether the tabs have completed loading."
+ },
+ {
+ "id": "WindowType",
+ "type": "string",
+ "description": "The type of a window. Under some circumstances a Window may not be assigned a type property.",
+ "enum": [
+ "normal",
+ "popup",
+ "panel",
+ "app",
+ "devtools",
+ "messageCompose",
+ "messageDisplay"
+ ]
+ },
+ {
+ "id": "UpdatePropertyName",
+ "type": "string",
+ "enum": ["favIconUrl", "status", "title"],
+ "description": "Event names supported in onUpdated."
+ },
+ {
+ "id": "UpdateFilter",
+ "type": "object",
+ "description": "An object describing filters to apply to tabs.onUpdated events.",
+ "properties": {
+ "urls": {
+ "type": "array",
+ "description": "A list of URLs or URL patterns. Events that cannot match any of the URLs will be filtered out. Filtering with urls requires the <permission>tabs</permission> or <permission>activeTab</permission> permission.",
+ "optional": true,
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "properties": {
+ "type": "array",
+ "optional": true,
+ "description": "A list of property names. Events that do not match any of the names will be filtered out.",
+ "items": {
+ "$ref": "UpdatePropertyName"
+ },
+ "minItems": 1
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "properties": {
+ "TAB_ID_NONE": {
+ "value": -1,
+ "description": "An ID which represents the absence of a tab."
+ }
+ },
+ "functions": [
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Retrieves details about the specified tab.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "description": "Gets the tab that this script call is being made from. May be undefined if called from a non-tab context (for example: a background page or popup view).",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "connect",
+ "type": "function",
+ "description": "Connects to the content script(s) in the specified tab. The `runtime.onConnect <|link-runtime-on-connect|>`__ event is fired in each content script running in the specified tab for the current extension. For more details, see `Content Script Messaging <|link-content-scripts|>`__.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "connectInfo",
+ "properties": {
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "Will be passed into onConnect for content scripts that are listening for the connection event."
+ },
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Open a port to a specific frame identified by ``frameId`` instead of all frames in the tab."
+ }
+ },
+ "optional": true
+ }
+ ],
+ "returns": {
+ "$ref": "runtime.Port",
+ "description": "A port that can be used to communicate with the content scripts running in the specified tab."
+ }
+ },
+ {
+ "name": "sendMessage",
+ "type": "function",
+ "description": "Sends a single message to the content script(s) in the specified tab, with an optional callback to run when a response is sent back. The `runtime.onMessage <|link-runtime-on-message|>`__ event is fired in each content script running in the specified tab for the current extension.",
+ "async": "responseCallback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "any",
+ "name": "message"
+ },
+ {
+ "type": "object",
+ "name": "options",
+ "properties": {
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Send a message to a specific frame identified by ``frameId`` instead of all frames in the tab."
+ }
+ },
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "responseCallback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "response",
+ "type": "any",
+ "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback will be called with no arguments and `runtime.lastError <|link-runtime-last-error|>`__ will be set to the error message."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates a new content tab. Use the :ref:`messageDisplay_api` to open messages. Only supported in <value>normal</value> windows. Same-site links in the loaded page are opened within Thunderbird, all other links are opened in the user's default browser. To override this behavior, add-ons have to register a `content script <https://bugzilla.mozilla.org/show_bug.cgi?id=1618828#c3>`__ , capture click events and handle them manually.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "createProperties",
+ "description": "Properties for the new tab. Defaults to an empty tab, if no ``url`` is provided.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The window to create the new tab in. Defaults to the current window."
+ },
+ "index": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The position the tab should take in the window. The provided value will be clamped to between zero and the number of tabs in the window."
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "The URL to navigate the tab to initially. Fully-qualified URLs must include a scheme (i.e. <value>http://www.google.com</value>, not <value>www.google.com</value>). Relative URLs will be relative to the current page within the extension."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab should become the active tab in the window. Does not affect whether the window is focused (see :ref:`windows.update`). Defaults to <value>true</value>."
+ },
+ "cookieStoreId": {
+ "type": "string",
+ "optional": true,
+ "description": "The `CookieStore <|link-cookieStore|>`__ id the new tab should use. Either a custom id created using the `contextualIdentities API <|link-contextualIdentity|>`__, or a built-in one: <value>firefox-default</value>, <value>firefox-container-1</value>, <value>firefox-container-2</value>, <value>firefox-container-3</value>, <value>firefox-container-4</value>, <value>firefox-container-5</value>. **Note:** The naming pattern was deliberately not changed for Thunderbird, but kept for compatibility reasons."
+ },
+ "selected": {
+ "deprecated": "Please use ``createProperties.active``.",
+ "unsupported": true,
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab should become the selected tab in the window. Defaults to <value>true</value>"
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab",
+ "optional": true,
+ "description": "Details about the created tab. Will contain the ID of the new tab."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "duplicate",
+ "type": "function",
+ "description": "Duplicates a tab.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The ID of the tab which is to be duplicated."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "optional": true,
+ "description": "Details about the duplicated tab. The :ref:`tabs.Tab` object doesn't contain ``url``, ``title`` and ``favIconUrl`` if the <permission>tabs</permission> permission has not been requested.",
+ "$ref": "Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all tabs that have the specified properties, or all tabs if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "mailTab": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab is a Thunderbird 3-pane tab."
+ },
+ "spaceId": {
+ "type": "integer",
+ "description": "The id of the space the tabs should belong to.",
+ "minimum": 1,
+ "optional": true
+ },
+ "type": {
+ "type": "string",
+ "optional": true,
+ "description": "Match tabs against the given Tab.type (see :ref:`tabs.Tab`). Ignored if ``queryInfo.mailTab`` is specified."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are active in their windows."
+ },
+ "highlighted": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are highlighted. Works as an alias of active."
+ },
+ "currentWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the current window."
+ },
+ "lastFocusedWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the last focused window."
+ },
+ "status": {
+ "$ref": "TabStatus",
+ "optional": true,
+ "description": "Whether the tabs have completed loading."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "Match page titles against a pattern."
+ },
+ "url": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "optional": true,
+ "description": "Match tabs against one or more `URL Patterns <|link-match-patterns|>`__. Note that fragment identifiers are not matched."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "description": "The ID of the parent window, or :ref:`windows.WINDOW_ID_CURRENT` for the current window."
+ },
+ "windowType": {
+ "$ref": "WindowType",
+ "optional": true,
+ "description": "The type of window the tabs are in."
+ },
+ "index": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The position of the tabs within their windows."
+ },
+ "cookieStoreId": {
+ "choices": [
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "The `CookieStore <|link-cookieStore|>`__ id(s) used by the tabs. Either custom ids created using the `contextualIdentities API <|link-contextualIdentity|>`__, or built-in ones: <value>firefox-default</value>, <value>firefox-container-1</value>, <value>firefox-container-2</value>, <value>firefox-container-3</value>, <value>firefox-container-4</value>, <value>firefox-container-5</value>. **Note:** The naming pattern was deliberately not changed for Thunderbird, but kept for compatibility reasons."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "array",
+ "items": {
+ "$ref": "Tab"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Modifies the properties of a tab. Properties that are not specified in ``updateProperties`` are not modified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "Defaults to the selected tab of the current window."
+ },
+ {
+ "type": "object",
+ "name": "updateProperties",
+ "description": "Properties which should to be updated.",
+ "properties": {
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "A URL of a page to load. If the URL points to a content page (a web page, an extension page or a registered WebExtension protocol handler page), the tab will navigate to the requested page. All other URLs will be opened externally without changing the tab. Note: This function will throw an error, if a content page is loaded into a non-content tab (its type must be either <value>content</value> or <value>mail</value>)."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Set this to <value>true</value>, if the tab should become active. Does not affect whether the window is focused (see :ref:`windows.update`). Setting this to <value>false</value> has no effect."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab",
+ "optional": true,
+ "description": "Details about the updated tab. The :ref:`tabs.Tab` object doesn't contain ``url``, ``title`` and ``favIconUrl`` if the <permission>tabs</permission> permission has not been requested."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "move",
+ "type": "function",
+ "description": "Moves one or more tabs to a new position within its current window, or to a different window. Note that tabs can only be moved to and from windows of type <value>normal</value>.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabIds",
+ "description": "The tab or list of tabs to move.",
+ "choices": [
+ {
+ "type": "integer",
+ "minimum": 0
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ ]
+ },
+ {
+ "type": "object",
+ "name": "moveProperties",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the window the tab is currently in."
+ },
+ "index": {
+ "type": "integer",
+ "minimum": -1,
+ "description": "The position to move the tab to. <value>-1</value> will place the tab at the end of the window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tabs",
+ "description": "Details about the moved tabs.",
+ "type": "array",
+ "items": {
+ "$ref": "Tab"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "reload",
+ "type": "function",
+ "description": "Reload a tab. Only applicable for tabs which display a content page.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab to reload; defaults to the selected tab of the current window."
+ },
+ {
+ "type": "object",
+ "name": "reloadProperties",
+ "optional": true,
+ "properties": {
+ "bypassCache": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether using any local cache. Default is false."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Closes one or more tabs.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabIds",
+ "description": "The tab or list of tabs to close.",
+ "choices": [
+ {
+ "type": "integer",
+ "minimum": 0
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "executeScript",
+ "type": "function",
+ "description": "Injects JavaScript code into a page. For details, see the `programmatic injection <|link-content-scripts|>`__ section of the content scripts doc.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab in which to run the script; defaults to the active tab of the current window."
+ },
+ {
+ "$ref": "extensionTypes.InjectDetails",
+ "name": "details",
+ "description": "Details of the script to run."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called after all the JavaScript has been executed.",
+ "parameters": [
+ {
+ "name": "result",
+ "optional": true,
+ "type": "array",
+ "items": {
+ "type": "any"
+ },
+ "description": "The result of the script in every injected frame."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "insertCSS",
+ "type": "function",
+ "description": "Injects CSS into a page. For details, see the `programmatic injection <|link-content-scripts|>`__ section of the content scripts doc.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab in which to insert the CSS; defaults to the active tab of the current window."
+ },
+ {
+ "$ref": "extensionTypes.InjectDetails",
+ "name": "details",
+ "description": "Details of the CSS text to insert."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called when all the CSS has been inserted.",
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "removeCSS",
+ "type": "function",
+ "description": "Removes injected CSS from a page. For details, see the `programmatic injection <|link-content-scripts|>`__ section of the content scripts doc.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab from which to remove the injected CSS; defaults to the active tab of the current window."
+ },
+ {
+ "$ref": "extensionTypes.InjectDetails",
+ "name": "details",
+ "description": "Details of the CSS text to remove."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called when all the CSS has been removed.",
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
+ "parameters": [
+ {
+ "$ref": "Tab",
+ "name": "tab",
+ "description": "Details of the tab that was created."
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a tab is updated.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "changeInfo",
+ "description": "Lists the changes to the state of the tab that was updated.",
+ "properties": {
+ "status": {
+ "type": "string",
+ "optional": true,
+ "description": "The status of the tab. Can be either <value>loading</value> or <value>complete</value>."
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "The tab's URL if it has changed."
+ },
+ "favIconUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "The tab's new favicon URL."
+ }
+ }
+ },
+ {
+ "$ref": "Tab",
+ "name": "tab",
+ "description": "Gives the state of the tab that was updated."
+ }
+ ],
+ "extraParameters": [
+ {
+ "$ref": "UpdateFilter",
+ "name": "filter",
+ "optional": true,
+ "description": "A set of filters that restricts the events that will be sent to this listener."
+ }
+ ]
+ },
+ {
+ "name": "onMoved",
+ "type": "function",
+ "description": "Fired when a tab is moved within a window. Only one move event is fired, representing the tab the user directly moved. Move events are not fired for the other tabs that must move in response. This event is not fired when a tab is moved between windows. For that, see :ref:`tabs.onDetached`.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "moveInfo",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "fromIndex": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "toIndex": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onActivated",
+ "type": "function",
+ "description": "Fires when the active tab in a window changes. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "activeInfo",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The ID of the tab that has become active."
+ },
+ "previousTabId": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab that was previously active, if that tab is still open."
+ },
+ "windowId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The ID of the window the active tab changed inside of."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onDetached",
+ "type": "function",
+ "description": "Fired when a tab is detached from a window, for example because it is being moved between windows.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "detachInfo",
+ "properties": {
+ "oldWindowId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "oldPosition": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onAttached",
+ "type": "function",
+ "description": "Fired when a tab is attached to a window, for example because it was moved between windows.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "attachInfo",
+ "properties": {
+ "newWindowId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "newPosition": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onRemoved",
+ "type": "function",
+ "description": "Fired when a tab is closed.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "removeInfo",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The window whose tab is closed."
+ },
+ "isWindowClosing": {
+ "type": "boolean",
+ "description": "Is <value>true</value> when the tab is being closed because its window is being closed."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/theme.json b/comm/mail/components/extensions/schemas/theme.json
new file mode 100644
index 0000000000..cba8abd780
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/theme.json
@@ -0,0 +1,542 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "PermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["theme"]
+ }
+ ]
+ },
+ {
+ "id": "ThemeColor",
+ "description": "Defines a color value.",
+ "choices": [
+ {
+ "type": "string",
+ "description": "A string containing a valid `CSS color string <|link-css-color-string|>`__, including hexadecimal or functional representations. For example the color *crimson* can be specified as: <li><value>crimson</value> <li><value>#dc143c</value> <li><value>rgb(220, 20, 60)</value> (or <value>rgba(220, 20, 60, 0.5)</value> to set 50% opacity) <li><value>hsl(348, 83%, 47%)</value> (or <value>hsla(348, 83%, 47%, 0.5)</value> to set 50% opacity)"
+ },
+ {
+ "type": "array",
+ "description": "An RGB array of 3 integers. For example <value>[220, 20, 60]</value> for the color *crimson*.",
+ "minItems": 3,
+ "maxItems": 3,
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ }
+ },
+ {
+ "type": "array",
+ "description": "An RGBA array of 3 integers and a fractional (a float between 0 and 1). For example <value>[220, 20, 60, 0.5]<value> for the color *crimson* with 50% opacity.",
+ "minItems": 4,
+ "maxItems": 4,
+ "items": {
+ "type": "number"
+ }
+ }
+ ]
+ },
+ {
+ "id": "ThemeExperiment",
+ "description": "Defines additional color, image and property keys to be used in :ref:`theme.ThemeType`, extending the theme-able areas of Thunderbird.",
+ "type": "object",
+ "properties": {
+ "stylesheet": {
+ "optional": true,
+ "description": "URL to a stylesheet introducing additional CSS variables, extending the theme-able areas of Thunderbird. The `theme_experiment add-on in our example repository <https://github.com/thunderbird/sample-extensions/tree/master/theme_experiment>`__ is using the stylesheet shown below, to add the <value>--chat-button-color</value> CSS color variable: <literalinclude>includes/theme/theme_experiment_style.css<lang>CSS</lang></literalinclude>The following <em>manifest.json</em> file maps the </value>--chat-button-color</value> CSS color variable to the theme color key <value>exp_chat_button</value> and uses it to set a color for the chat button: <literalinclude>includes/theme/theme_experiment_manifest.json<lang>JSON</lang></literalinclude>",
+ "$ref": "ExtensionURL"
+ },
+ "images": {
+ "type": "object",
+ "optional": true,
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map new theme image keys to internal Thunderbird CSS image variables. The new image key is usable as an image reference in :ref:`theme.ThemeType`. Example: <literalinclude>includes/theme/theme_experiment_image.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "colors": {
+ "type": "object",
+ "optional": true,
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map new theme color keys to internal Thunderbird CSS color variables. The example shown below maps the theme color key <value>popup_affordance</value> to the CSS color variable </value>--arrowpanel-dimmed</value>. The new color key is usable as a color reference in :ref:`theme.ThemeType`. <literalinclude>includes/theme/theme_experiment_color.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "properties": {
+ "type": "object",
+ "optional": true,
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map new theme property keys to internal Thunderbird CSS property variables. The new property key is usable as a property reference in :ref:`theme.ThemeType`. Example: <literalinclude>includes/theme/theme_experiment_property.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "id": "ThemeType",
+ "description": "Contains the color, image and property settings of a theme.",
+ "type": "object",
+ "properties": {
+ "images": {
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map images to theme image keys. The following built-in theme image keys are supported:",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "additional_backgrounds": {
+ "type": "array",
+ "items": {
+ "$ref": "ImageDataOrExtensionURL"
+ },
+ "maxItems": 15,
+ "optional": true,
+ "description": "Additional images added to the header area and displayed behind the ``theme_frame`` image."
+ },
+ "headerURL": {
+ "$ref": "ImageDataOrExtensionURL",
+ "optional": true,
+ "deprecated": "Unsupported images property, use ``theme.images.theme_frame``, this alias is ignored in Thunderbird >= 70."
+ },
+ "theme_frame": {
+ "$ref": "ImageDataOrExtensionURL",
+ "optional": true,
+ "description": "Foreground image on the header area."
+ }
+ },
+ "additionalProperties": {
+ "$ref": "ImageDataOrExtensionURL"
+ }
+ },
+ "colors": {
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map color values to theme color keys. The following built-in theme color keys are supported:",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "tab_selected": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Background color of the selected tab. Defaults to the color specified by ``toolbar``."
+ },
+ "accentcolor": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "deprecated": "Unsupported colors property, use ``theme.colors.frame``, this alias is ignored in Thunderbird >= 70."
+ },
+ "frame": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the header area."
+ },
+ "frame_inactive": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the header area when the window is inactive."
+ },
+ "textcolor": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "deprecated": "Unsupported color property, use ``theme.colors.tab_background_text``, this alias is ignored in Thunderbird >= 70."
+ },
+ "tab_background_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of the unselected tabs."
+ },
+ "tab_background_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the vertical separator of the background tabs."
+ },
+ "tab_loading": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the tab loading indicator."
+ },
+ "tab_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color for the selected tab. Defaults to the color specified by ``toolbar_text``."
+ },
+ "tab_line": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the selected tab line."
+ },
+ "toolbar": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the toolbars. Also used as default value for ``tab_selected``."
+ },
+ "toolbar_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color in the main Thunderbird toolbar. Also used as default value for ``icons`` and ``tab_text``."
+ },
+ "bookmark_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird."
+ },
+ "toolbar_field": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color for fields in the toolbar, such as the search field."
+ },
+ "toolbar_field_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color for fields in the toolbar."
+ },
+ "toolbar_field_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color for fields in the toolbar."
+ },
+ "toolbar_field_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird.",
+ "deprecated": "This color property is ignored in >= 89."
+ },
+ "toolbar_top_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the line separating the top of the toolbar from the region above."
+ },
+ "toolbar_bottom_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the line separating the bottom of the toolbar from the region below."
+ },
+ "toolbar_vertical_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the vertical separators on the toolbars."
+ },
+ "icons": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the toolbar icons. Defaults to the color specified by ``toolbar_text``."
+ },
+ "icons_attention": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the toolbar icons in attention state such as the chat icon with new messages."
+ },
+ "button_background_hover": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the background of the toolbar buttons on hover."
+ },
+ "button_background_active": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the background of the pressed toolbar buttons."
+ },
+ "popup": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of popups such as the AppMenu."
+ },
+ "popup_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of popups."
+ },
+ "popup_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color of popups."
+ },
+ "toolbar_field_focus": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The focused background color for fields in the toolbar."
+ },
+ "toolbar_field_text_focus": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color in the focused fields in the toolbar."
+ },
+ "toolbar_field_border_focus": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The focused border color for fields in the toolbar."
+ },
+ "popup_highlight": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of items highlighted using the keyboard inside popups."
+ },
+ "popup_highlight_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of items highlighted using the keyboard inside popups."
+ },
+ "ntp_background": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird."
+ },
+ "ntp_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird."
+ },
+ "sidebar": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the trees."
+ },
+ "sidebar_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color of the trees."
+ },
+ "sidebar_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of the trees. Needed to enable the tree theming."
+ },
+ "sidebar_highlight": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of highlighted rows in trees."
+ },
+ "sidebar_highlight_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of highlighted rows in trees."
+ },
+ "sidebar_highlight_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color of highlighted rows in trees."
+ },
+ "toolbar_field_highlight": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color used to indicate the current selection of text in the search field."
+ },
+ "toolbar_field_highlight_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color used to draw text that's currently selected in the search field."
+ }
+ },
+ "additionalProperties": {
+ "$ref": "ThemeColor"
+ }
+ },
+ "properties": {
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map property values to theme property keys. The following built-in theme property keys are supported:",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "additional_backgrounds_alignment": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "bottom",
+ "center",
+ "left",
+ "right",
+ "top",
+ "center bottom",
+ "center center",
+ "center top",
+ "left bottom",
+ "left center",
+ "left top",
+ "right bottom",
+ "right center",
+ "right top"
+ ]
+ },
+ "maxItems": 15,
+ "optional": true
+ },
+ "additional_backgrounds_tiling": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"]
+ },
+ "maxItems": 15,
+ "optional": true
+ },
+ "color_scheme": {
+ "description": "If set, overrides the general theme (context menus, toolbars, content area).",
+ "optional": true,
+ "type": "string",
+ "enum": ["light", "dark", "auto"]
+ },
+ "content_color_scheme": {
+ "description": "If set, overrides the color scheme for the content area.",
+ "optional": true,
+ "type": "string",
+ "enum": ["light", "dark", "auto"]
+ }
+ },
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ }
+ },
+ {
+ "id": "ThemeManifest",
+ "type": "object",
+ "description": "Contents of manifest.json for a static theme",
+ "$import": "manifest.ManifestBase",
+ "properties": {
+ "theme": {
+ "$ref": "ThemeType"
+ },
+ "dark_theme": {
+ "$ref": "ThemeType",
+ "optional": true,
+ "description": "Fallback properties for the dark system theme."
+ },
+ "default_locale": {
+ "type": "string",
+ "optional": true
+ },
+ "theme_experiment": {
+ "$ref": "ThemeExperiment",
+ "optional": true,
+ "description": "CSS file with additional styles."
+ },
+ "icons": {
+ "type": "object",
+ "optional": true,
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "type": "string"
+ }
+ },
+ "description": "Icons shown in the Add-ons Manager."
+ }
+ }
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "theme_experiment": {
+ "$ref": "ThemeExperiment",
+ "optional": true,
+ "description": "A theme experiment allows modifying the user interface of Thunderbird beyond what is currently possible using the built-in color, image and property keys of :ref:`theme.ThemeType`. These experiments are a precursor to proposing new theme features for inclusion in Thunderbird. Experimentation is done by mapping internal CSS color, image and property variables to new theme keys and using them in :ref:`theme.ThemeType` and by loading additional style sheets to add new CSS variables, extending the theme-able areas of Thunderbird. Can be used in static and dynamic themes."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "theme",
+ "description": "The theme API allows for customization of Thunderbird's visual elements.",
+ "types": [
+ {
+ "id": "ThemeUpdateInfo",
+ "type": "object",
+ "description": "Info provided in the onUpdated listener.",
+ "properties": {
+ "theme": {
+ "$ref": "ThemeType",
+ "description": "The new theme after update"
+ },
+ "windowId": {
+ "type": "integer",
+ "description": "The id of the window the theme has been applied to",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a new theme has been applied",
+ "parameters": [
+ {
+ "$ref": "ThemeUpdateInfo",
+ "name": "updateInfo",
+ "description": "Details of the theme update"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "async": "callback",
+ "description": "Returns the current theme for the specified window or the last focused window.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "optional": true,
+ "description": "The window for which we want the theme."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ThemeType"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "description": "Make complete updates to the theme. Resolves when the update has completed.",
+ "permissions": ["theme"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "optional": true,
+ "description": "The id of the window to update. No id updates all windows."
+ },
+ {
+ "name": "details",
+ "$ref": "manifest.ThemeType",
+ "description": "The properties of the theme to update."
+ }
+ ]
+ },
+ {
+ "name": "reset",
+ "type": "function",
+ "async": true,
+ "description": "Removes the updates made to the theme.",
+ "permissions": ["theme"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "optional": true,
+ "description": "The id of the window to reset. No id resets all windows."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/windows.json b/comm/mail/components/extensions/schemas/windows.json
new file mode 100644
index 0000000000..129364e155
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/windows.json
@@ -0,0 +1,511 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "windows",
+ "description": "The windows API supports creating, modifying and interacting with Thunderbird windows.",
+ "types": [
+ {
+ "id": "WindowType",
+ "type": "string",
+ "description": "The type of a window. Under some circumstances a window may not be assigned a type property.",
+ "enum": ["normal", "popup", "messageCompose", "messageDisplay"]
+ },
+ {
+ "id": "WindowState",
+ "type": "string",
+ "description": "The state of this window.",
+ "enum": ["normal", "minimized", "maximized", "fullscreen", "docked"]
+ },
+ {
+ "id": "Window",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The ID of the window. Window IDs are unique within a session."
+ },
+ "focused": {
+ "type": "boolean",
+ "description": "Whether the window is currently the focused window."
+ },
+ "top": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset of the window from the top edge of the screen in pixels."
+ },
+ "left": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset of the window from the left edge of the screen in pixels."
+ },
+ "width": {
+ "type": "integer",
+ "optional": true,
+ "description": "The width of the window, including the frame, in pixels."
+ },
+ "height": {
+ "type": "integer",
+ "optional": true,
+ "description": "The height of the window, including the frame, in pixels."
+ },
+ "tabs": {
+ "type": "array",
+ "items": {
+ "$ref": "tabs.Tab"
+ },
+ "optional": true,
+ "description": "Array of :ref:`tabs.Tab` objects representing the current tabs in the window. Only included if requested by :ref:`windows.get`, :ref:`windows.getCurrent`, :ref:`windows.getAll` or :ref:`windows.getLastFocused`, and the optional :ref:`windows.GetInfo` parameter has its ``populate`` member set to <value>true</value>."
+ },
+ "incognito": {
+ "type": "boolean",
+ "description": "Whether the window is incognito. Since Thunderbird does not support the incognito mode, this is always <value>false</value>."
+ },
+ "type": {
+ "$ref": "WindowType",
+ "optional": true,
+ "description": "The type of window this is."
+ },
+ "state": {
+ "$ref": "WindowState",
+ "optional": true,
+ "description": "The state of this window."
+ },
+ "alwaysOnTop": {
+ "type": "boolean",
+ "description": "Whether the window is set to be always on top."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The title of the window. Read-only."
+ }
+ }
+ },
+ {
+ "id": "CreateType",
+ "type": "string",
+ "description": "Specifies what type of window to create. Thunderbird does not support <value>panel</value> and <value>detached_panel</value>, they are interpreted as <value>popup</value>.",
+ "enum": ["normal", "popup", "panel", "detached_panel"]
+ },
+ {
+ "id": "GetInfo",
+ "type": "object",
+ "description": "Specifies additional requirements for the returned windows.",
+ "properties": {
+ "populate": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, the :ref:`windows.Window` returned will have a ``tabs`` property that contains an array of :ref:`tabs.Tab` objects representing the tabs inside the window. The :ref:`tabs.Tab` objects only contain the ``url``, ``title`` and ``favIconUrl`` properties if the extension's manifest file includes the <permission>tabs</permission> permission."
+ },
+ "windowTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "optional": true,
+ "description": "If set, the :ref:`windows.Window` returned will be filtered based on its type. Supported by :ref:`windows.getAll` only, ignored in all other functions."
+ }
+ }
+ }
+ ],
+ "properties": {
+ "WINDOW_ID_NONE": {
+ "value": -1,
+ "description": "The windowId value that represents the absence of a window."
+ },
+ "WINDOW_ID_CURRENT": {
+ "value": -2,
+ "description": "The windowId value that represents the current window."
+ }
+ },
+ "functions": [
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Gets details about a window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -2
+ },
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "description": "Gets the active or topmost window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getLastFocused",
+ "type": "function",
+ "description": "Gets the window that was most recently focused &mdash; typically the window 'on top'.",
+ "async": "callback",
+ "parameters": [
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAll",
+ "type": "function",
+ "description": "Gets all windows.",
+ "async": "callback",
+ "parameters": [
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "windows",
+ "type": "array",
+ "items": {
+ "$ref": "Window"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates (opens) a new window with any optional sizing, position or default URL provided. When loading a page into a popup window, same-site links are opened within the same window, all other links are opened in the user's default browser. To override this behavior, add-ons have to register a `content script <https://bugzilla.mozilla.org/show_bug.cgi?id=1618828#c3>`__ , capture click events and handle them manually. Same-site links with targets other than <value>_self</value> are opened in a new tab in the most recent ``normal`` Thunderbird window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "createData",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "url": {
+ "description": "A URL or array of URLs to open as tabs in the window. Fully-qualified URLs must include a scheme (i.e. <value>http://www.google.com</value>, not <value>www.google.com</value>). Relative URLs will be relative to the current page within the extension. Defaults to the New Tab Page.",
+ "optional": true,
+ "choices": [
+ {
+ "type": "string",
+ "format": "relativeUrl"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "relativeUrl"
+ }
+ }
+ ]
+ },
+ "tabId": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The id of the tab for which you want to adopt to the new window."
+ },
+ "left": {
+ "type": "integer",
+ "optional": true,
+ "description": "The number of pixels to position the new window from the left edge of the screen. If not specified, the new window is offset naturally from the last focused window."
+ },
+ "top": {
+ "type": "integer",
+ "optional": true,
+ "description": "The number of pixels to position the new window from the top edge of the screen. If not specified, the new window is offset naturally from the last focused window."
+ },
+ "width": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The width in pixels of the new window, including the frame. If not specified defaults to a natural width."
+ },
+ "height": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The height in pixels of the new window, including the frame. If not specified defaults to a natural height."
+ },
+ "focused": {
+ "unsupported": true,
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, opens an active window. If false, opens an inactive window."
+ },
+ "incognito": {
+ "unsupported": true,
+ "type": "boolean",
+ "optional": true
+ },
+ "type": {
+ "$ref": "CreateType",
+ "optional": true,
+ "description": "Specifies what type of window to create. Thunderbird does not support <value>panel</value> and <value>detached_panel</value>, they are interpreted as <value>popup</value>."
+ },
+ "state": {
+ "$ref": "WindowState",
+ "optional": true,
+ "description": "The initial state of the window. The ``minimized``, ``maximized`` and ``fullscreen`` states cannot be combined with ``left``, ``top``, ``width`` or ``height``."
+ },
+ "allowScriptsToClose": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Allow scripts running inside the window to close the window by calling <code>window.close()</code>."
+ },
+ "cookieStoreId": {
+ "type": "string",
+ "optional": true,
+ "description": "The CookieStoreId to use for all tabs that were created when the window is opened."
+ },
+ "titlePreface": {
+ "type": "string",
+ "optional": true,
+ "description": "A string to add to the beginning of the window title."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window",
+ "description": "Contains details about the created window.",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Updates the properties of a window. Specify only the properties that you want to change; unspecified properties will be left unchanged.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -2
+ },
+ {
+ "type": "object",
+ "name": "updateInfo",
+ "properties": {
+ "left": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset from the left edge of the screen to move the window to in pixels. This value is ignored for panels."
+ },
+ "top": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset from the top edge of the screen to move the window to in pixels. This value is ignored for panels."
+ },
+ "width": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The width to resize the window to in pixels."
+ },
+ "height": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The height to resize the window to in pixels."
+ },
+ "focused": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, brings the window to the front. If false, brings the next window in the z-order to the front."
+ },
+ "drawAttention": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Setting this to <value>true</value> will cause the window to be displayed in a manner that draws the user's attention to the window, without changing the focused window. The effect lasts until the user changes focus to the window. This option has no effect if the window already has focus."
+ },
+ "state": {
+ "$ref": "WindowState",
+ "optional": true,
+ "description": "The new state of the window. The ``minimized``, ``maximized`` and ``fullscreen`` states cannot be combined with ``left``, ``top``, ``width`` or ``height``."
+ },
+ "titlePreface": {
+ "type": "string",
+ "optional": true,
+ "description": "A string to add to the beginning of the window title."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Removes (closes) a window, and all the tabs inside it.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -2
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "openDefaultBrowser",
+ "type": "function",
+ "description": "Opens the provided URL in the default system browser.",
+ "async": true,
+ "parameters": [
+ {
+ "type": "string",
+ "name": "url"
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a window is created.",
+ "filters": [
+ {
+ "name": "windowTypes",
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "description": "Conditions that the window's type being created must satisfy. By default it will satisfy <value>['app', 'normal', 'panel', 'popup']</value>, with <value>app</value> and <value>panel</value> window types limited to the extension's own windows."
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "Window",
+ "name": "window",
+ "description": "Details of the window that was created."
+ }
+ ]
+ },
+ {
+ "name": "onRemoved",
+ "type": "function",
+ "description": "Fired when a window is removed (closed).",
+ "filters": [
+ {
+ "name": "windowTypes",
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "description": "Conditions that the window's type being removed must satisfy. By default it will satisfy <value>['app', 'normal', 'panel', 'popup']</value>, with <value>app</value> and <value>panel</value> window types limited to the extension's own windows."
+ }
+ ],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": 0,
+ "description": "ID of the removed window."
+ }
+ ]
+ },
+ {
+ "name": "onFocusChanged",
+ "type": "function",
+ "description": "Fired when the currently focused window changes. Will be :ref:`windows.WINDOW_ID_NONE`, if all windows have lost focus. **Note:** On some Linux window managers, WINDOW_ID_NONE will always be sent immediately preceding a switch from one window to another.",
+ "filters": [
+ {
+ "name": "windowTypes",
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "description": "Conditions that the window's type being focused must satisfy. By default it will satisfy <value>['app', 'normal', 'panel', 'popup']</value>, with <value>app</value> and <value>panel</value> window types limited to the extension's own windows."
+ }
+ ],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -1,
+ "description": "ID of the newly focused window."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs b/comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs
new file mode 100644
index 0000000000..5320b0b6d7
--- /dev/null
+++ b/comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// TODO bug 1836863: Implement AppUiTestDelegate.
+
+export var AppUiTestDelegate = {};
diff --git a/comm/mail/components/extensions/test/browser/.eslintrc.js b/comm/mail/components/extensions/test/browser/.eslintrc.js
new file mode 100644
index 0000000000..e57058ecb1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+};
diff --git a/comm/mail/components/extensions/test/browser/browser.ini b/comm/mail/components/extensions/test/browser/browser.ini
new file mode 100644
index 0000000000..1bd2925968
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser.ini
@@ -0,0 +1,135 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.message_display.disable_remote_image=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files =
+ head_menus.js
+ test_browserAction.js
+ ../xpcshell/data/utils.js
+tags = webextensions
+
+[browser_ext_addressBooksUI.js]
+tags = addrbook
+[browser_ext_bug1812530.js]
+support-files = data/content.html
+tags = contextmenu
+[browser_ext_browserAction_customized.js]
+[browser_ext_browserAction_not_customized.js]
+[browser_ext_browserAction_popup_click.js]
+[browser_ext_browserAction_popup_click_mv3_event_pages.js]
+[browser_ext_browserAction_properties.js]
+[browser_ext_clickHandler.js]
+support-files = data/content.html data/linktest.html messages/messageWithLink.eml
+[browser_ext_cloudFile.js]
+support-files = data/cloudFile1.txt data/cloudFile2.txt
+[browser_ext_commands_execute_browser_action.js]
+[browser_ext_commands_execute_compose_action.js]
+[browser_ext_commands_execute_message_display_action.js]
+[browser_ext_commands_getAll.js]
+[browser_ext_commands_onChanged.js]
+[browser_ext_commands_onCommand.js]
+[browser_ext_commands_onCommand_bug1845236.js]
+[browser_ext_commands_update.js]
+[browser_ext_compose_attachments.js]
+[browser_ext_compose_begin_attachments.js]
+[browser_ext_compose_begin_body.js]
+[browser_ext_compose_begin_bug1691254.js]
+[browser_ext_compose_begin_forward.js]
+[browser_ext_compose_begin_headers.js]
+[browser_ext_compose_begin_identity.js]
+[browser_ext_compose_begin_new.js]
+[browser_ext_compose_begin_reply.js]
+[browser_ext_compose_details.js]
+[browser_ext_compose_details_headers.js]
+[browser_ext_compose_details_body.js]
+[browser_ext_compose_bug1692439.js]
+[browser_ext_compose_bug1804796.js]
+[browser_ext_compose_dictionaries.js]
+[browser_ext_compose_onBeforeSend.js]
+[browser_ext_compose_saveDraft.js]
+[browser_ext_compose_saveTemplate.js]
+[browser_ext_compose_sendMessage.js]
+[browser_ext_composeAction.js]
+[browser_ext_composeAction_popup_click.js]
+[browser_ext_composeAction_popup_click_mv3_event_pages.js]
+[browser_ext_composeAction_properties.js]
+[browser_ext_composeScripts.js]
+[browser_ext_content_handler.js]
+[browser_ext_content_tabs_navigation_menu.js]
+support-files = data/content.html
+tags = contextmenu
+[browser_ext_contentScripts.js]
+[browser_ext_mailTabs_mv3.js]
+[browser_ext_mailTabs.js]
+[browser_ext_menus_context_action.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_compose.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_content.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_folder_pane.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_message_panes.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_tabs.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_tools_main_menu.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_message_one_attachment.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+[browser_ext_menus_message_two_attachments.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_popup_action.js]
+[browser_ext_menus_replace_menu.js]
+tags = contextmenu
+[browser_ext_menus_replace_menu_context.js]
+tags = contextmenu
+[browser_ext_message_external.js]
+support-files = messages/attachedMessageSample.eml
+[browser_ext_messageDisplay.js]
+[browser_ext_messageDisplay_bug1827032.js]
+[browser_ext_messageDisplay_bug1828056.js]
+[browser_ext_messageDisplay_open_file.js]
+[browser_ext_messageDisplay_open_headerMessageId.js]
+[browser_ext_messageDisplay_open_messageId.js]
+[browser_ext_messageDisplayAction.js]
+[browser_ext_messageDisplayAction_popup_click.js]
+[browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js]
+[browser_ext_messageDisplayAction_properties.js]
+[browser_ext_messageDisplayScripts.js]
+[browser_ext_messages_open_attachment.js]
+[browser_ext_quickFilter.js]
+[browser_ext_sessions.js]
+[browser_ext_spaces.js]
+[browser_ext_spacesToolbar.js]
+[browser_ext_tabs_content.js]
+[browser_ext_tabs_cookieStoreId.js]
+[browser_ext_tabs_events.js]
+[browser_ext_tabs_onCreated_bug1817872.js]
+[browser_ext_tabs_move.js]
+[browser_ext_tabs_query.js]
+[browser_ext_tabs_update_reload.js]
+[browser_ext_themes_onUpdated.js]
+[browser_ext_tooltip_in_extension_pages.js]
+[browser_ext_windows.js]
+[browser_ext_windows_bug1732559.js]
+[browser_ext_windows_create_normal_cookieStoreId.js]
+[browser_ext_windows_create_popup_cookieStoreId.js]
+[browser_ext_windows_events.js]
+[browser_ext_windows_types.js]
+
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js b/comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js
new file mode 100644
index 0000000000..4171bf47bf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testUI() {
+ async function background() {
+ async function checkNumberOfAddressBookTabs(expectedNumberOfTabs) {
+ let addressBookTabs = await browser.tabs.query({ type: "addressBook" });
+ browser.test.assertEq(
+ expectedNumberOfTabs,
+ addressBookTabs.length,
+ "Should find the correct number of open address book tabs"
+ );
+ }
+
+ let addedTabs = new Set();
+ let removedTabs = 0;
+ function tabCreateListener(tab) {
+ if (tab.type == "addressBook") {
+ addedTabs.add(tab.id);
+ } else {
+ browser.test.fail(
+ "Should not receive a onTabCreated event for a non address book tab"
+ );
+ }
+ }
+
+ function tabRemoveListener(tabId) {
+ console.log("Remove: " + tabId);
+ if (addedTabs.has(tabId)) {
+ removedTabs++;
+ } else {
+ browser.test.fail(
+ "Should not receive a onTabRemoved event for a non address book tab"
+ );
+ }
+ }
+
+ browser.tabs.onCreated.addListener(tabCreateListener);
+ browser.tabs.onRemoved.addListener(tabRemoveListener);
+
+ await window.sendMessage("checkNumberOfAddressBookTabs", 0);
+ await checkNumberOfAddressBookTabs(0);
+
+ let abTab1 = await browser.addressBooks.openUI();
+ browser.test.log(JSON.stringify(abTab1));
+ browser.test.assertEq(
+ "addressBook",
+ abTab1.type,
+ "Should have found an addressBook tab"
+ );
+ await window.sendMessage("checkNumberOfAddressBookTabs", 1);
+ await checkNumberOfAddressBookTabs(1);
+
+ await browser.addressBooks.openUI();
+ let abTab2 = await browser.addressBooks.openUI();
+ browser.test.log(JSON.stringify(abTab2));
+ browser.test.assertEq(
+ "addressBook",
+ abTab2.type,
+ "Should have found an addressBook tab"
+ );
+ await window.sendMessage("checkNumberOfAddressBookTabs", 1);
+ await checkNumberOfAddressBookTabs(1);
+
+ browser.test.assertEq(
+ abTab1.id,
+ abTab2.id,
+ "addressBook tabs should be identical"
+ );
+
+ await browser.addressBooks.closeUI();
+ await window.sendMessage("checkNumberOfAddressBookTabs", 0);
+ await checkNumberOfAddressBookTabs(0);
+
+ browser.tabs.onCreated.removeListener(tabCreateListener);
+ browser.tabs.onRemoved.removeListener(tabRemoveListener);
+
+ browser.test.assertEq(
+ 1,
+ removedTabs,
+ "Should have seen the correct number of address book tabs being removed"
+ );
+
+ browser.test.assertEq(
+ 1,
+ addedTabs.size,
+ "Should have seen the correct number of address book tabs being added"
+ );
+
+ browser.test.notifyPass("addressBooks");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ extension.onMessage("checkNumberOfAddressBookTabs", count => {
+ let tabmail = document.getElementById("tabmail");
+ let tabs = tabmail.tabInfo.filter(
+ tab => tab.browser?.currentURI.spec == "about:addressbook"
+ );
+ Assert.equal(tabs.length, count, "Right number of address books open");
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js
new file mode 100644
index 0000000000..056fec372e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+async function enforceState(state) {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState(state);
+ await stateChangeObserved;
+}
+
+add_setup(async () => {
+ // Set a customized state for the spaces we are working with in this test.
+ await enforceState({
+ mail: ["spacer", "search-bar", "spacer"],
+ calendar: ["spacer", "search-bar", "spacer"],
+ });
+
+ registerCleanupFunction(async () => {
+ await enforceState({});
+ });
+});
+
+// Load browserAction tests.
+Services.scriptloader.loadSubScript(
+ new URL("test_browserAction.js", gTestPath).href,
+ this
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js
new file mode 100644
index 0000000000..755e950a84
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_setup(async () => {
+ Assert.equal(
+ 0,
+ Object.keys(getState()).length,
+ "Unified toolbar should not be customized"
+ );
+});
+
+// Load browserAction tests.
+Services.scriptloader.loadSubScript(
+ new URL("test_browserAction.js", gTestPath).href,
+ this
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js
new file mode 100644
index 0000000000..9b985a2c7a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js
@@ -0,0 +1,399 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let messages;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+});
+
+// This test clicks on the action button to open the popup.
+add_task(async function test_popup_open_with_click() {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-mouse-click",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-mouse-click",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ messageWindow.close();
+ }
+});
+
+// This test uses openPopup() to open the popup in a normal window.
+add_task(async function test_popup_open_with_openPopup_in_normal_window() {
+ let files = {
+ "background.js": async () => {
+ let windows = await browser.windows.getAll();
+ let mailWindow = windows.find(window => window.type == "normal");
+ let messageWindow = windows.find(
+ window => window.type == "messageDisplay"
+ );
+ browser.test.assertTrue(!!mailWindow, "should have found a mailWindow");
+ browser.test.assertTrue(
+ !!messageWindow,
+ "should have found a messageWindow"
+ );
+
+ // The test starts with an opened messageWindow, the browser_action is not
+ // allowed there and should not be visible, openPopup() should fail.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the messageWindow is active"
+ );
+
+ // Specifically open the browser_action of the mailWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup({ windowId: mailWindow.id }),
+ "openPopup() should have succeeded when explicitly requesting the mailWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(mailWindow.id)).focused,
+ "mailWindow should be focused"
+ );
+
+ // mailWindow is the topmost window now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the mailWindow has become active"
+ );
+ await window.waitForMessage();
+
+ // Create content tab, the browser_action is not allowed in that space and
+ // should not be visible, openPopup() should fail.
+ let contentTab = await browser.tabs.create({
+ url: "https://www.example.com",
+ });
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the content tab is active"
+ );
+
+ // Close the content tab and return to the mail space, the browser_action
+ // should be visible again, openPopup() should succeed.
+ await browser.tabs.remove(contentTab.id);
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the content tab was closed"
+ );
+ await window.waitForMessage();
+
+ // Disable the browser_action, openPopup() should fail.
+ await browser.browserAction.disable();
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed after the action_button was disabled"
+ );
+
+ // Enable the browser_action, openPopup() should succeed.
+ await browser.browserAction.enable();
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the action_button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a browser_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ (await browser.windows.get(popupWindow.id)).focused,
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the browser_action of the mailWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup({ windowId: mailWindow.id }),
+ "openPopup() should have succeeded when explicitly requesting the mailWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(mailWindow.id)).focused,
+ "mailWindow should be focused"
+ );
+
+ // Close the popup window
+ await browser.windows.remove(popupWindow.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+});
+
+// This test adds the action button to the message window and not to the mail
+// window (the default_windows manifest property is set to ["messageDisplay"].
+// the test then uses openPopup() to open the popup in a message window.
+add_task(async function test_popup_open_with_openPopup_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ let windows = await browser.windows.getAll();
+ let mailWindow = windows.find(window => window.type == "normal");
+ let messageWindow = windows.find(
+ window => window.type == "messageDisplay"
+ );
+ browser.test.assertTrue(!!mailWindow, "should have found a mailWindow");
+ browser.test.assertTrue(
+ !!messageWindow,
+ "should have found a messageWindow"
+ );
+
+ // The test starts with an opened messageWindow, the browser_action is allowed
+ // there and should be visible, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Collapse the toolbar, openPopup() should fail.
+ await window.sendMessage("collapseToolbar", true);
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the toolbar is collapsed"
+ );
+
+ // Restore the toolbar, openPopup() should succeed.
+ await window.sendMessage("collapseToolbar", false);
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the toolbar is restored"
+ );
+ await window.waitForMessage();
+
+ // Specifically open the browser_action of the mailWindow, it should not be
+ // allowed there and openPopup() should fail.
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup({ windowId: mailWindow.id }),
+ "openPopup() should have failed when explicitly requesting the mailWindow"
+ );
+
+ // The messageWindow should still have focus, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should still have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Disable the browser_action, openPopup() should fail.
+ await browser.browserAction.disable();
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed after the action_button was disabled"
+ );
+
+ // Enable the browser_action, openPopup() should succeed.
+ await browser.browserAction.enable();
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the action_button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a browser_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ await browser.windows.get(popupWindow.id),
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the browser_action of the messageWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup({ windowId: messageWindow.id }),
+ "openPopup() should have succeeded when explicitly requesting the messageWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+
+ // The messageWindow is focused now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Close the popup window and finish
+ await browser.windows.remove(popupWindow.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ default_windows: ["messageDisplay"],
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ extension.onMessage("collapseToolbar", state => {
+ let window = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let toolbar = window.document.getElementById("mail-bar3");
+ if (state) {
+ toolbar.setAttribute("collapsed", "true");
+ } else {
+ toolbar.removeAttribute("collapsed");
+ }
+ extension.sendMessage();
+ });
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js
new file mode 100644
index 0000000000..a58e0077ef
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let subFolders;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ await TestUtils.waitForCondition(
+ () => subFolders[0].messages.hasMoreElements(),
+ "Messages should be added to folder"
+ );
+});
+
+function getMessage() {
+ let messages = subFolders[0].messages;
+ ok(messages.hasMoreElements(), "Should have messages to iterate to");
+ return messages.getNext();
+}
+
+async function subtest_popup_open_with_click_MV3_event_pages(
+ terminateBackground
+) {
+ info("3-pane tab");
+ let testConfig = {
+ actionType: "action",
+ manifest_version: 3,
+ terminateBackground,
+ testType: "open-with-mouse-click",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(getMessage());
+ let testConfig = {
+ actionType: "action",
+ manifest_version: 3,
+ terminateBackground,
+ testType: "open-with-mouse-click",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ messageWindow.close();
+ }
+}
+// This MV3 test clicks on the action button to open the popup.
+add_task(async function test_event_pages_without_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(false);
+});
+// This MV3 test clicks on the action button to open the popup (background termination).
+add_task(async function test_event_pages_with_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(true);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js
new file mode 100644
index 0000000000..18633c5715
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js
@@ -0,0 +1,348 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+
+ let files = {
+ "background.js": async () => {
+ async function checkProperty(property, expectedDefault, ...expected) {
+ browser.test.log(
+ `${property}: ${expectedDefault}, ${expected.join(", ")}`
+ );
+
+ browser.test.assertEq(
+ expectedDefault,
+ await browser.browserAction[property]({}),
+ `Default value for ${property} should be correct`
+ );
+ for (let i = 0; i < 3; i++) {
+ browser.test.assertEq(
+ expected[i],
+ await browser.browserAction[property]({ tabId: tabIDs[i] }),
+ `Specific value for ${property} of tab #${i} should be correct`
+ );
+ }
+ }
+
+ async function checkRealState(property, ...expected) {
+ await window.sendMessage(whichTest, property, expected);
+ }
+
+ let tabs = await browser.mailTabs.query({});
+ browser.test.assertEq(3, tabs.length);
+ let tabIDs = tabs.map(t => t.id);
+
+ let whichTest = "checkProperty";
+
+ // Test enable property.
+ await checkProperty("isEnabled", true, true, true, true);
+ await checkRealState("enabled", true, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await checkRealState("enabled", true, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+ await checkRealState("enabled", false, true, true);
+
+ // Test title property (since a label has not been set, this sets the
+ // tooltip and the actual label of the button).
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[2], title: "tab2" });
+ await checkProperty("getTitle", "default", "default", "default", "tab2");
+ await checkRealState("tooltip", "default", "default", "tab2");
+ await checkRealState("label", "default", "default", "tab2");
+ await browser.browserAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "new", "new", "tab2");
+ await checkRealState("tooltip", "new", "new", "tab2");
+ await checkRealState("label", "new", "new", "tab2");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+ await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+ await checkRealState("tooltip", "new", "tab1", "tab2");
+ await checkRealState("label", "new", "tab1", "tab2");
+ await browser.browserAction.setTitle({ tabId: tabIDs[2], title: null });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "new", "tab1", "new");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await checkRealState("tooltip", "default", "tab1", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: null });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ // Test label property (tooltip should not change).
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[2], label: "" });
+ await checkProperty("getLabel", null, null, null, "");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "");
+ await browser.browserAction.setLabel({ tabId: tabIDs[2], label: "tab2" });
+ await checkProperty("getLabel", null, null, null, "tab2");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "tab2");
+ await browser.browserAction.setLabel({ label: "new" });
+ await checkProperty("getLabel", "new", "new", "new", "tab2");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "new", "tab2");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: "tab1" });
+ await checkProperty("getLabel", "new", "new", "tab1", "tab2");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "tab1", "tab2");
+ await browser.browserAction.setLabel({ tabId: tabIDs[2], label: null });
+ await checkProperty("getLabel", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setLabel({ label: null });
+ await checkProperty("getLabel", null, null, "tab1", null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: null });
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ // Check that properties are updated without switching tabs. We might be
+ // relying on the tab switch to update the properties.
+
+ // Tab 0's enabled state doesn't reflect the default any more, so we
+ // can't just run the code above again.
+
+ browser.test.log("checkPropertyCurrent");
+ whichTest = "checkPropertyCurrent";
+
+ // Test enable property.
+ await checkProperty("isEnabled", true, false, true, true);
+ await checkRealState("enabled", false, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await checkRealState("enabled", true, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+ await checkRealState("enabled", false, true, true);
+
+ // Test title property (since a label has not been set, this sets the
+ // tooltip and the actual label of the button).
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[0], title: "tab0" });
+ await checkProperty("getTitle", "default", "tab0", "default", "default");
+ await checkRealState("tooltip", "tab0", "default", "default");
+ await checkRealState("label", "tab0", "default", "default");
+ await browser.browserAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "tab0", "new", "new");
+ await checkRealState("tooltip", "tab0", "new", "new");
+ await checkRealState("label", "tab0", "new", "new");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+ await checkProperty("getTitle", "new", "tab0", "tab1", "new");
+ await checkRealState("tooltip", "tab0", "tab1", "new");
+ await checkRealState("label", "tab0", "tab1", "new");
+ await browser.browserAction.setTitle({ tabId: tabIDs[0], title: null });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "new", "tab1", "new");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await checkRealState("tooltip", "default", "tab1", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: null });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ // Test label property (tooltip should not change).
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[0], label: "" });
+ await checkProperty("getLabel", null, "", null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "", "default", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[0], label: "tab0" });
+ await checkProperty("getLabel", null, "tab0", null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "tab0", "default", "default");
+ await browser.browserAction.setLabel({ label: "new" });
+ await checkProperty("getLabel", "new", "tab0", "new", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "tab0", "new", "new");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: "tab1" });
+ await checkProperty("getLabel", "new", "tab0", "tab1", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "tab0", "tab1", "new");
+ await browser.browserAction.setLabel({ tabId: tabIDs[0], label: null });
+ await checkProperty("getLabel", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setLabel({ label: null });
+ await checkProperty("getLabel", null, null, "tab1", null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: null });
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ await browser.tabs.remove(tabIDs[1]);
+ await browser.tabs.remove(tabIDs[2]);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_properties@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ },
+ },
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: false,
+ });
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: false,
+ });
+
+ let mailTabs = tabmail.tabInfo;
+ is(mailTabs.length, 3, "Expect 3 tabs");
+ tabmail.switchToTab(mailTabs[0]);
+
+ await extension.startup();
+
+ let button = document.querySelector(
+ `.unified-toolbar [extension="browser_action_properties@mochi.test"]`
+ );
+
+ extension.onMessage("checkProperty", async (property, expected) => {
+ for (let i = 0; i < 3; i++) {
+ tabmail.switchToTab(mailTabs[i]);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ switch (property) {
+ case "enabled":
+ is(button.disabled, !expected[i], `button ${i} enabled state`);
+ break;
+ case "tooltip":
+ is(
+ button.getAttribute("title"),
+ expected[i],
+ `button ${i} tooltip title`
+ );
+ break;
+ case "label":
+ if (expected[i] == "") {
+ ok(
+ button.classList.contains("prefer-icon-only"),
+ `button ${i} has hidden label`
+ );
+ } else {
+ is(button.getAttribute("label"), expected[i], `button ${i} label`);
+ }
+ break;
+ }
+ }
+
+ tabmail.switchToTab(mailTabs[0]);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkPropertyCurrent", async (property, expected) => {
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ switch (property) {
+ case "enabled":
+ is(button.disabled, !expected[0], `button 0 enabled state`);
+ break;
+ case "tooltip":
+ is(button.getAttribute("title"), expected[0], `button 0 tooltip title`);
+ break;
+ case "label":
+ if (expected[0] == "") {
+ ok(
+ button.classList.contains("prefer-icon-only"),
+ `button 0 has hidden label`
+ );
+ } else {
+ is(button.getAttribute("label"), expected[0], `button 0 label`);
+ }
+ break;
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ tabmail.closeTab(mailTabs[2]);
+ tabmail.closeTab(mailTabs[1]);
+ is(tabmail.tabInfo.length, 1);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js b/comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js
new file mode 100644
index 0000000000..1042ae5bbf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js
@@ -0,0 +1,200 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. *
+ */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {},
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ urlLoaded(url) {
+ return this._loadedURLs.includes(url);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+const subtest_clickOpenInBrowserContextMenu = async (extension, getBrowser) => {
+ async function contextClick(elementSelector, browser) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ let menuId = browser.getAttribute("context");
+ let menu = browser.ownerGlobal.top.document.getElementById(menuId);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ await rightClickOnContent(menu, elementSelector, browser);
+ Assert.ok(
+ menu.querySelector("#browserContext-openInBrowser"),
+ "menu item should exist"
+ );
+ menu.activateItem(menu.querySelector("#browserContext-openInBrowser"));
+ await hiddenPromise;
+ }
+
+ await extension.startup();
+
+ // Wait for click on #description
+ {
+ let { elementSelector, url } = await extension.awaitMessage("contextClick");
+ Assert.equal(
+ "#description",
+ elementSelector,
+ `Test should click on the correct element.`
+ );
+ Assert.equal(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html",
+ url,
+ `Test should open the correct page.`
+ );
+ await contextClick(elementSelector, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(url),
+ `Page should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ // Open remote file and re-open it in the browser.
+ const url =
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html";
+ const elementSelector = "#description";
+
+ let testTab = await browser.tabs.create({ url });
+ await window.sendMessage("contextClick", { elementSelector, url });
+ await browser.tabs.remove(testTab.id);
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ // Open remote file and re-open it in the browser.
+ const url =
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html";
+ const elementSelector = "#description";
+
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await window.sendMessage("contextClick", { elementSelector, url });
+ await browser.windows.remove(testWindow.id);
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+});
+
+add_task(async function test_mail3pane() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ // Open remote file and re-open it in the browser.
+ const url =
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html";
+ const elementSelector = "#description";
+
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await window.sendMessage("contextClick", { elementSelector, url });
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js b/comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js
new file mode 100644
index 0000000000..504de75218
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js
@@ -0,0 +1,614 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {},
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ urlLoaded(url) {
+ let rv = this._loadedURLs.length == 1 && this._loadedURLs[0] == url;
+ this._loadedURLs = [];
+ return rv;
+ },
+ hasAnyUrlLoaded() {
+ let rv = this._loadedURLs.length > 0;
+ this._loadedURLs = [];
+ return rv;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+const getCommonFiles = async () => {
+ return {
+ "utils.js": await getUtilsJS(),
+ "common.js": () => {
+ window.CreateTabPromise = class {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ let createListener = tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ resolve(tab);
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ });
+ }
+ async done() {
+ return this.promise;
+ }
+ };
+
+ window.UpdateTabPromise = class {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ let log = {};
+ let updateListener = (tabId, changes, tab) => {
+ if (changes.url == "about:blank") {
+ // Reset whatever we have seen so far.
+ log = {};
+ } else {
+ if (changes.url) {
+ log.url = changes.url;
+ }
+ if (changes.status == "loading") {
+ log.loading = true;
+ }
+ // The complete is only valid, if we seen a url (which was not
+ // "about:blank")
+ if (log.url && changes.status == "complete") {
+ log.complete = true;
+ }
+ }
+ if (log.id && log.id != tabId) {
+ browser.test.fail(
+ "Should not receive update events for multiple tabs"
+ );
+ }
+ log.id = tabId;
+
+ if (log.url && log.loading && log.complete) {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(log);
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ });
+ }
+ async verify(id, url) {
+ // The updatePromise resolves after we have seen both states (loading
+ // and complete) and a url.
+ let updateLog = await this.promise;
+ browser.test.assertEq(
+ id,
+ updateLog.id,
+ "Updates must belong to the current tab"
+ );
+ browser.test.assertEq(
+ url,
+ updateLog.url,
+ "Should have seen the correct url loaded."
+ );
+ }
+ };
+ },
+ "background.js": async () => {
+ let expectedLinkHandler = await window.sendMessage("expectedLinkHandler");
+
+ // Open local file and click link to a different site.
+ await window.expectLinkOpenInExternalBrowser(
+ browser.runtime.getURL("test.html"),
+ "#link1",
+ "https://www.example.de/"
+ );
+
+ // Open local file and click same site link (no target).
+ await window.expectLinkOpenInSameTab(
+ browser.runtime.getURL("test.html"),
+ "#link2",
+ browser.runtime.getURL("example.html")
+ );
+
+ // Open local file and click same site link ("_self" target).
+ await window.expectLinkOpenInSameTab(
+ browser.runtime.getURL("test.html"),
+ "#link3",
+ browser.runtime.getURL("example.html#self")
+ );
+
+ // Open local file and click same site link ("_blank" target).
+ await window.expectLinkOpenInNewTab(
+ browser.runtime.getURL("test.html"),
+ "#link4",
+ browser.runtime.getURL("example.html#blank")
+ );
+
+ // Open local file and click same site link ("_other" target).
+ await window.expectLinkOpenInNewTab(
+ browser.runtime.getURL("test.html"),
+ "#link5",
+ browser.runtime.getURL("example.html#other")
+ );
+
+ // Open a remote page and click link on same site.
+ if (expectedLinkHandler == "single-page") {
+ await window.expectLinkOpenInExternalBrowser(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/linktest.html",
+ "#linkExt1",
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html"
+ );
+ } else {
+ await window.expectLinkOpenInSameTab(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/linktest.html",
+ "#linkExt1",
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html"
+ );
+ }
+
+ // Open a remote page and click link to a different site.
+ await window.expectLinkOpenInExternalBrowser(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/linktest.html",
+ "#linkExt2",
+ "https://mozilla.org/"
+ );
+
+ browser.test.notifyPass();
+ },
+ "example.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>This is an example page</p>
+ </body>
+ </html>`,
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <ul>
+ <li><a id="link1" href="https://www.example.de/">external</a>
+ <li><a id="link2" href="example.html">no target</a>
+ <li><a id="link3" href="example.html#self" target = "_self">_self target</a>
+ <li><a id="link4" href="example.html#blank" target = "_blank">_blank target</a>
+ <li><a id="link5" href="example.html#other" target = "_other">_other target</a>
+ </ul>
+ </body>
+ </html>`,
+ };
+};
+
+const subtest_clickInBrowser = async (
+ extension,
+ expectedLinkHandler,
+ getBrowser
+) => {
+ async function clickLink(linkId, browser) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+ await synthesizeMouseAtCenterAndRetry(linkId, {}, browser);
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("expectedLinkHandler");
+ extension.sendMessage(expectedLinkHandler);
+
+ // Wait for click on #link1 (external)
+ {
+ let { linkId, expectedUrl } = await extension.awaitMessage("click");
+ Assert.equal("#link1", linkId, `Test should click on the correct link.`);
+ Assert.equal(
+ "https://www.example.de/",
+ expectedUrl,
+ `Test should open the correct link.`
+ );
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(expectedUrl),
+ `Link should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link2 (same tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link2", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link3 (same tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link3", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link4 (new tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link4", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link5 (new tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link5", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #linkExt1
+ if (expectedLinkHandler == "single-page") {
+ // Should open extern with single-page link handler.
+ let { linkId, expectedUrl } = await extension.awaitMessage("click");
+ Assert.equal("#linkExt1", linkId, `Test should click on the correct link.`);
+ Assert.equal(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html",
+ expectedUrl,
+ `Test should open the correct link.`
+ );
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(expectedUrl),
+ `Link should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ } else {
+ // Should open in same tab with single-site link handler.
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#linkExt1", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #linkExt2 (external)
+ {
+ let { linkId, expectedUrl } = await extension.awaitMessage("click");
+ Assert.equal("#linkExt2", linkId, `Test should click on the correct link.`);
+ Assert.equal(
+ "https://mozilla.org/",
+ expectedUrl,
+ `Test should open the correct link.`
+ );
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(expectedUrl),
+ `Link should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "tabFunctions.js": async () => {
+ let openTestTab = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise();
+ let testTab = await browser.tabs.create({ url });
+ await createdTestTab.done();
+ await updatedTestTab.verify(testTab.id, url);
+ return testTab;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInSameTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testTab to open in self.
+ let updatedTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ await updatedTab.verify(testTab.id, expectedUrl);
+
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInExternalBrowser = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+ await window.sendMessage("click", { linkId, expectedUrl });
+ await browser.tabs.remove(testTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "common.js", "tabFunctions.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ "single-site",
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+}).skip(AppConstants.DEBUG); // Disabled until Bug 1770105 is fully fixed.
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "windowFunctions.js": async () => {
+ let openTestTab = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise();
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await createdTestTab.done();
+
+ let [testTab] = await browser.tabs.query({ windowId: testWindow.id });
+ await updatedTestTab.verify(testTab.id, url);
+ return testTab;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testWindow to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInSameTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testWindow to open in self.
+ let updatedTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ await updatedTab.verify(testTab.id, expectedUrl);
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInExternalBrowser = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+ await window.sendMessage("click", { linkId, expectedUrl });
+ await browser.tabs.remove(testTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "windowFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ "single-site",
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+}).skip(AppConstants.DEBUG); // Disabled until Bug 1770105 is fully fixed.
+
+add_task(async function test_mail3pane() {
+ let account = createAccount();
+ let subFolders = account.incomingServer.rootFolder.subFolders;
+ createMessages(subFolders[0], 1);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ Assert.ok(Boolean(about3Pane), "about:3pane should be the current tab");
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+ about3Pane.threadTree.selectedIndex = 0;
+ await loadedPromise;
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "mail3paneFunctions.js": async () => {
+ let updateTestTab = async url => {
+ let updatedTestTab = new window.UpdateTabPromise();
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await updatedTestTab.verify(mailTabs[0].id, url);
+ return mailTabs[0];
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ await updateTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ };
+
+ window.expectLinkOpenInSameTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await updateTestTab(testUrl);
+
+ // Click a link in testTab to open in self.
+ let updatedTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ await updatedTab.verify(testTab.id, expectedUrl);
+ };
+
+ window.expectLinkOpenInExternalBrowser = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ await updateTestTab(testUrl);
+ await window.sendMessage("click", { linkId, expectedUrl });
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "mail3paneFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ "single-page",
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+}).skip(AppConstants.DEBUG); // Disabled until Bug 1770105 is fully fixed.
+
+// This is actually not an extension test, but everything we need is here already
+// and we only want to simulate a click on a link in a message.
+add_task(async function test_message() {
+ let gAccount = createAccount();
+ let gRootFolder = gAccount.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of gRootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ await createMessageFromFile(
+ subFolders.test0,
+ getTestFilePath("messages/messageWithLink.eml")
+ );
+
+ // Select the message which has a link.
+ let gFolder = subFolders.test0;
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder.URI);
+ let messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+ let loadedPromise = BrowserTestUtils.browserLoaded(messagePane);
+ about3Pane.threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Click the link.
+ await synthesizeMouseAtCenterAndRetry("#link", {}, messagePane);
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(
+ "https://www.example.de/messageLink.html"
+ ),
+ `Link should have correctly been opened in external browser.`
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js b/comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js
new file mode 100644
index 0000000000..2e9b53916c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js
@@ -0,0 +1,1444 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+/**
+ * Test cloudfile methods (getAccount, getAllAccounts, updateAccount) and
+ * events (onAccountAdded, onAccountDeleted, onFileUpload, onFileUploadAbort,
+ * onFileDeleted, onFileRename) without UI interaction.
+ */
+add_task(async function test_without_UI() {
+ async function background() {
+ function createCloudfileAccount() {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ function assertAccountsMatch(b, a) {
+ browser.test.assertEq(a.id, b.id);
+ browser.test.assertEq(a.name, b.name);
+ browser.test.assertEq(a.configured, b.configured);
+ browser.test.assertEq(a.uploadSizeLimit, b.uploadSizeLimit);
+ browser.test.assertEq(a.spaceRemaining, b.spaceRemaining);
+ browser.test.assertEq(a.spaceUsed, b.spaceUsed);
+ browser.test.assertEq(a.managementUrl, b.managementUrl);
+ }
+
+ async function test_account_creation_removal() {
+ browser.test.log("test_account_creation_removal");
+ // Account creation
+ let [createdAccount] = await createCloudfileAccount();
+ assertAccountsMatch(createdAccount, {
+ id: "account1",
+ name: "mochitest",
+ configured: false,
+ uploadSizeLimit: -1,
+ spaceRemaining: -1,
+ spaceUsed: -1,
+ managementUrl: browser.runtime.getURL("/content/management.html"),
+ });
+
+ // Other account creation
+ await new Promise((resolve, reject) => {
+ function accountListener(account) {
+ browser.cloudFile.onAccountAdded.removeListener(accountListener);
+ browser.test.fail("Got onAccountAdded for account from other addon");
+ reject();
+ }
+
+ browser.cloudFile.onAccountAdded.addListener(accountListener);
+ browser.test.sendMessage("createAccount", "ext-other-addon");
+
+ // Resolve in the next tick
+ setTimeout(() => {
+ browser.cloudFile.onAccountAdded.removeListener(accountListener);
+ resolve();
+ });
+ });
+
+ // Account removal
+ let [removedAccountId] = await removeCloudfileAccount(createdAccount.id);
+ browser.test.assertEq(createdAccount.id, removedAccountId);
+ }
+
+ async function test_getters_update() {
+ browser.test.log("test_getters_update");
+ browser.test.sendMessage("createAccount", "ext-other-addon");
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ // getAccount and getAllAccounts
+ let retrievedAccount = await browser.cloudFile.getAccount(
+ createdAccount.id
+ );
+ assertAccountsMatch(createdAccount, retrievedAccount);
+
+ let retrievedAccounts = await browser.cloudFile.getAllAccounts();
+ browser.test.assertEq(retrievedAccounts.length, 1);
+ assertAccountsMatch(createdAccount, retrievedAccounts[0]);
+
+ // update()
+ let changes = {
+ configured: true,
+ // uploadSizeLimit intentionally left unset
+ spaceRemaining: 456,
+ spaceUsed: 789,
+ managementUrl: "/account.html",
+ };
+
+ let changedAccount = await browser.cloudFile.updateAccount(
+ retrievedAccount.id,
+ changes
+ );
+ retrievedAccount = await browser.cloudFile.getAccount(createdAccount.id);
+
+ let expected = {
+ id: createdAccount.id,
+ name: "mochitest",
+ configured: true,
+ uploadSizeLimit: -1,
+ spaceRemaining: 456,
+ spaceUsed: 789,
+ managementUrl: browser.runtime.getURL("/account.html"),
+ };
+
+ assertAccountsMatch(changedAccount, expected);
+ assertAccountsMatch(retrievedAccount, expected);
+
+ await removeCloudfileAccount(createdAccount.id);
+ }
+
+ async function test_upload_rename_delete() {
+ browser.test.log("test_upload_rename_delete");
+ let [createdAccount] = await createCloudfileAccount();
+
+ let fileId = await new Promise(resolve => {
+ async function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let content = await data.text();
+ browser.test.assertEq(content, "you got the moves!\n");
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ browser.test.log("test upload error");
+ await new Promise(resolve => {
+ function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { error: true };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadErr",
+ "Upload error."
+ );
+ });
+
+ browser.test.log("test upload error with message");
+ await new Promise(resolve => {
+ function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { error: "Service currently unavailable." };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadErrWithCustomMessage",
+ "Service currently unavailable."
+ );
+ });
+
+ browser.test.log("test upload quota error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadWouldExceedQuota",
+ "Quota error: Can't upload file. Only 1KB left of quota."
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: -1,
+ });
+
+ browser.test.log("test upload file size limit error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadExceedsFileLimit",
+ "Upload error: File size is 19KB and exceeds the file size limit of 1KB"
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: -1,
+ });
+
+ browser.test.log("test rename with url update");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile3.txt");
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + newName };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage("renameFile", createdAccount.id, fileId, {
+ newName: "cloudFile3.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ });
+
+ browser.test.log("test rename without url update");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile4.txt");
+ setTimeout(() => resolve(id));
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage("renameFile", createdAccount.id, fileId, {
+ newName: "cloudFile4.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ });
+
+ browser.test.log("test rename error");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile5.txt");
+ setTimeout(() => resolve(id));
+ return { error: true };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage(
+ "renameFile",
+ createdAccount.id,
+ fileId,
+ { newName: "cloudFile5.txt" },
+ "renameErr",
+ "Rename error."
+ );
+ });
+
+ browser.test.log("test rename error with message");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile5.txt");
+ setTimeout(() => resolve(id));
+ return { error: "Service currently unavailable." };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage(
+ "renameFile",
+ createdAccount.id,
+ fileId,
+ { newName: "cloudFile5.txt" },
+ "renameErrWithCustomMessage",
+ "Service currently unavailable."
+ );
+ });
+
+ browser.test.log("test upload aborted");
+ await new Promise(resolve => {
+ async function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+
+ // The listener won't return until onFileUploadAbort fires. When that happens,
+ // we return an aborted message, which completes the abort cycle.
+ await new Promise(resolveAbort => {
+ function abortListener(accountAccount, abortId) {
+ browser.cloudFile.onFileUploadAbort.removeListener(abortListener);
+ browser.test.assertEq(account.id, accountAccount.id);
+ browser.test.assertEq(id, abortId);
+ resolveAbort();
+ }
+ browser.cloudFile.onFileUploadAbort.addListener(abortListener);
+ browser.test.sendMessage("cancelUpload", createdAccount.id);
+ });
+
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(resolve);
+ return { aborted: true };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadCancelled",
+ "Upload cancelled."
+ );
+ });
+
+ browser.test.log("test delete");
+ await new Promise(resolve => {
+ function fileListener(account, id) {
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(id, fileId);
+ setTimeout(resolve);
+ }
+
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+ browser.test.sendMessage("deleteFile", createdAccount.id);
+ });
+
+ await removeCloudfileAccount(createdAccount.id);
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ // Tests to run
+ await test_account_creation_removal();
+ await test_getters_update();
+ await test_upload_rename_delete();
+
+ browser.test.notifyPass("cloudFile");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+
+ let uploads = {};
+
+ extension.onMessage("createAccount", (id = "ext-cloudfile@mochi.test") => {
+ cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage(
+ "uploadFileError",
+ async (id, filename, expectedErrorStatus, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ let status;
+ try {
+ await account.uploadFile(null, testFiles[filename]);
+ } catch (ex) {
+ status = ex;
+ }
+
+ Assert.ok(
+ !!status,
+ `Upload should have failed for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.result,
+ cloudFileAccounts.constants[expectedErrorStatus],
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ extension.sendMessage();
+ }
+ );
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ account.uploadFile(null, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage(
+ "renameFile",
+ (
+ id,
+ uploadId,
+ { newName, newUrl },
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ account.renameFile(null, uploadId, newName).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ Assert.equal(upload.name, newName, "New name should match.");
+ Assert.equal(upload.url, newUrl, "New url should match.");
+ },
+ status => {
+ Assert.equal(status.result, expectedErrorStatus);
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct.`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage("cancelUpload", id => {
+ let account = cloudFileAccounts.getAccount(id);
+ account.cancelFileUpload(null, testFiles.cloudFile2);
+ });
+
+ extension.onMessage("deleteFile", id => {
+ let account = cloudFileAccounts.getAccount(id);
+ account.deleteFile(null, uploads.cloudFile1.id);
+ });
+
+ Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ await extension.startup();
+ Assert.ok(cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ Assert.equal(cloudFileAccounts.accounts.length, 1);
+
+ await extension.awaitFinish("cloudFile");
+ await extension.unload();
+
+ Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ Assert.equal(cloudFileAccounts.accounts.length, 0);
+});
+
+/**
+ * Test the tab parameter in cloudFile.onFileUpload, cloudFile.onFileDeleted,
+ * cloudFile.onFileRename and cloudFile.onFileUploadAbort listeners with UI
+ * interaction.
+ */
+add_task(async function test_compose_window_MV2() {
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+ let uploads = {};
+ let composeWindow;
+
+ async function background() {
+ function createCloudfileAccount(id) {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount", id);
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ async function test_tab_in_upload_rename_abort_delete_listener(composeTab) {
+ browser.test.log("test_upload_delete");
+ let [createdAccount] = await createCloudfileAccount(
+ "ext-cloudfile@mochi.test"
+ );
+
+ let fileId = await new Promise(resolve => {
+ async function fileListener(
+ uploadAccount,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(uploadAccount.id, createdAccount.id);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let content = await data.text();
+ browser.test.assertEq(content, "you got the moves!\n");
+
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ browser.test.log("test rename with Url update");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName, tab) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile3.txt");
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + newName };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage("renameFile", createdAccount.id, fileId, {
+ newName: "cloudFile3.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ });
+
+ browser.test.log("test upload aborted");
+ await new Promise(resolve => {
+ async function fileListener(
+ uploadAccount,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+
+ // The listener won't return until onFileUploadAbort fires. When that happens,
+ // we return an aborted message, which completes the abort cycle.
+ await new Promise(resolveAbort => {
+ function abortListener(abortAccount, abortId, tab) {
+ browser.cloudFile.onFileUploadAbort.removeListener(abortListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(uploadAccount.id, abortAccount.id);
+ browser.test.assertEq(id, abortId);
+ resolveAbort();
+ }
+ browser.cloudFile.onFileUploadAbort.addListener(abortListener);
+ browser.test.sendMessage("cancelUpload", createdAccount.id);
+ });
+
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(resolve);
+ return { aborted: true };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadCancelled",
+ "Upload cancelled."
+ );
+ });
+
+ browser.test.log("test delete");
+ await new Promise(resolve => {
+ function fileListener(deleteAccount, id, tab) {
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(deleteAccount.id, createdAccount.id);
+ browser.test.assertEq(id, fileId);
+ setTimeout(resolve);
+ }
+
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+ browser.test.sendMessage("deleteFile", createdAccount.id);
+ });
+
+ await removeCloudfileAccount(createdAccount.id);
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ let [composerTab] = await browser.tabs.query({
+ windowType: "messageCompose",
+ });
+ await test_tab_in_upload_rename_abort_delete_listener(composerTab);
+
+ browser.test.notifyPass("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("createAccount", id => {
+ cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ cloudFileAccount.uploadFile(composeWindow, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage(
+ "renameFile",
+ (
+ id,
+ uploadId,
+ { newName, newUrl },
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ cloudFileAccount.renameFile(composeWindow, uploadId, newName).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ Assert.equal(upload.name, newName, "New name should match.");
+ Assert.equal(upload.url, newUrl, "New url should match.");
+ },
+ status => {
+ Assert.equal(status.result, expectedErrorStatus);
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct.`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage("cancelUpload", id => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ cloudFileAccount.cancelFileUpload(composeWindow, testFiles.cloudFile2);
+ });
+
+ extension.onMessage("deleteFile", id => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ cloudFileAccount.deleteFile(composeWindow, uploads.cloudFile1.id);
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+
+ composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ composeWindow.close();
+});
+
+/**
+ * Test persistent cloudFile.* events (onFileUpload, onFileDeleted, onFileRename,
+ * onFileUploadAbort, onAccountAdded, onAccountDeleted) with UI interaction and
+ * background terminations and background restarts.
+ */
+add_task(async function test_compose_window_MV3_event_page() {
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+ let uploads = {};
+ let composeWindow;
+
+ async function background() {
+ let abortResolveCallback;
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.cloudFile.onFileUpload.addListener(
+ async (uploadAccount, { id, name, data }, tab, relatedFileInfo) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onFileUpload should be the wake up event"
+ );
+ let [{ cloudAccountId, composeTabId, aborting }] =
+ await window.sendMessage("getEnvironment");
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(uploadAccount.id, cloudAccountId);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let content = await data.text();
+ browser.test.assertEq(content, "you got the moves!\n");
+ browser.test.assertEq(undefined, relatedFileInfo);
+
+ if (aborting) {
+ let abortPromise = new Promise(resolve => {
+ abortResolveCallback = resolve;
+ });
+ browser.test.sendMessage("uploadStarted", id);
+ await abortPromise;
+ setTimeout(() => {
+ browser.test.sendMessage("uploadAborted");
+ });
+ return { aborted: true };
+ }
+
+ setTimeout(() => {
+ browser.test.sendMessage("uploadFinished", id);
+ });
+ return { url: "https://example.com/" + name };
+ }
+ );
+
+ browser.cloudFile.onFileRename.addListener(
+ async (account, id, newName, tab) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onFileRename should be the wake up event"
+ );
+ let [{ cloudAccountId, fileId, composeTabId }] =
+ await window.sendMessage("getEnvironment");
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(account.id, cloudAccountId);
+ browser.test.assertEq(id, fileId);
+ browser.test.assertEq(newName, "cloudFile3.txt");
+ setTimeout(() => {
+ browser.test.sendMessage("renameFinished", id);
+ });
+ return { url: "https://example.com/" + newName };
+ }
+ );
+
+ browser.cloudFile.onFileDeleted.addListener(async (account, id, tab) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onFileDeleted should be the wake up event"
+ );
+ let [{ cloudAccountId, fileId, composeTabId }] = await window.sendMessage(
+ "getEnvironment"
+ );
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(account.id, cloudAccountId);
+ browser.test.assertEq(id, fileId);
+ setTimeout(() => {
+ browser.test.sendMessage("deleteFinished");
+ });
+ });
+
+ browser.cloudFile.onFileUploadAbort.addListener(
+ async (account, id, tab) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 2,
+ "onFileUploadAbort should not be the wake up event"
+ );
+ let [{ cloudAccountId, fileId, composeTabId }] =
+ await window.sendMessage("getEnvironment");
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(account.id, cloudAccountId);
+ browser.test.assertEq(id, fileId);
+ abortResolveCallback();
+ }
+ );
+
+ browser.cloudFile.onAccountAdded.addListener(account => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onAccountAdded should be the wake up event"
+ );
+ browser.test.sendMessage("accountCreated", account.id);
+ });
+
+ browser.cloudFile.onAccountDeleted.addListener(async accountId => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onAccountDeleted should be the wake up event"
+ );
+ let [{ cloudAccountId }] = await window.sendMessage("getEnvironment");
+ browser.test.assertEq(accountId, cloudAccountId);
+ browser.test.notifyPass("finished");
+ });
+
+ browser.runtime.onInstalled.addListener(async () => {
+ eventCounter++;
+ let [composeTab] = await browser.tabs.query({
+ windowType: "messageCompose",
+ });
+ await window.sendMessage("setEnvironment", {
+ composeTabId: composeTab.id,
+ });
+ browser.test.sendMessage("installed");
+ });
+
+ browser.test.sendMessage("background started");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 3,
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ browser_specific_settings: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ function uploadFile(
+ id,
+ filename,
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ return cloudFileAccount.uploadFile(composeWindow, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ function startUpload(id, filename) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ return cloudFileAccount
+ .uploadFile(composeWindow, testFiles[filename])
+ .catch(() => {});
+ }
+ function cancelUpload(id, filename) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ return cloudFileAccount.cancelFileUpload(
+ composeWindow,
+ testFiles[filename]
+ );
+ }
+ function renameFile(
+ id,
+ uploadId,
+ { newName, newUrl },
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ return cloudFileAccount.renameFile(composeWindow, uploadId, newName).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ Assert.equal(upload.name, newName, "New name should match.");
+ Assert.equal(upload.url, newUrl, "New url should match.");
+ },
+ status => {
+ Assert.equal(status.result, expectedErrorStatus);
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct.`
+ );
+ }
+ );
+ }
+ function deleteFile(id, uploadId) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ return cloudFileAccount.deleteFile(composeWindow, uploadId);
+ }
+
+ let environment = {};
+ extension.onMessage("setEnvironment", data => {
+ if (data.composeTabId) {
+ environment.composeTabId = data.composeTabId;
+ }
+ extension.sendMessage();
+ });
+ extension.onMessage("getEnvironment", () => {
+ extension.sendMessage(environment);
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+
+ composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("installed");
+ await extension.awaitMessage("background started");
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "onFileUpload",
+ "onFileRename",
+ "onFileDeleted",
+ "onFileUploadAbort",
+ "onAccountAdded",
+ "onAccountDeleted",
+ ];
+ for (let eventName of persistent_events) {
+ assertPersistentListeners(extension, "cloudFile", eventName, {
+ primed,
+ });
+ }
+ }
+
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+
+ // Create account.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ cloudFileAccounts.createAccount("ext-cloudfile@mochi.test");
+ await extension.awaitMessage("background started");
+ environment.cloudAccountId = await extension.awaitMessage("accountCreated");
+ checkPersistentListeners({ primed: false });
+
+ // Upload.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ uploadFile(environment.cloudAccountId, "cloudFile1");
+ await extension.awaitMessage("background started");
+ environment.fileId = await extension.awaitMessage("uploadFinished");
+ checkPersistentListeners({ primed: false });
+
+ // Rename.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ renameFile(environment.cloudAccountId, environment.fileId, {
+ newName: "cloudFile3.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ await extension.awaitMessage("background started");
+ await extension.awaitMessage("renameFinished");
+ checkPersistentListeners({ primed: false });
+
+ // Delete.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ deleteFile(environment.cloudAccountId, environment.fileId);
+ await extension.awaitMessage("background started");
+ await extension.awaitMessage("deleteFinished");
+ checkPersistentListeners({ primed: false });
+
+ // Aborted upload.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ environment.aborting = true;
+ startUpload(environment.cloudAccountId, "cloudFile1");
+ await extension.awaitMessage("background started");
+ environment.fileId = await extension.awaitMessage("uploadStarted");
+ cancelUpload(environment.cloudAccountId, "cloudFile1");
+ await extension.awaitMessage("uploadAborted");
+ checkPersistentListeners({ primed: false });
+
+ // Remove account.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ cloudFileAccounts.removeAccount(environment.cloudAccountId);
+ await extension.awaitMessage("background started");
+ checkPersistentListeners({ primed: false });
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ composeWindow.close();
+});
+
+/**
+ * Test cloudFiles without accounts and removed local files.
+ */
+add_task(async function test_incomplete_cloudFiles() {
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+ let uploads = {};
+ let composeWindow;
+ let cloudFileAccount = null;
+
+ async function background() {
+ function createCloudfileAccount(id) {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount", id);
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ let [composerTab] = await browser.tabs.query({
+ windowType: "messageCompose",
+ });
+
+ let [createdAccount] = await createCloudfileAccount(
+ "ext-cloudfile@mochi.test"
+ );
+
+ await new Promise(resolve => {
+ function fileListener(
+ uploadAccount,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ await window.sendMessage("attachAndInvalidate", "cloudFile1");
+ let attachments = await browser.compose.listAttachments(composerTab.id);
+ let [attachmentId] = attachments
+ .filter(e => e.name == "cloudFile1.txt")
+ .map(e => e.id);
+
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composerTab.id, attachmentId, {
+ name: "cloudFile3",
+ }),
+ e => {
+ return (
+ e.message.startsWith(
+ "CloudFile Error: Attachment file not found: "
+ ) && e.message.endsWith("cloudFile1.txt_invalid")
+ );
+ },
+ "browser.compose.updateAttachment() should reject, if the local file does not exist."
+ );
+
+ await removeCloudfileAccount(createdAccount.id);
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composerTab.id, attachmentId, {
+ name: "cloudFile3",
+ }),
+ `CloudFile Error: Account not found: ${createdAccount.id}`,
+ "browser.compose.updateAttachment() should reject, if the account does not exist."
+ );
+
+ browser.test.notifyPass("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ permissions: ["compose"],
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("createAccount", id => {
+ cloudFileAccount = cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("attachAndInvalidate", async filename => {
+ let upload = uploads[filename];
+ await composeWindow.attachToCloudRepeat(
+ uploads[filename],
+ cloudFileAccount
+ );
+
+ let bucket = composeWindow.document.getElementById("attachmentBucket");
+ let item = [...bucket.children].find(e => e.attachment.name == upload.name);
+ Assert.ok(item, "Should have found the attachment item");
+
+ // Invalidate the cloud attachment, simulating a file move/delete.
+ item.attachment.url = `${item.attachment.url}_invalid`;
+ item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id);
+ extension.sendMessage();
+ });
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ cloudFileAccount.uploadFile(composeWindow, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ let account = createAccount();
+ addIdentity(account);
+
+ composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ composeWindow.close();
+});
+
+/** Test data_format "File", which is the default if none is specified in the
+ * manifest. */
+add_task(async function test_file_format() {
+ async function background() {
+ function createCloudfileAccount() {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ browser.test.log("test upload");
+ await new Promise(resolve => {
+ function fileListener(account, { id, name, data }, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let reader = new FileReader();
+ reader.addEventListener("loadend", () => {
+ browser.test.assertEq(reader.result, "you got the moves!\n");
+ setTimeout(() => resolve(id));
+ });
+ reader.readAsText(data);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ browser.test.log("test upload quota error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadWouldExceedQuota",
+ "Quota error: Can't upload file. Only 1KB left of quota."
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: -1,
+ });
+
+ browser.test.log("test upload file size limit error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadExceedsFileLimit",
+ "Upload error: File size is 19KB and exceeds the file size limit of 1KB"
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: -1,
+ });
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("cloudFile");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+
+ extension.onMessage("createAccount", (id = "ext-cloudfile@mochi.test") => {
+ cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage(
+ "uploadFileError",
+ async (id, filename, expectedErrorStatus, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ let status;
+ try {
+ await account.uploadFile(null, testFiles[filename]);
+ } catch (ex) {
+ status = ex;
+ }
+
+ Assert.ok(
+ !!status,
+ `Upload should have failed for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.result,
+ cloudFileAccounts.constants[expectedErrorStatus],
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ extension.sendMessage();
+ }
+ );
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ account.uploadFile(null, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("cloudFile");
+ await extension.unload();
+
+ Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ Assert.equal(cloudFileAccounts.accounts.length, 0);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js
new file mode 100644
index 0000000000..47b804a763
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js
@@ -0,0 +1,226 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function testExecuteBrowserActionWithOptions_mv2(options = {}) {
+ // Make sure the mouse isn't hovering over the browserAction widget.
+ let folderTree = document
+ .getElementById("tabmail")
+ .currentAbout3Pane.document.getElementById("folderTree");
+ EventUtils.synthesizeMouseAtCenter(folderTree, { type: "mouseover" }, window);
+
+ let extensionOptions = {
+ useAddonManager: "temporary",
+ };
+
+ extensionOptions.manifest = {
+ commands: {
+ _execute_browser_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ },
+ browser_action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withPopup) {
+ extensionOptions.manifest.browser_action.default_popup = "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.runtime.sendMessage("from-browser-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = () => {
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.browserAction.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the browserAction has a popup."
+ );
+ browser.test.notifyFail("execute-browser-action-on-clicked-fired");
+ } else {
+ browser.test.notifyPass("execute-browser-action-on-clicked-fired");
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-browser-action-popup") {
+ browser.test.notifyPass("execute-browser-action-popup-opened");
+ }
+ });
+
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+ extension.onMessage("send-keys", () => {
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ });
+
+ await extension.startup();
+
+ await SimpleTest.promiseFocus(window);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-browser-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension)) {
+ await awaitExtensionPanel(extension);
+ }
+ await closeBrowserAction(extension);
+ } else {
+ await extension.awaitFinish("execute-browser-action-on-clicked-fired");
+ }
+ await extension.unload();
+}
+
+add_task(async function test_execute_browser_action_with_popup_mv2() {
+ await testExecuteBrowserActionWithOptions_mv2({
+ withPopup: true,
+ });
+});
+
+add_task(async function test_execute_browser_action_without_popup_mv2() {
+ await testExecuteBrowserActionWithOptions_mv2();
+});
+
+async function testExecuteActionWithOptions_mv3(options = {}) {
+ // Make sure the mouse isn't hovering over the action widget.
+ let folderTree = document
+ .getElementById("tabmail")
+ .currentAbout3Pane.document.getElementById("folderTree");
+ EventUtils.synthesizeMouseAtCenter(folderTree, { type: "mouseover" }, window);
+
+ let extensionOptions = {
+ useAddonManager: "temporary",
+ };
+
+ extensionOptions.manifest = {
+ manifest_version: 3,
+ commands: {
+ _execute_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ },
+ action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withPopup) {
+ extensionOptions.manifest.action.default_popup = "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.runtime.sendMessage("from-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = () => {
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.action.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the action has a popup."
+ );
+ browser.test.notifyFail("execute-action-on-clicked-fired");
+ } else {
+ browser.test.notifyPass("execute-action-on-clicked-fired");
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-action-popup") {
+ browser.test.notifyPass("execute-action-popup-opened");
+ }
+ });
+
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+ extension.onMessage("send-keys", () => {
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ });
+
+ await extension.startup();
+
+ await SimpleTest.promiseFocus(window);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension)) {
+ await awaitExtensionPanel(extension);
+ }
+ await closeBrowserAction(extension);
+ } else {
+ await extension.awaitFinish("execute-action-on-clicked-fired");
+ }
+ await extension.unload();
+}
+
+add_task(async function test_execute_browser_action_with_popup_mv3() {
+ await testExecuteActionWithOptions_mv3({
+ withPopup: true,
+ });
+});
+
+add_task(async function test_execute_browser_action_without_popup_mv3() {
+ await testExecuteActionWithOptions_mv3();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js
new file mode 100644
index 0000000000..a84a2cac3c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js
@@ -0,0 +1,138 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let gAccount;
+
+async function testExecuteComposeActionWithOptions(options = {}) {
+ info(
+ `--> Running test commands_execute_compose_action with the following options: ${JSON.stringify(
+ options
+ )}`
+ );
+
+ let extensionOptions = {};
+ extensionOptions.manifest = {
+ permissions: ["accountsRead"],
+ commands: {
+ _execute_compose_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ mac: "Ctrl+Shift+J",
+ },
+ },
+ },
+ compose_action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withFormatToolbar) {
+ extensionOptions.manifest.compose_action.default_area = "formattoolbar";
+ }
+
+ if (options.withPopup) {
+ extensionOptions.manifest.compose_action.default_popup = "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.test.log("sending from-compose-action-popup");
+ browser.runtime.sendMessage("from-compose-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length, "number of accounts");
+
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.composeAction.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the composeAction has a popup."
+ );
+ browser.test.notifyFail("execute-compose-action-on-clicked-fired");
+ } else {
+ browser.test.notifyPass("execute-compose-action-on-clicked-fired");
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-compose-action-popup") {
+ browser.test.notifyPass("execute-compose-action-popup-opened");
+ }
+ });
+
+ browser.test.log("Sending send-keys");
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+ await extension.startup();
+
+ let composeWindow = await openComposeWindow(gAccount);
+ await focusWindow(composeWindow);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ await extension.awaitMessage("send-keys");
+ info("Simulating ALT+SHIFT+J");
+ let modifiers =
+ AppConstants.platform == "macosx"
+ ? { metaKey: true, shiftKey: true }
+ : { altKey: true, shiftKey: true };
+ EventUtils.synthesizeKey("j", modifiers, composeWindow);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-compose-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension, composeWindow)) {
+ await awaitExtensionPanel(extension, composeWindow);
+ }
+ await closeBrowserAction(extension, composeWindow);
+ } else {
+ await extension.awaitFinish("execute-compose-action-on-clicked-fired");
+ }
+ composeWindow.close();
+ await extension.unload();
+}
+
+add_setup(async () => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+});
+
+let popupJobs = [true, false];
+let formatToolbarJobs = [true, false];
+
+for (let popupJob of popupJobs) {
+ for (let formatToolbarJob of formatToolbarJobs) {
+ add_task(async () => {
+ await testExecuteComposeActionWithOptions({
+ withPopup: popupJob,
+ withFormatToolbar: formatToolbarJob,
+ });
+ });
+ }
+}
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js
new file mode 100644
index 0000000000..2b35b791ec
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js
@@ -0,0 +1,168 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let gMessages;
+
+async function testExecuteMessageDisplayActionWithOptions(msg, options = {}) {
+ info(
+ `--> Running test commands_execute_message_display_action with the following options: ${JSON.stringify(
+ options
+ )}`
+ );
+
+ let extensionOptions = {};
+ extensionOptions.manifest = {
+ commands: {
+ _execute_message_display_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ },
+ message_display_action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withPopup) {
+ extensionOptions.manifest.message_display_action.default_popup =
+ "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.test.log("sending from-message-display-action-popup");
+ browser.runtime.sendMessage("from-message-display-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = () => {
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.messageDisplayAction.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the messageDisplayAction has a popup."
+ );
+ browser.test.notifyFail(
+ "execute-message-display-action-on-clicked-fired"
+ );
+ } else {
+ browser.test.notifyPass(
+ "execute-message-display-action-on-clicked-fired"
+ );
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-message-display-action-popup") {
+ browser.test.notifyPass(
+ "execute-message-display-action-popup-opened"
+ );
+ }
+ });
+
+ browser.test.log("Sending send-keys");
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+ extension.onMessage("send-keys", () => {
+ info("Simulating ALT+SHIFT+J");
+ EventUtils.synthesizeKey(
+ "j",
+ { altKey: true, shiftKey: true },
+ messageWindow
+ );
+ });
+
+ await extension.startup();
+
+ let tabmail = document.getElementById("tabmail");
+ let messageWindow = window;
+ let aboutMessage = tabmail.currentAboutMessage;
+ switch (options.displayType) {
+ case "tab":
+ await openMessageInTab(msg);
+ aboutMessage = tabmail.currentAboutMessage;
+ break;
+ case "window":
+ messageWindow = await openMessageInWindow(msg);
+ aboutMessage = messageWindow.messageBrowser.contentWindow;
+ break;
+ }
+ await SimpleTest.promiseFocus(aboutMessage);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-message-display-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension, aboutMessage)) {
+ await awaitExtensionPanel(extension, aboutMessage);
+ }
+ await closeBrowserAction(extension, aboutMessage);
+ } else {
+ await extension.awaitFinish(
+ "execute-message-display-action-on-clicked-fired"
+ );
+ }
+
+ switch (options.displayType) {
+ case "tab":
+ tabmail.closeTab();
+ break;
+ case "window":
+ messageWindow.close();
+ break;
+ }
+
+ await extension.unload();
+}
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ gMessages = [...subFolders[0].messages];
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders[0].URI);
+ about3Pane.threadTree.selectedIndex = 0;
+});
+
+let popupJobs = [true, false];
+let displayJobs = ["3pane", "tab", "window"];
+
+for (let popupJob of popupJobs) {
+ for (let displayJob of displayJobs) {
+ add_task(async () => {
+ await testExecuteMessageDisplayActionWithOptions(gMessages[1], {
+ withPopup: popupJob,
+ displayType: displayJob,
+ });
+ });
+ }
+}
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js
new file mode 100644
index 0000000000..c38fdc291c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js
@@ -0,0 +1,142 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "_locales/en/messages.json": {
+ with_translation: {
+ message: "The description",
+ description: "A description",
+ },
+ },
+ },
+ manifest: {
+ name: "Commands Extension",
+ default_locale: "en",
+ commands: {
+ "with-desciption": {
+ suggested_key: {
+ default: "Ctrl+Shift+Y",
+ },
+ description: "should have a description",
+ },
+ "without-description": {
+ suggested_key: {
+ default: "Ctrl+Shift+D",
+ },
+ },
+ "with-platform-info": {
+ suggested_key: {
+ mac: "Ctrl+Shift+M",
+ linux: "Ctrl+Shift+L",
+ windows: "Ctrl+Shift+W",
+ android: "Ctrl+Shift+A",
+ },
+ },
+ "with-translation": {
+ description: "__MSG_with_translation__",
+ },
+ "without-suggested-key": {
+ description: "has no suggested_key",
+ },
+ "without-suggested-key-nor-description": {},
+ },
+ },
+
+ background() {
+ browser.test.onMessage.addListener((message, additionalScope) => {
+ browser.commands.getAll(commands => {
+ let errorMessage = "getAll should return an array of commands";
+ browser.test.assertEq(commands.length, 6, errorMessage);
+
+ let command = commands.find(c => c.name == "with-desciption");
+
+ errorMessage =
+ "The description should match what is provided in the manifest";
+ browser.test.assertEq(
+ "should have a description",
+ command.description,
+ errorMessage
+ );
+
+ errorMessage =
+ "The shortcut should match the default shortcut provided in the manifest";
+ browser.test.assertEq("Ctrl+Shift+Y", command.shortcut, errorMessage);
+
+ command = commands.find(c => c.name == "without-description");
+
+ errorMessage =
+ "The description should be empty when it is not provided";
+ browser.test.assertEq(null, command.description, errorMessage);
+
+ errorMessage =
+ "The shortcut should match the default shortcut provided in the manifest";
+ browser.test.assertEq("Ctrl+Shift+D", command.shortcut, errorMessage);
+
+ let platformKeys = {
+ macosx: "M",
+ linux: "L",
+ win: "W",
+ android: "A",
+ };
+
+ command = commands.find(c => c.name == "with-platform-info");
+ let platformKey = platformKeys[additionalScope.platform];
+ let shortcut = `Ctrl+Shift+${platformKey}`;
+ errorMessage = `The shortcut should match the one provided in the manifest for OS='${additionalScope.platform}'`;
+ browser.test.assertEq(shortcut, command.shortcut, errorMessage);
+
+ command = commands.find(c => c.name == "with-translation");
+ browser.test.assertEq(
+ command.description,
+ "The description",
+ "The description can be localized"
+ );
+
+ command = commands.find(c => c.name == "without-suggested-key");
+
+ browser.test.assertEq(
+ "has no suggested_key",
+ command.description,
+ "The description should match what is provided in the manifest"
+ );
+
+ browser.test.assertEq(
+ "",
+ command.shortcut,
+ "The shortcut should be empty if not provided"
+ );
+
+ command = commands.find(
+ c => c.name == "without-suggested-key-nor-description"
+ );
+
+ browser.test.assertEq(
+ null,
+ command.description,
+ "The description should be empty when it is not provided"
+ );
+
+ browser.test.assertEq(
+ "",
+ command.shortcut,
+ "The shortcut should be empty if not provided"
+ );
+
+ browser.test.notifyPass("commands");
+ });
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ extension.sendMessage("additional-scope", {
+ platform: AppConstants.platform,
+ });
+ await extension.awaitFinish("commands");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js
new file mode 100644
index 0000000000..db90d71f00
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js
@@ -0,0 +1,59 @@
+"use strict";
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "commands@mochi.test" } },
+ commands: {
+ foo: {
+ suggested_key: {
+ default: "Ctrl+Shift+V",
+ },
+ description: "The foo command",
+ },
+ },
+ },
+ async background() {
+ const { commands } = browser.runtime.getManifest();
+
+ const originalFoo = commands.foo;
+
+ let resolver = {};
+ resolver.promise = new Promise(resolve => (resolver.resolve = resolve));
+
+ browser.commands.onChanged.addListener(update => {
+ browser.test.assertDeepEq(
+ update,
+ {
+ name: "foo",
+ newShortcut: "Ctrl+Shift+L",
+ oldShortcut: originalFoo.suggested_key.default,
+ },
+ `The name should match what was provided in the manifest.
+ The new shortcut should match what was provided in the update.
+ The old shortcut should match what was provided in the manifest
+ `
+ );
+ browser.test.assertFalse(
+ resolver.hasResolvedAlready,
+ `resolver was not resolved yet`
+ );
+ resolver.resolve();
+ resolver.hasResolvedAlready = true;
+ });
+
+ await browser.commands.update({ name: "foo", shortcut: "Ctrl+Shift+L" });
+ // We're checking that nothing emits when
+ // the new shortcut is identical to the old one
+ await browser.commands.update({ name: "foo", shortcut: "Ctrl+Shift+L" });
+
+ await resolver.promise;
+
+ browser.test.notifyPass("commands");
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("commands");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js
new file mode 100644
index 0000000000..82928957f4
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js
@@ -0,0 +1,577 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var testCommands = [
+ // Ctrl Shortcuts
+ {
+ name: "toggle-ctrl-a",
+ shortcut: "Ctrl+A",
+ key: "A",
+ // Does not work in compose window on Linux.
+ skip: ["messageCompose", "content"],
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-up",
+ shortcut: "Ctrl+Up",
+ key: "VK_UP",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ // Alt Shortcuts
+ {
+ name: "toggle-alt-a",
+ shortcut: "Alt+A",
+ key: "A",
+ // Does not work in compose window on Mac.
+ skip: ["messageCompose"],
+ modifiers: {
+ altKey: true,
+ },
+ },
+ {
+ name: "toggle-alt-down",
+ shortcut: "Alt+Down",
+ key: "VK_DOWN",
+ modifiers: {
+ altKey: true,
+ },
+ },
+ // Mac Shortcuts
+ {
+ name: "toggle-command-shift-page-up",
+ shortcutMac: "Command+Shift+PageUp",
+ key: "VK_PAGE_UP",
+ modifiers: {
+ accelKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "toggle-mac-control-shift+period",
+ shortcut: "Ctrl+Shift+Period",
+ shortcutMac: "MacCtrl+Shift+Period",
+ key: "VK_PERIOD",
+ modifiers: {
+ ctrlKey: true,
+ shiftKey: true,
+ },
+ },
+ // Ctrl+Shift Shortcuts
+ {
+ name: "toggle-ctrl-shift-left",
+ shortcut: "Ctrl+Shift+Left",
+ key: "VK_LEFT",
+ modifiers: {
+ accelKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-shift-1",
+ shortcut: "Ctrl+Shift+1",
+ key: "1",
+ modifiers: {
+ accelKey: true,
+ shiftKey: true,
+ },
+ },
+ // Alt+Shift Shortcuts
+ {
+ name: "toggle-alt-shift-1",
+ shortcut: "Alt+Shift+1",
+ key: "1",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ // TODO: This results in multiple events fired. See bug 1805375.
+ /*
+ {
+ name: "toggle-alt-shift-a",
+ shortcut: "Alt+Shift+A",
+ key: "A",
+ // Does not work in compose window on Mac.
+ skip: ["messageCompose"],
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ */
+ {
+ name: "toggle-alt-shift-right",
+ shortcut: "Alt+Shift+Right",
+ key: "VK_RIGHT",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ // Function keys
+ {
+ name: "function-keys-Alt+Shift+F3",
+ shortcut: "Alt+Shift+F3",
+ key: "VK_F3",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "function-keys-F2",
+ shortcut: "F2",
+ key: "VK_F2",
+ modifiers: {
+ altKey: false,
+ shiftKey: false,
+ },
+ },
+ // Misc Shortcuts
+ {
+ name: "valid-command-with-unrecognized-property-name",
+ shortcut: "Alt+Shift+3",
+ key: "3",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ unrecognized_property: "with-a-random-value",
+ },
+ {
+ name: "spaces-in-shortcut-name",
+ shortcut: " Alt + Shift + 2 ",
+ key: "2",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-space",
+ shortcut: "Ctrl+Space",
+ key: "VK_SPACE",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-comma",
+ shortcut: "Ctrl+Comma",
+ key: "VK_COMMA",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-period",
+ shortcut: "Ctrl+Period",
+ key: "VK_PERIOD",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-alt-v",
+ shortcut: "Ctrl+Alt+V",
+ key: "V",
+ modifiers: {
+ accelKey: true,
+ altKey: true,
+ },
+ },
+];
+
+requestLongerTimeout(2);
+
+add_task(async function test_user_defined_commands() {
+ let win1 = await openNewMailWindow();
+
+ let commands = {};
+ let isMac = AppConstants.platform == "macosx";
+ let totalMacOnlyCommands = 0;
+ let numberNumericCommands = 4;
+
+ for (let testCommand of testCommands) {
+ let command = {
+ suggested_key: {},
+ };
+
+ if (testCommand.shortcut) {
+ command.suggested_key.default = testCommand.shortcut;
+ }
+
+ if (testCommand.shortcutMac) {
+ command.suggested_key.mac = testCommand.shortcutMac;
+ }
+
+ if (testCommand.shortcutMac && !testCommand.shortcut) {
+ totalMacOnlyCommands++;
+ }
+
+ if (testCommand.unrecognized_property) {
+ command.unrecognized_property = testCommand.unrecognized_property;
+ }
+
+ commands[testCommand.name] = command;
+ }
+
+ function background() {
+ browser.commands.onCommand.addListener((commandName, activeTab) => {
+ browser.test.sendMessage("oncommand event received", {
+ commandName,
+ activeTab,
+ });
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands,
+ },
+ background,
+ });
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ /Reading manifest: Warning processing commands.*.unrecognized_property: An unexpected property was found/,
+ },
+ ]);
+ });
+
+ // Unrecognized_property in manifest triggers warning.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("ready");
+
+ async function runTest(window, expectedTabType) {
+ for (let testCommand of testCommands) {
+ if (testCommand.skip && testCommand.skip.includes(expectedTabType)) {
+ continue;
+ }
+ if (testCommand.shortcutMac && !testCommand.shortcut && !isMac) {
+ continue;
+ }
+ await BrowserTestUtils.synthesizeKey(
+ testCommand.key,
+ testCommand.modifiers,
+ window.browsingContext
+ );
+ let message = await extension.awaitMessage("oncommand event received");
+ is(
+ message.commandName,
+ testCommand.name,
+ `Expected onCommand listener to fire with the correct name: ${testCommand.name}`
+ );
+ is(
+ message.activeTab.type,
+ expectedTabType,
+ `Expected onCommand listener to fire with the correct tab type: ${expectedTabType}`
+ );
+ }
+ }
+
+ // Create another window after the extension is loaded.
+ let win2 = await openNewMailWindow();
+
+ let totalTestCommands =
+ Object.keys(testCommands).length + numberNumericCommands;
+ let expectedCommandsRegistered = isMac
+ ? totalTestCommands
+ : totalTestCommands - totalMacOnlyCommands;
+
+ let account = createAccount();
+ addIdentity(account);
+ let win3 = await openComposeWindow(account);
+ // Some key combinations do not work if the TO field has focus.
+ win3.document.querySelector("editor").focus();
+
+ // Confirm the keysets have been added to all windows.
+ let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`;
+
+ let keyset = win1.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ "Expected keyset of window #1 to have the correct number of children"
+ );
+
+ keyset = win2.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ "Expected keyset of window #2 to have the correct number of children"
+ );
+
+ keyset = win3.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ "Expected keyset of window #3 to have the correct number of children"
+ );
+
+ // Confirm that the commands are registered to all windows.
+ await focusWindow(win1);
+ await runTest(win1, "mail");
+
+ await focusWindow(win2);
+ await runTest(win2, "mail");
+
+ await focusWindow(win3);
+ await runTest(win3, "messageCompose");
+
+ // Unload the extension and confirm that the keysets have been removed from all windows.
+ await extension.unload();
+
+ keyset = win1.document.getElementById(keysetID);
+ is(keyset, null, "Expected keyset to be removed from the window #1");
+
+ keyset = win2.document.getElementById(keysetID);
+ is(keyset, null, "Expected keyset to be removed from the window #2");
+
+ keyset = win3.document.getElementById(keysetID);
+ is(keyset, null, "Expected keyset to be removed from the window #3");
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+ await BrowserTestUtils.closeWindow(win3);
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+
+add_task(async function test_commands_MV3_event_page() {
+ let win1 = await openNewMailWindow();
+
+ let commands = {};
+ let isMac = AppConstants.platform == "macosx";
+ let totalMacOnlyCommands = 0;
+ let numberNumericCommands = 4;
+
+ for (let testCommand of testCommands) {
+ let command = {
+ suggested_key: {},
+ };
+
+ if (testCommand.shortcut) {
+ command.suggested_key.default = testCommand.shortcut;
+ }
+
+ if (testCommand.shortcutMac) {
+ command.suggested_key.mac = testCommand.shortcutMac;
+ }
+
+ if (testCommand.shortcutMac && !testCommand.shortcut) {
+ totalMacOnlyCommands++;
+ }
+
+ if (testCommand.unrecognized_property) {
+ command.unrecognized_property = testCommand.unrecognized_property;
+ }
+
+ commands[testCommand.name] = command;
+ }
+
+ function background() {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.test.onMessage.addListener(async message => {
+ if (message == "createPopup") {
+ let popup = await browser.windows.create({
+ type: "popup",
+ url: "example.html",
+ });
+ browser.test.sendMessage("popupCreated", popup);
+ }
+ });
+
+ browser.commands.onCommand.addListener(async (commandName, activeTab) => {
+ browser.test.sendMessage("oncommand event received", {
+ eventCount: ++eventCounter,
+ commandName,
+ activeTab,
+ });
+ });
+ browser.test.sendMessage("ready");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ "example.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>This is an example page</p>
+ </body>
+ </html>`,
+ },
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: { gecko: { id: "commands@mochi.test" } },
+ commands,
+ },
+ });
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ /Reading manifest: Warning processing commands.*.unrecognized_property: An unexpected property was found/,
+ },
+ ]);
+ });
+
+ // Unrecognized_property in manifest triggers warning.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("ready");
+
+ // Check for persistent listener.
+ assertPersistentListeners(extension, "commands", "onCommand", {
+ primed: false,
+ });
+
+ let gEventCounter = 0;
+ async function runTest(window, expectedTabType) {
+ // The second run will terminate the background script before each keypress,
+ // verifying that the background script is waking up correctly.
+ for (let terminateBackground of [false, true]) {
+ for (let testCommand of testCommands) {
+ if (testCommand.skip && testCommand.skip.includes(expectedTabType)) {
+ continue;
+ }
+ if (testCommand.shortcutMac && !testCommand.shortcut && !isMac) {
+ continue;
+ }
+
+ if (terminateBackground) {
+ gEventCounter = 0;
+ }
+
+ if (terminateBackground) {
+ // Terminate the background and verify the primed persistent listener.
+ await extension.terminateBackground({
+ disableResetIdleForTest: true,
+ });
+ assertPersistentListeners(extension, "commands", "onCommand", {
+ primed: true,
+ });
+ await BrowserTestUtils.synthesizeKey(
+ testCommand.key,
+ testCommand.modifiers,
+ window.browsingContext
+ );
+ // Wait for background restart.
+ await extension.awaitMessage("ready");
+ } else {
+ await BrowserTestUtils.synthesizeKey(
+ testCommand.key,
+ testCommand.modifiers,
+ window.browsingContext
+ );
+ }
+
+ let message = await extension.awaitMessage("oncommand event received");
+ is(
+ message.commandName,
+ testCommand.name,
+ `onCommand listener should fire with the correct command name`
+ );
+ is(
+ message.activeTab.type,
+ expectedTabType,
+ `onCommand listener should fire with the correct tab type`
+ );
+ is(
+ message.eventCount,
+ ++gEventCounter,
+ `Event counter should be correct`
+ );
+ }
+ }
+ }
+
+ // Create another window after the extension is loaded.
+ let win2 = await openNewMailWindow();
+
+ let totalTestCommands =
+ Object.keys(testCommands).length + numberNumericCommands;
+ let expectedCommandsRegistered = isMac
+ ? totalTestCommands
+ : totalTestCommands - totalMacOnlyCommands;
+
+ let account = createAccount();
+ addIdentity(account);
+ let win3 = await openComposeWindow(account);
+ // Some key combinations do not work if the TO field has focus.
+ win3.document.querySelector("editor").focus();
+
+ // Open a popup window.
+ let popupPromise = extension.awaitMessage("popupCreated");
+ extension.sendMessage("createPopup");
+ let popup = await popupPromise;
+ let win4 = Services.wm.getOuterWindowWithId(popup.id);
+
+ // Confirm the keysets have been added to all windows.
+ let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`;
+
+ let windows = [
+ { window: win1, autoRemove: false, type: "mail" },
+ { window: win2, autoRemove: false, type: "mail" },
+ { window: win3, autoRemove: false, type: "messageCompose" },
+ { window: win4, autoRemove: true, type: "content" },
+ ];
+ for (let i in windows) {
+ let keyset = windows[i].window.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ `Expected keyset of window #${i} to have the correct number of children`
+ );
+
+ // Confirm that the commands are registered to all windows.
+ await focusWindow(windows[i].window);
+ await runTest(windows[i].window, windows[i].type);
+ }
+
+ // Unload the extension and confirm that the keysets have been removed from
+ // all windows.
+ await extension.unload();
+ for (let i in windows) {
+ // Extension popup windows are removed/closed on extension unload, so they
+ // have to skip this part of the test.
+ if (windows[i].autoRemove) {
+ continue;
+ }
+ let keyset = windows[i].window.document.getElementById(keysetID);
+ is(keyset, null, `Expected keyset to be removed from the window #${i}`);
+ await BrowserTestUtils.closeWindow(windows[i].window);
+ }
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js
new file mode 100644
index 0000000000..3a240cc1ce
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js
@@ -0,0 +1,74 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_multiple_messages_selected() {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 2);
+ await TestUtils.waitForCondition(
+ () => subFolders[0].messages.hasMoreElements(),
+ "Messages should be added to folder"
+ );
+
+ async function background() {
+ browser.commands.onCommand.addListener((commandName, activeTab) => {
+ browser.test.sendMessage("oncommand event received", {
+ commandName,
+ activeTab,
+ });
+ });
+
+ let { messages } = await browser.messages.query({});
+ await browser.mailTabs.setSelectedMessages(messages.map(m => m.id));
+ let { messages: selectedMessages } =
+ await browser.mailTabs.getSelectedMessages();
+ browser.test.assertEq(
+ selectedMessages.length,
+ 2,
+ "Should have two messages selected"
+ );
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["accountsRead", "messagesRead"],
+ commands: {
+ "test-multi-message": {
+ suggested_key: {
+ default: "Ctrl+Up",
+ },
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Trigger the registered command.
+ await BrowserTestUtils.synthesizeKey(
+ "VK_UP",
+ {
+ accelKey: true,
+ },
+ window.browsingContext
+ );
+ let message = await extension.awaitMessage("oncommand event received");
+ is(
+ message.commandName,
+ "test-multi-message",
+ `Expected onCommand listener to fire with the correct name: test-multi-message`
+ );
+ is(
+ message.activeTab.type,
+ "mail",
+ `Expected onCommand listener to fire with the correct tab type: mail`
+ );
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_update.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_update.js
new file mode 100644
index 0000000000..1d57585ca6
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_update.js
@@ -0,0 +1,357 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+
+function enableAddon(addon) {
+ return new Promise(resolve => {
+ AddonManager.addAddonListener({
+ onEnabled(enabledAddon) {
+ if (enabledAddon.id == addon.id) {
+ resolve();
+ AddonManager.removeAddonListener(this);
+ }
+ },
+ });
+ addon.enable();
+ });
+}
+
+function disableAddon(addon) {
+ return new Promise(resolve => {
+ AddonManager.addAddonListener({
+ onDisabled(disabledAddon) {
+ if (disabledAddon.id == addon.id) {
+ resolve();
+ AddonManager.removeAddonListener(this);
+ }
+ },
+ });
+ addon.disable();
+ });
+}
+
+add_task(async function test_update_defined_command() {
+ let extension;
+ let updatedExtension;
+
+ registerCleanupFunction(async () => {
+ await extension.unload();
+
+ // updatedExtension might not have started up if we didn't make it that far.
+ if (updatedExtension) {
+ await updatedExtension.unload();
+ }
+
+ // Check that ESS is cleaned up on uninstall.
+ let storedCommands = ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ "commands"
+ );
+ is(storedCommands.length, 0, "There are no stored commands after unload");
+ });
+
+ extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id: "commands_update@mochi.test" } },
+ commands: {
+ foo: {
+ suggested_key: {
+ default: "Ctrl+Shift+I",
+ },
+ description: "The foo command",
+ },
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "update") {
+ await browser.commands.update(data);
+ browser.test.sendMessage("updateDone");
+ return;
+ } else if (msg == "reset") {
+ await browser.commands.reset(data);
+ browser.test.sendMessage("resetDone");
+ return;
+ } else if (msg != "run") {
+ return;
+ }
+ // Test initial manifest command.
+ let commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is 1 command");
+ let command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is right");
+ browser.test.assertEq(
+ "The foo command",
+ command.description,
+ "The description is right"
+ );
+ browser.test.assertEq(
+ "Ctrl+Shift+I",
+ command.shortcut,
+ "The shortcut is right"
+ );
+
+ // Update the shortcut.
+ await browser.commands.update({
+ name: "foo",
+ shortcut: "Ctrl+Shift+L",
+ });
+
+ // Test the updated shortcut.
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The foo command",
+ command.description,
+ "The description is unchanged"
+ );
+ browser.test.assertEq(
+ "Ctrl+Shift+L",
+ command.shortcut,
+ "The shortcut is updated"
+ );
+
+ // Update the description.
+ await browser.commands.update({
+ name: "foo",
+ description: "The only command",
+ });
+
+ // Test the updated shortcut.
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The only command",
+ command.description,
+ "The description is updated"
+ );
+ browser.test.assertEq(
+ "Ctrl+Shift+L",
+ command.shortcut,
+ "The shortcut is unchanged"
+ );
+
+ // Clear the shortcut.
+ await browser.commands.update({
+ name: "foo",
+ shortcut: "",
+ });
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The only command",
+ command.description,
+ "The description is unchanged"
+ );
+ browser.test.assertEq("", command.shortcut, "The shortcut is empty");
+
+ // Update the description and shortcut.
+ await browser.commands.update({
+ name: "foo",
+ description: "The new command",
+ shortcut: " Alt+ Shift +9",
+ });
+
+ // Test the updated shortcut.
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The new command",
+ command.description,
+ "The description is updated"
+ );
+ browser.test.assertEq(
+ "Alt+Shift+9",
+ command.shortcut,
+ "The shortcut is updated"
+ );
+
+ // Test a bad shortcut update.
+ browser.test.assertThrows(
+ () =>
+ browser.commands.update({ name: "foo", shortcut: "Ctl+Shift+L" }),
+ /Type error for parameter detail .+ primary modifier and a key/,
+ "It rejects for a bad shortcut"
+ );
+
+ // Try to update a command that doesn't exist.
+ await browser.test.assertRejects(
+ browser.commands.update({ name: "bar", shortcut: "Ctrl+Shift+L" }),
+ 'Unknown command "bar"',
+ "It rejects for an unknown command"
+ );
+
+ browser.test.notifyPass("commands");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+
+ function extensionKeyset(extensionId) {
+ return document.getElementById(
+ makeWidgetId(`ext-keyset-id-${extensionId}`)
+ );
+ }
+
+ function checkKey(extensionId, shortcutKey, modifiers) {
+ let keyset = extensionKeyset(extensionId);
+ is(keyset.children.length, 1, "There is 1 key in the keyset");
+ let key = keyset.children[0];
+ is(key.getAttribute("key"), shortcutKey, "The key is correct");
+ is(key.getAttribute("modifiers"), modifiers, "The modifiers are correct");
+ }
+
+ function checkNumericKey(extensionId, key, modifiers) {
+ let keyset = extensionKeyset(extensionId);
+ is(
+ keyset.children.length,
+ 2,
+ "There are 2 keys in the keyset now, 1 of which contains a keycode."
+ );
+ let numpadKey = keyset.children[0];
+ is(
+ numpadKey.getAttribute("keycode"),
+ `VK_NUMPAD${key}`,
+ "The numpad keycode is correct."
+ );
+ is(
+ numpadKey.getAttribute("modifiers"),
+ modifiers,
+ "The modifiers are correct"
+ );
+
+ let originalNumericKey = keyset.children[1];
+ is(
+ originalNumericKey.getAttribute("keycode"),
+ `VK_${key}`,
+ "The original key is correct."
+ );
+ is(
+ originalNumericKey.getAttribute("modifiers"),
+ modifiers,
+ "The modifiers are correct"
+ );
+ }
+
+ // Check that the <key> is set for the original shortcut.
+ checkKey(extension.id, "I", "accel,shift");
+
+ await extension.awaitMessage("ready");
+ extension.sendMessage("run");
+ await extension.awaitFinish("commands");
+
+ // Check that the <keycode> has been updated.
+ checkNumericKey(extension.id, "9", "alt,shift");
+
+ // Check that the updated command is stored in ExtensionSettingsStore.
+ let storedCommands = ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ "commands"
+ );
+ is(storedCommands.length, 1, "There is only one stored command");
+ let command = ExtensionSettingsStore.getSetting(
+ "commands",
+ "foo",
+ extension.id
+ ).value;
+ is(command.description, "The new command", "The description is stored");
+ is(command.shortcut, "Alt+Shift+9", "The shortcut is stored");
+
+ // Check that the key is updated immediately.
+ extension.sendMessage("update", { name: "foo", shortcut: "Ctrl+Shift+M" });
+ await extension.awaitMessage("updateDone");
+ checkKey(extension.id, "M", "accel,shift");
+
+ // Ensure all successive updates are stored.
+ // Force the command to only have a description saved.
+ await ExtensionSettingsStore.addSetting(extension.id, "commands", "foo", {
+ description: "description only",
+ });
+ // This command now only has a description set in storage, also update the shortcut.
+ extension.sendMessage("update", { name: "foo", shortcut: "Alt+Shift+9" });
+ await extension.awaitMessage("updateDone");
+ let storedCommand = await ExtensionSettingsStore.getSetting(
+ "commands",
+ "foo",
+ extension.id
+ );
+ is(
+ storedCommand.value.shortcut,
+ "Alt+Shift+9",
+ "The shortcut is saved correctly"
+ );
+ is(
+ storedCommand.value.description,
+ "description only",
+ "The description is saved correctly"
+ );
+
+ // Calling browser.commands.reset("foo") should reset to manifest version.
+ extension.sendMessage("reset", "foo");
+ await extension.awaitMessage("resetDone");
+
+ checkKey(extension.id, "I", "accel,shift");
+
+ // Check that enable/disable removes the keyset and reloads the saved command.
+ let addon = await AddonManager.getAddonByID(extension.id);
+ await disableAddon(addon);
+ let keyset = extensionKeyset(extension.id);
+ is(keyset, null, "The extension keyset is removed when disabled");
+ // Add some commands to storage, only "foo" should get loaded.
+ await ExtensionSettingsStore.addSetting(extension.id, "commands", "foo", {
+ shortcut: "Alt+Shift+9",
+ });
+ await ExtensionSettingsStore.addSetting(extension.id, "commands", "unknown", {
+ shortcut: "Ctrl+Shift+P",
+ });
+ storedCommands = ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ "commands"
+ );
+ is(storedCommands.length, 2, "There are now 2 commands stored");
+ await enableAddon(addon);
+ // Wait for the keyset to appear (it's async on enable).
+ await TestUtils.waitForCondition(() => extensionKeyset(extension.id));
+ // The keyset is back with the value from ExtensionSettingsStore.
+ checkNumericKey(extension.id, "9", "alt,shift");
+
+ // Check that an update to a shortcut in the manifest is mapped correctly.
+ updatedExtension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id: "commands_update@mochi.test" } },
+ commands: {
+ foo: {
+ suggested_key: {
+ default: "Ctrl+Shift+L",
+ },
+ description: "The foo command",
+ },
+ },
+ },
+ });
+ await updatedExtension.startup();
+
+ await TestUtils.waitForCondition(() => extensionKeyset(extension.id));
+ // Shortcut is unchanged since it was previously updated.
+ checkNumericKey(extension.id, "9", "alt,shift");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction.js
new file mode 100644
index 0000000000..aba352edf1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction.js
@@ -0,0 +1,268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+});
+
+// This test uses a command from the menus API to open the popup.
+add_task(async function test_popup_open_with_menu_command() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ for (let area of ["maintoolbar", "formattoolbar"]) {
+ let testConfig = {
+ actionType: "compose_action",
+ testType: "open-with-menu-command",
+ default_area: area,
+ window: composeWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ }
+
+ composeWindow.close();
+});
+
+add_task(async function test_theme_icons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action@mochi.test",
+ },
+ },
+ compose_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ let uuid = extension.uuid;
+ let button = composeWindow.document.getElementById(
+ "compose_action_mochi_test-composeAction-toolbarbutton"
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(
+ composeWindow.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ Assert.equal(
+ composeWindow.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ composeWindow.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+
+ composeWindow.close();
+ await extension.unload();
+});
+
+add_task(async function test_button_order() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ area: "maintoolbar",
+ toolbar: "composeToolbar2",
+ },
+ {
+ name: "addon2",
+ area: "formattoolbar",
+ toolbar: "FormatToolbar",
+ },
+ {
+ name: "addon3",
+ area: "maintoolbar",
+ toolbar: "composeToolbar2",
+ },
+ {
+ name: "addon4",
+ area: "formattoolbar",
+ toolbar: "FormatToolbar",
+ },
+ ],
+ composeWindow,
+ "compose_action"
+ );
+
+ composeWindow.close();
+});
+
+add_task(async function test_upgrade() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ // Add a compose_action, to make sure the currentSet has been initialized.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension1",
+ applications: { gecko: { id: "Extension1@mochi.test" } },
+ compose_action: {
+ default_title: "Extension1",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension1 ready");
+ },
+ });
+ await extension1.startup();
+ await extension1.awaitMessage("Extension1 ready");
+
+ // Add extension without a compose_action.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 ready");
+ },
+ });
+ await extension2.startup();
+ await extension2.awaitMessage("Extension2 ready");
+
+ // Update the extension, now including a compose_action.
+ let updatedExtension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "2.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ compose_action: {
+ default_title: "Extension2",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 updated");
+ },
+ });
+ await updatedExtension2.startup();
+ await updatedExtension2.awaitMessage("Extension2 updated");
+
+ let button = composeWindow.document.getElementById(
+ "extension2_mochi_test-composeAction-toolbarbutton"
+ );
+
+ Assert.ok(button, "Button should exist");
+
+ await extension1.unload();
+ await extension2.unload();
+ await updatedExtension2.unload();
+
+ composeWindow.close();
+});
+
+add_task(async function test_iconPath() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ // String values for the default_icon manifest entry have been tested in the
+ // theme_icons test already. Here we test imagePath objects for the manifest key
+ // and string values as well as objects for the setIcons() function.
+ let files = {
+ "background.js": async () => {
+ await window.sendMessage("checkState", "icon1.png");
+
+ await browser.composeAction.setIcon({ path: "icon2.png" });
+ await window.sendMessage("checkState", "icon2.png");
+
+ await browser.composeAction.setIcon({ path: { 16: "icon3.png" } });
+ await window.sendMessage("checkState", "icon3.png");
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action@mochi.test",
+ },
+ },
+ compose_action: {
+ default_title: "default",
+ default_icon: { 16: "icon1.png" },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkState", async expected => {
+ let uuid = extension.uuid;
+ let button = composeWindow.document.getElementById(
+ "compose_action_mochi_test-composeAction-toolbarbutton"
+ );
+
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/${expected}")`,
+ `Icon path should be correct.`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js
new file mode 100644
index 0000000000..2c858cf8ab
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+});
+
+// This test clicks on the action button to open the popup.
+add_task(async function test_popup_open_with_click() {
+ for (let area of [null, "formattoolbar"]) {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await run_popup_test({
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ });
+
+ await run_popup_test({
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ disable_button: true,
+ });
+
+ await run_popup_test({
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ use_default_popup: true,
+ });
+
+ composeWindow.close();
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ }
+});
+
+let background_for_openPopup_tests = async () => {
+ let composeTab = await browser.compose.beginNew();
+ browser.test.assertTrue(!!composeTab, "should have found a compose tab");
+
+ let windows = await browser.windows.getAll();
+ let composeWindow = windows.find(window => window.type == "messageCompose");
+ browser.test.assertTrue(
+ !!composeWindow,
+ "should have found a compose window"
+ );
+
+ // The test starts with an opened composeWindow, the compose_action
+ // is allowed there and should be visible, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(composeWindow.id)).focused,
+ "composeWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded while the compose window is active"
+ );
+ await window.waitForMessage();
+
+ // Disable the compose_action, openPopup() should fail.
+ await browser.composeAction.disable();
+ browser.test.assertFalse(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have failed after the action button was disabled"
+ );
+
+ // Enable the compose_action, openPopup() should succeed.
+ await browser.composeAction.enable();
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded after the action button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a compose_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ (await browser.windows.get(popupWindow.id)).focused,
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the compose_action of the compose window, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup({
+ windowId: composeWindow.id,
+ }),
+ "openPopup() should have succeeded when explicitly requesting the compose window"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(composeWindow.id)).focused,
+ "composeWindow should be focused"
+ );
+
+ // The compose window is focused now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded while the compose window is active"
+ );
+ await window.waitForMessage();
+
+ // Collapse the toolbar, openPopup() should fail.
+ await window.sendMessage("collapseToolbar", true);
+ browser.test.assertFalse(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have failed while the toolbar is collapsed"
+ );
+
+ // Restore the toolbar, openPopup() should succeed.
+ await window.sendMessage("collapseToolbar", false);
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded after the toolbar is restored"
+ );
+ await window.waitForMessage();
+
+ // Close the popup window and finish
+ await browser.windows.remove(popupWindow.id);
+ await browser.windows.remove(composeWindow.id);
+ browser.test.notifyPass("finished");
+};
+
+// This test uses openPopup() to open the popup in a compose window.
+add_task(
+ async function test_popup_open_with_openPopup_in_compose_maintoolbar() {
+ let files = {
+ "background.js": background_for_openPopup_tests,
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ compose_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ extension.onMessage("collapseToolbar", state => {
+ let window = Services.wm.getMostRecentWindow("msgcompose");
+ let toolbar = window.document.getElementById("composeToolbar2");
+ if (state) {
+ toolbar.setAttribute("collapsed", "true");
+ } else {
+ toolbar.removeAttribute("collapsed");
+ }
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+// This test uses openPopup() to open the popup in a compose window.
+add_task(
+ async function test_popup_open_with_openPopup_in_compose_formatoolbar() {
+ let files = {
+ "background.js": background_for_openPopup_tests,
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ compose_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ default_area: "formattoolbar",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ extension.onMessage("collapseToolbar", state => {
+ let window = Services.wm.getMostRecentWindow("msgcompose");
+ let toolbar = window.document.getElementById("FormatToolbar");
+ if (state) {
+ toolbar.setAttribute("collapsed", "true");
+ } else {
+ toolbar.removeAttribute("collapsed");
+ }
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js
new file mode 100644
index 0000000000..2a5cca1e12
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+});
+
+async function subtest_popup_open_with_click_MV3_event_pages(
+ terminateBackground
+) {
+ for (let area of [null, "formattoolbar"]) {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ composeWindow.close();
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ }
+}
+// This MV3 test clicks on the action button to open the popup.
+add_task(async function test_event_pages_without_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(false);
+});
+// This MV3 test clicks on the action button to open the popup (background termination).
+add_task(async function test_event_pages_with_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(true);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js
new file mode 100644
index 0000000000..517dae8c46
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let account = createAccount();
+ addIdentity(account);
+
+ let files = {
+ "background.js": async () => {
+ async function checkProperty(property, expectedDefault, ...expected) {
+ browser.test.log(
+ `${property}: ${expectedDefault}, ${expected.join(", ")}`
+ );
+
+ browser.test.assertEq(
+ expectedDefault,
+ await browser.composeAction[property]({})
+ );
+ for (let i = 0; i < 3; i++) {
+ browser.test.assertEq(
+ expected[i],
+ await browser.composeAction[property]({ tabId: tabIDs[i] })
+ );
+ }
+
+ await window.sendMessage("checkProperty", property, expected);
+ }
+
+ await browser.compose.beginNew();
+ await browser.compose.beginNew();
+ await browser.compose.beginNew();
+ let windows = await browser.windows.getAll({
+ populate: true,
+ windowTypes: ["messageCompose"],
+ });
+ let tabIDs = windows.map(w => w.tabs[0].id);
+
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.composeAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.composeAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.composeAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.composeAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.composeAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.composeAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await browser.composeAction.setTitle({ tabId: tabIDs[2], title: "tab2" });
+ await checkProperty("getTitle", "default", "default", "default", "tab2");
+ await browser.composeAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "new", "new", "tab2");
+ await browser.composeAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+ await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+ await browser.composeAction.setTitle({ tabId: tabIDs[2], title: null });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await browser.composeAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await browser.composeAction.setTitle({ tabId: tabIDs[1], title: null });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+
+ await browser.tabs.remove(tabIDs[0]);
+ await browser.tabs.remove(tabIDs[1]);
+ await browser.tabs.remove(tabIDs[2]);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action_properties@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ compose_action: {
+ default_title: "default",
+ },
+ },
+ });
+
+ extension.onMessage("checkProperty", async (property, expected) => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 3);
+
+ for (let i = 0; i < 3; i++) {
+ let button = composeWindows[i].document.getElementById(
+ "compose_action_properties_mochi_test-composeAction-toolbarbutton"
+ );
+ switch (property) {
+ case "isEnabled":
+ is(button.disabled, !expected[i], `button ${i} enabled state`);
+ break;
+ case "getTitle":
+ is(button.getAttribute("label"), expected[i], `button ${i} label`);
+ break;
+ }
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js b/comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js
new file mode 100644
index 0000000000..b642a5654d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js
@@ -0,0 +1,531 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+addIdentity(createAccount());
+
+async function checkComposeBody(expected, waitForEvent) {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ Assert.equal(composeWindows.length, 1);
+
+ let composeWindow = composeWindows[0];
+ if (waitForEvent) {
+ await BrowserTestUtils.waitForEvent(
+ composeWindow,
+ "extension-scripts-added"
+ );
+ }
+
+ let composeEditor = composeWindow.GetCurrentEditorElement();
+
+ await checkContent(composeEditor, expected);
+}
+
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
+add_task(async function testInsertRemoveCSS() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgb(0, 255, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgb(0, 128, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests browser.tabs.insertCSS fails without the "compose" permission. */
+add_task(async function testInsertRemoveCSSNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: darkred; }",
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ file: "test.css",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript. */
+add_task(async function testExecuteScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, { file: "test.js" });
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ textContent: "" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ foo: "bar" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript fails without the "compose" permission. */
+add_task(async function testExecuteScriptNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { file: "test.js" }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ file: "test.js",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ foo: null, textContent: "" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests the messenger alias is available. */
+add_task(async function testExecuteScriptAlias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.textContent = messenger.runtime.getManifest().applications.gecko.id;`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ applications: { gecko: { id: "compose_scripts@mochitest" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ textContent: "" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ textContent: "compose_scripts@mochitest" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Tests browser.composeScripts.register correctly adds CSS and JavaScript to
+ * message composition windows opened after it was called. Also tests calling
+ * `unregister` on the returned object.
+ */
+add_task(async function testRegisterBeforeCompose() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let registeredScript = await browser.composeScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ });
+
+ await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await registeredScript.unregister();
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ true
+ );
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkComposeBody({
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await extension.unload();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await BrowserTestUtils.closeWindow(
+ Services.wm.getMostRecentWindow("msgcompose")
+ );
+});
+
+/**
+ * Tests browser.composeScripts.register correctly adds CSS and JavaScript to
+ * message composition windows already open when it was called. Also tests
+ * calling `unregister` on the returned object.
+ */
+add_task(async function testRegisterDuringCompose() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ let registeredScript = await browser.composeScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ });
+
+ await window.sendMessage();
+
+ await registeredScript.unregister();
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests content_scripts in the manifest do not affect compose windows. */
+async function subtestContentScriptManifest(...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions,
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["test.css"],
+ js: ["test.js"],
+ match_about_blank: true,
+ match_origin_as_fallback: true,
+ },
+ ],
+ },
+ });
+
+ // match_origin_as_fallback is not implemented yet. Bug 1475831.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+}
+
+add_task(async function testContentScriptManifestNoPermission() {
+ await subtestContentScriptManifest();
+});
+add_task(async function testContentScriptManifest() {
+ await subtestContentScriptManifest("compose");
+});
+
+/** Tests registered content scripts do not affect compose windows. */
+async function subtestContentScriptRegister(...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ await browser.contentScripts.register({
+ matches: ["<all_urls>"],
+ css: [{ file: "test.css" }],
+ js: [{ file: "test.js" }],
+ matchAboutBlank: true,
+ });
+
+ let tab = await browser.compose.beginNew();
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+}
+
+add_task(async function testContentScriptRegisterNoPermission() {
+ await subtestContentScriptRegister("<all_urls>");
+});
+add_task(async function testContentScriptRegister() {
+ await subtestContentScriptRegister("<all_urls>", "compose");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js
new file mode 100644
index 0000000000..2b66b5a200
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js
@@ -0,0 +1,2268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+
+function findWindow(subject) {
+ let windows = Array.from(Services.wm.getEnumerator("msgcompose"));
+ return windows.find(win => {
+ let composeFields = win.GetComposeDetails();
+ return composeFields.subject == subject;
+ });
+}
+
+var MockCompleteGenericSendMessage = {
+ register() {
+ // For every compose window that opens, replace the function which does the
+ // actual sending with one that only records when it has been called.
+ MockCompleteGenericSendMessage._didTryToSendMessage = false;
+ ExtensionSupport.registerWindowListener("MockCompleteGenericSendMessage", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow(window) {
+ window.CompleteGenericSendMessage = function (msgType) {
+ let items = [...window.gAttachmentBucket.itemChildren];
+ for (let item of items) {
+ if (item.attachment.sendViaCloud && item.cloudFileAccount) {
+ item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id);
+ }
+ }
+ Services.obs.notifyObservers(
+ {
+ composeWindow: window,
+ },
+ "mail:composeSendProgressStop"
+ );
+ };
+ },
+ });
+ },
+
+ unregister() {
+ ExtensionSupport.unregisterWindowListener("MockCompleteGenericSendMessage");
+ },
+};
+
+add_task(async function test_file_attachments() {
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ browser.test.assertEq(size, data.size);
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(expected.length, attachments.length);
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt", {
+ type: "application/vnd.regify",
+ });
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+ let file3 = new File(["I'm pretending to be file two."], "file3.txt");
+ let composeTab = await browser.compose.beginNew({
+ subject: "Message #1",
+ });
+
+ await checkUI(composeTab);
+
+ // Add an attachment.
+
+ let attachment1 = await browser.compose.addAttachment(composeTab.id, {
+ file: file1,
+ });
+ browser.test.assertEq("file1.txt", attachment1.name);
+ browser.test.assertEq(16, attachment1.size);
+ await checkData(attachment1, file1.size);
+
+ let [, added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: attachment1.id, name: "file1.txt" }
+ );
+ await checkData(added1, file1.size);
+
+ await checkUI(composeTab, {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ });
+
+ // Add another attachment.
+
+ let attachment2 = await browser.compose.addAttachment(composeTab.id, {
+ file: file2,
+ name: "this is file2.txt",
+ });
+ browser.test.assertEq("this is file2.txt", attachment2.name);
+ browser.test.assertEq(41, attachment2.size);
+ await checkData(attachment2, file2.size);
+
+ let [, added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(added2, file2.size);
+
+ await checkUI(
+ composeTab,
+ {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ },
+ { id: attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Change an attachment.
+
+ let changed2 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ {
+ name: "file2 with a new name.txt",
+ }
+ );
+ browser.test.assertEq("file2 with a new name.txt", changed2.name);
+ browser.test.assertEq(41, changed2.size);
+ await checkData(changed2, file2.size);
+
+ await checkUI(
+ composeTab,
+ {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ },
+ {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file2.size,
+ }
+ );
+
+ let changed3 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ { file: file3 }
+ );
+ browser.test.assertEq("file2 with a new name.txt", changed3.name);
+ browser.test.assertEq(30, changed3.size);
+ await checkData(changed3, file3.size);
+
+ await checkUI(
+ composeTab,
+ {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ },
+ {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file3.size,
+ }
+ );
+
+ // Remove the first/local attachment.
+
+ await browser.compose.removeAttachment(composeTab.id, attachment1.id);
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab.id },
+ attachment1.id
+ );
+
+ await checkUI(composeTab, {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file3.size,
+ });
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return {
+ url: "https://cloud.provider.net/1",
+ templateInfo: {
+ download_limit: "2",
+ service_name: "Superior Mochitest Service",
+ },
+ };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "file2 with a new name.txt"
+ );
+ });
+
+ // File retrieved by WebExt API should still be the real file.
+ await checkData(attachment2, 30);
+
+ // UI should show both file size.
+ await checkUI(composeTab, {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file3.size,
+ htmlSize: 4536,
+ });
+
+ // Rename the second/cloud attachment.
+
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composeTab.id, attachment2.id, {
+ name: "cloud file2 with a new name.txt",
+ }),
+ "Rename error: Missing cloudFile.onFileRename listener for compose.attachments@mochi.test",
+ "Provider should reject for missing rename support"
+ );
+
+ function cloudFileRenameListener(account, id) {
+ browser.cloudFile.onFileRename.removeListener(cloudFileRenameListener);
+ browser.test.assertEq(1, id);
+ return { url: "https://cloud.provider.net/2" };
+ }
+ browser.cloudFile.onFileRename.addListener(cloudFileRenameListener);
+
+ let changed4 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ {
+ name: "cloud file2 with a new name.txt",
+ }
+ );
+ browser.test.assertEq("cloud file2 with a new name.txt", changed4.name);
+ browser.test.assertEq(30, changed4.size);
+
+ await checkUI(composeTab, {
+ id: attachment2.id,
+ name: "cloud file2 with a new name.txt",
+ size: file3.size,
+ htmlSize: 4554,
+ });
+
+ // File retrieved by WebExt API should still be the real file.
+ await checkData(changed4, 30);
+
+ // Update the second/cloud attachment.
+
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composeTab.id, attachment2.id, {
+ file: file2,
+ }),
+ "Upload error: Missing cloudFile.onFileUpload listener for compose.attachments@mochi.test (or it is not returning url or aborted)",
+ "Provider should reject due to upload errors"
+ );
+
+ function cloudFileUploadListener(
+ account,
+ fileInfo,
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(cloudFileUploadListener);
+ browser.test.assertEq(3, fileInfo.id);
+ browser.test.assertEq("cloud file2 with a new name.txt", fileInfo.name);
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq(
+ "cloud file2 with a new name.txt",
+ relatedFileInfo.name
+ );
+ browser.test.assertTrue(
+ relatedFileInfo.dataChanged,
+ `data should have changed`
+ );
+ browser.test.assertEq(
+ "2",
+ relatedFileInfo.templateInfo.download_limit,
+ "templateInfo download_limit should be correct"
+ );
+ browser.test.assertEq(
+ "Superior Mochitest Service",
+ relatedFileInfo.templateInfo.service_name,
+ "templateInfo service_name should be correct"
+ );
+ return { url: "https://cloud.provider.net/3" };
+ }
+ browser.cloudFile.onFileUpload.addListener(cloudFileUploadListener);
+
+ let changed5 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ { file: file2 }
+ );
+
+ browser.test.assertEq("cloud file2 with a new name.txt", changed5.name);
+ browser.test.assertEq(41, changed5.size);
+ await checkData(changed5, file2.size);
+
+ // Remove the second/cloud attachment.
+
+ await browser.compose.removeAttachment(composeTab.id, attachment2.id);
+
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab.id },
+ attachment2.id
+ );
+
+ await checkUI(composeTab);
+
+ await browser.tabs.remove(composeTab.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentType } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ if (contentType) {
+ Assert.equal(
+ item.attachment.contentType,
+ contentType,
+ "contentType should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Displayed name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize),
+ "Total size should match."
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_compose_attachments() {
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance
+ data instanceof File,
+ "Returned file obj should be a File instance."
+ );
+ browser.test.assertEq(
+ size,
+ data.size,
+ "Reported size should be correct."
+ );
+ browser.test.assertEq(
+ attachment.name,
+ data.name,
+ "Name of the File object should match the name of the attachment."
+ );
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(
+ expected.length,
+ attachments.length,
+ "Number of found attachments should be correct."
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ async function cloneAttachment(
+ attachment,
+ composeTab,
+ name = attachment.name
+ ) {
+ let clone;
+
+ // If the name is not changed, try to pass in the full object.
+ if (name == attachment.name) {
+ clone = await browser.compose.addAttachment(
+ composeTab.id,
+ attachment
+ );
+ } else {
+ clone = await browser.compose.addAttachment(composeTab.id, {
+ id: attachment.id,
+ name,
+ });
+ }
+
+ browser.test.assertEq(name, clone.name);
+ browser.test.assertEq(attachment.size, clone.size);
+ await checkData(clone, attachment.size);
+
+ let [, added] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: clone.id, name }
+ );
+ await checkData(added, attachment.size);
+ return clone;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt");
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+
+ // -----------------------------------------------------------------------
+
+ let composeTab1 = await browser.compose.beginNew({
+ subject: "Message #2",
+ });
+ await checkUI(composeTab1);
+
+ // Add an attachment to composeTab1.
+
+ let tab1_attachment1 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file1,
+ }
+ );
+ browser.test.assertEq("file1.txt", tab1_attachment1.name);
+ browser.test.assertEq(16, tab1_attachment1.size);
+ await checkData(tab1_attachment1, file1.size);
+
+ let [, tab1_added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment1.id, name: "file1.txt" }
+ );
+ await checkData(tab1_added1, file1.size);
+
+ await checkUI(composeTab1, {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ // Add another attachment to composeTab1.
+
+ let tab1_attachment2 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file2,
+ name: "this is file2.txt",
+ }
+ );
+ browser.test.assertEq("this is file2.txt", tab1_attachment2.name);
+ browser.test.assertEq(41, tab1_attachment2.size);
+ await checkData(tab1_attachment2, file2.size);
+
+ let [, tab1_added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(tab1_added2, file2.size);
+
+ await checkUI(
+ composeTab1,
+ { id: tab1_attachment1.id, name: "file1.txt", size: file1.size },
+ { id: tab1_attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/1" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "this is file2.txt"
+ );
+ });
+
+ await checkUI(
+ composeTab1,
+ {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab1_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // -----------------------------------------------------------------------
+
+ // Create a second compose window and clone both attachments from tab1. The
+ // second one should be cloned as a cloud attachment, having no size and the
+ // correct contentLocation. Both attachments will be renamed while cloning.
+
+ // The cloud file rename should be handled as a new file upload, because
+ // the same url is used in tab1. The original attachment should be passed
+ // as relatedFileInfo.
+ let tab2_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(2, fileInfo.id);
+ browser.test.assertEq("this is renamed file2.txt", fileInfo.name);
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/2" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let composeTab2 = await browser.compose.beginNew({
+ subject: "Message #3",
+ });
+ let tab2_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab2,
+ "I want to be called file3.txt"
+ );
+ await checkUI(composeTab2, {
+ id: tab2_attachment1.id,
+ name: "I want to be called file3.txt",
+ size: file1.size,
+ });
+
+ let tab2_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab2,
+ "this is renamed file2.txt"
+ );
+
+ await checkUI(
+ composeTab2,
+ {
+ id: tab2_attachment1.id,
+ name: "I want to be called file3.txt",
+ size: file1.size,
+ },
+ {
+ id: tab2_attachment2.id,
+ name: "this is renamed file2.txt",
+ size: 41,
+ htmlSize: 4324,
+ contentLocation: "https://cloud.provider.net/2",
+ }
+ );
+
+ await tab2_uploadPromise;
+
+ // -----------------------------------------------------------------------
+
+ // Create a 3rd compose window and clone both attachments from tab1. The
+ // second one should be cloned as cloud attachment, having no size and the
+ // correct contentLocation. Files are not renamed this time, so there should
+ // not be an upload request (which would fail without upload listener), as
+ // we simply re-attach the cloudFileUpload data.
+
+ let composeTab3 = await browser.compose.beginNew({
+ subject: "Message #4",
+ });
+ let tab3_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab3
+ );
+ await checkUI(composeTab3, {
+ id: tab3_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ let tab3_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab3
+ );
+
+ await checkUI(
+ composeTab3,
+ {
+ id: tab3_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab3_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // Rename the cloned cloud attachments of tab3. It should trigger a new
+ // upload, to not invalidate the original url still used in tab1.
+
+ let tab3_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(3, fileInfo.id);
+ browser.test.assertEq(
+ "That is going to be interesting.txt",
+ fileInfo.name
+ );
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/3" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let tab3_changed2 = await browser.compose.updateAttachment(
+ composeTab3.id,
+ tab3_attachment2.id,
+ {
+ name: "That is going to be interesting.txt",
+ }
+ );
+ browser.test.assertEq(
+ "That is going to be interesting.txt",
+ tab3_changed2.name
+ );
+ browser.test.assertEq(41, tab3_changed2.size);
+ await checkData(tab3_changed2, file2.size);
+
+ await checkUI(
+ composeTab3,
+ {
+ id: tab3_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab3_attachment2.id,
+ name: "That is going to be interesting.txt",
+ size: 41,
+ htmlSize: 4354,
+ contentLocation: "https://cloud.provider.net/3",
+ }
+ );
+
+ await tab3_uploadPromise;
+
+ // -----------------------------------------------------------------------
+
+ // Open a 4th compose window and directly clone attachment1 and attachment2,
+ // renaming both. This should trigger a new file upload.
+
+ let tab4_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(4, fileInfo.id);
+ browser.test.assertEq(
+ "I got renamed too, how crazy is that!.txt",
+ fileInfo.name
+ );
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/4" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let tab4_details = { subject: "Message #5" };
+ tab4_details.attachments = [
+ Object.assign({}, tab1_attachment1),
+ Object.assign({}, tab1_attachment2),
+ ];
+ tab4_details.attachments[0].name = "I got renamed.txt";
+ tab4_details.attachments[1].name =
+ "I got renamed too, how crazy is that!.txt";
+ let composeTab4 = await browser.compose.beginNew(tab4_details);
+
+ // In this test we need to manually request the id of the added attachments.
+ let [tab4_attachment1, tab4_attachment2] =
+ await browser.compose.listAttachments(composeTab4.id);
+
+ let [, addedReClone1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab4.id },
+ { id: tab4_attachment1.id, name: "I got renamed.txt" }
+ );
+ await checkData(addedReClone1, file1.size);
+ let [, addedReClone2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab4.id },
+ {
+ id: tab4_attachment2.id,
+ name: "I got renamed too, how crazy is that!.txt",
+ }
+ );
+ await checkData(addedReClone2, file2.size);
+
+ await checkUI(
+ composeTab4,
+ {
+ id: tab4_attachment1.id,
+ name: "I got renamed.txt",
+ size: file1.size,
+ },
+ {
+ id: tab4_attachment2.id,
+ name: "I got renamed too, how crazy is that!.txt",
+ size: 41,
+ htmlSize: 4372,
+ contentLocation: "https://cloud.provider.net/4",
+ }
+ );
+
+ await tab4_uploadPromise;
+
+ // -----------------------------------------------------------------------
+
+ // Open a 5th compose window and directly clone attachment1 and attachment2
+ // from tab1.
+
+ let tab5_details = { subject: "Message #6" };
+ tab5_details.attachments = [tab1_attachment1, tab1_attachment2];
+ let composeTab5 = await browser.compose.beginNew(tab5_details);
+
+ // In this test we need to manually request the id of the added attachments.
+ let [tab5_attachment1, tab5_attachment2] =
+ await browser.compose.listAttachments(composeTab5.id);
+
+ await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab5.id },
+ { id: tab5_attachment1.id, name: "file1.txt" }
+ );
+ await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab5.id },
+ { id: tab5_attachment2.id, name: "this is file2.txt" }
+ );
+
+ // Delete the cloud attachment2 in tab1, which should not trigger a cloud
+ // delete, as the url is still used in tab5.
+
+ function fileListener(account, id, tab) {
+ browser.test.fail(
+ `The onFileDeleted listener should not fire for deleting a cloud file which is still used in another tab.`
+ );
+ }
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+
+ await browser.compose.removeAttachment(
+ composeTab1.id,
+ tab1_attachment2.id
+ );
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab1.id },
+ tab1_attachment2.id
+ );
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+
+ // Renaming cloud attachment2 in tab5 should now be a simple rename, as the
+ // url is not used anywhere anymore.
+
+ let tab5_renamePromise = new Promise(resolve => {
+ function fileListener() {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ setTimeout(() => resolve());
+ }
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ });
+
+ await browser.compose.updateAttachment(
+ composeTab5.id,
+ tab5_attachment2.id,
+ {
+ name: "I am the only one left.txt",
+ }
+ );
+ await tab5_renamePromise;
+
+ // Delete the cloud attachment2 in tab5, which now should trigger a cloud
+ // delete.
+
+ let tab5_deletePromise = new Promise(resolve => {
+ function fileListener(account, id, tab) {
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+ setTimeout(() => resolve(id));
+ }
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+ });
+
+ await browser.compose.removeAttachment(
+ composeTab5.id,
+ tab5_attachment2.id
+ );
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab5.id },
+ tab5_attachment2.id
+ );
+ await tab5_deletePromise;
+
+ // Clean up
+
+ await browser.tabs.remove(composeTab5.id);
+ await browser.tabs.remove(composeTab4.id);
+ await browser.tabs.remove(composeTab3.id);
+ await browser.tabs.remove(composeTab2.id);
+ await browser.tabs.remove(composeTab1.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentLocation } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ // size and name are checked against the displayed values, contentLocation
+ // is checked against the associated attachment.
+
+ if (contentLocation) {
+ Assert.equal(
+ item.attachment.contentLocation,
+ contentLocation,
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "sendViaCloud for cloud files should be correct."
+ );
+ } else {
+ Assert.equal(
+ item.attachment.contentLocation,
+ "",
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ false,
+ "sendViaCloud for cloud files should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize)
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_compose_attachments_immutable() {
+ MockCompleteGenericSendMessage.register();
+
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance
+ data instanceof File,
+ "Returned file obj should be a File instance."
+ );
+ browser.test.assertEq(
+ size,
+ data.size,
+ "Reported size should be correct."
+ );
+ browser.test.assertEq(
+ attachment.name,
+ data.name,
+ "Name of the File object should match the name of the attachment."
+ );
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(
+ expected.length,
+ attachments.length,
+ "Number of found attachments should be correct."
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ async function cloneAttachment(
+ attachment,
+ composeTab,
+ name = attachment.name
+ ) {
+ let clone;
+
+ // If the name is not changed, try to pass in the full object.
+ if (name == attachment.name) {
+ clone = await browser.compose.addAttachment(
+ composeTab.id,
+ attachment
+ );
+ } else {
+ clone = await browser.compose.addAttachment(composeTab.id, {
+ id: attachment.id,
+ name,
+ });
+ }
+
+ browser.test.assertEq(name, clone.name);
+ browser.test.assertEq(attachment.size, clone.size);
+ await checkData(clone, attachment.size);
+
+ let [, added] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: clone.id, name }
+ );
+ await checkData(added, attachment.size);
+ return clone;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt");
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+
+ // -----------------------------------------------------------------------
+
+ let composeTab1 = await browser.compose.beginNew({
+ to: "user@inter.net",
+ subject: "Test",
+ });
+ await checkUI(composeTab1);
+
+ // Add an attachment to composeTab1.
+
+ let tab1_attachment1 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file1,
+ }
+ );
+ browser.test.assertEq("file1.txt", tab1_attachment1.name);
+ browser.test.assertEq(16, tab1_attachment1.size);
+ await checkData(tab1_attachment1, file1.size);
+
+ let [, tab1_added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment1.id, name: "file1.txt" }
+ );
+ await checkData(tab1_added1, file1.size);
+
+ await checkUI(composeTab1, {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ // Add another attachment to composeTab1.
+
+ let tab1_attachment2 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file2,
+ name: "this is file2.txt",
+ }
+ );
+ browser.test.assertEq("this is file2.txt", tab1_attachment2.name);
+ browser.test.assertEq(41, tab1_attachment2.size);
+ await checkData(tab1_attachment2, file2.size);
+
+ let [, tab1_added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(tab1_added2, file2.size);
+
+ await checkUI(
+ composeTab1,
+ { id: tab1_attachment1.id, name: "file1.txt", size: file1.size },
+ { id: tab1_attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/1" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "this is file2.txt"
+ );
+ });
+
+ await checkUI(
+ composeTab1,
+ {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab1_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // -----------------------------------------------------------------------
+
+ // Create a second compose window and clone both attachments from tab1. The
+ // second one should be cloned as a cloud attachment, having no size and the
+ // correct contentLocation.
+
+ let composeTab2 = await browser.compose.beginNew({
+ subject: "Message #7",
+ });
+ let tab2_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab2
+ );
+ await checkUI(composeTab2, {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ let tab2_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab2,
+ "this is file2.txt"
+ );
+
+ await checkUI(
+ composeTab2,
+ {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab2_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // Send the message and have its attachment marked as immutable.
+ await browser.compose.sendMessage(composeTab1.id, { mode: "sendNow" });
+ await browser.tabs.remove(composeTab1.id);
+
+ // Delete the cloud attachment2 in tab2, which should not trigger a cloud
+ // delete, as the url has been marked as immutable by sending the message
+ // in tab1.
+
+ function fileListener(account, id, tab) {
+ browser.test.fail(
+ `The onFileDeleted listener should not fire for deleting a cloud file marked as immutable.`
+ );
+ }
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+
+ await browser.compose.removeAttachment(
+ composeTab2.id,
+ tab2_attachment2.id
+ );
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab2.id },
+ tab2_attachment2.id
+ );
+
+ // Clean up
+
+ await browser.tabs.remove(composeTab2.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentLocation } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ // size and name are checked against the displayed values, contentLocation
+ // is checked against the associated attachment.
+
+ if (contentLocation) {
+ Assert.equal(
+ item.attachment.contentLocation,
+ contentLocation,
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "sendViaCloud for cloud files should be correct."
+ );
+ } else {
+ Assert.equal(
+ item.attachment.contentLocation,
+ "",
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ false,
+ "sendViaCloud for cloud files should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize)
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ MockCompleteGenericSendMessage.unregister();
+});
+
+add_task(async function test_compose_attachments_no_reuse() {
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance
+ data instanceof File,
+ "Returned file obj should be a File instance."
+ );
+ browser.test.assertEq(
+ size,
+ data.size,
+ "Reported size should be correct."
+ );
+ browser.test.assertEq(
+ attachment.name,
+ data.name,
+ "Name of the File object should match the name of the attachment."
+ );
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(
+ expected.length,
+ attachments.length,
+ "Number of found attachments should be correct."
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ async function cloneAttachment(
+ attachment,
+ composeTab,
+ name = attachment.name
+ ) {
+ let clone;
+
+ // If the name is not changed, try to pass in the full object.
+ if (name == attachment.name) {
+ clone = await browser.compose.addAttachment(
+ composeTab.id,
+ attachment
+ );
+ } else {
+ clone = await browser.compose.addAttachment(composeTab.id, {
+ id: attachment.id,
+ name,
+ });
+ }
+
+ browser.test.assertEq(name, clone.name);
+ browser.test.assertEq(attachment.size, clone.size);
+ await checkData(clone, attachment.size);
+
+ let [, added] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: clone.id, name }
+ );
+ await checkData(added, attachment.size);
+ return clone;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt");
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+
+ // -----------------------------------------------------------------------
+
+ let composeTab1 = await browser.compose.beginNew({
+ subject: "Message #8",
+ });
+ await checkUI(composeTab1);
+
+ // Add an attachment to composeTab1.
+
+ let tab1_attachment1 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file1,
+ }
+ );
+ browser.test.assertEq("file1.txt", tab1_attachment1.name);
+ browser.test.assertEq(16, tab1_attachment1.size);
+ await checkData(tab1_attachment1, file1.size);
+
+ let [, tab1_added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment1.id, name: "file1.txt" }
+ );
+ await checkData(tab1_added1, file1.size);
+
+ await checkUI(composeTab1, {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ // Add another attachment to composeTab1.
+
+ let tab1_attachment2 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file2,
+ name: "this is file2.txt",
+ }
+ );
+ browser.test.assertEq("this is file2.txt", tab1_attachment2.name);
+ browser.test.assertEq(41, tab1_attachment2.size);
+ await checkData(tab1_attachment2, file2.size);
+
+ let [, tab1_added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(tab1_added2, file2.size);
+
+ await checkUI(
+ composeTab1,
+ { id: tab1_attachment1.id, name: "file1.txt", size: file1.size },
+ { id: tab1_attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/1" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "this is file2.txt"
+ );
+ });
+
+ await checkUI(
+ composeTab1,
+ {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab1_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // -----------------------------------------------------------------------
+
+ // Create a second compose window and clone both attachments from tab1. The
+ // second one should be cloned as a cloud attachment, having no size and the
+ // correct contentLocation.
+ // Attachments are not renamed, but since reuse_uploads is disabled, a new
+ // upload request must be issued. The original attachment should be passed
+ // as relatedFileInfo.
+ let tab2_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(2, fileInfo.id);
+ browser.test.assertEq("this is file2.txt", fileInfo.name);
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/2" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let composeTab2 = await browser.compose.beginNew({
+ subject: "Message #9",
+ });
+ let tab2_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab2
+ );
+ await checkUI(composeTab2, {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ let tab2_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab2,
+ "this is file2.txt"
+ );
+
+ await checkUI(
+ composeTab2,
+ {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab2_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/2",
+ }
+ );
+
+ await tab2_uploadPromise;
+
+ await browser.tabs.remove(composeTab2.id);
+ await browser.tabs.remove(composeTab1.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ reuse_uploads: false,
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentLocation } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ // size and name are checked against the displayed values, contentLocation
+ // is checked against the associated attachment.
+
+ if (contentLocation) {
+ Assert.equal(
+ item.attachment.contentLocation,
+ contentLocation,
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "sendViaCloud for cloud files should be correct."
+ );
+ } else {
+ Assert.equal(
+ item.attachment.contentLocation,
+ "",
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ false,
+ "sendViaCloud for cloud files should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize)
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_without_permission() {
+ let files = {
+ "background.js": async () => {
+ // Try to use onAttachmentAdded.
+ await browser.test.assertThrows(
+ () => browser.compose.onAttachmentAdded.addListener(),
+ /browser\.compose\.onAttachmentAdded is undefined/,
+ "Should reject listener without proper permission"
+ );
+
+ // Try to use onAttachmentRemoved.
+ await browser.test.assertThrows(
+ () => browser.compose.onAttachmentRemoved.addListener(),
+ /browser\.compose\.onAttachmentRemoved is undefined/,
+ "Should reject listener without proper permission"
+ );
+
+ // Try to use listAttachments.
+ await browser.test.assertThrows(
+ () => browser.compose.listAttachments(),
+ `browser.compose.listAttachments is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ // Try to use addAttachment.
+ await browser.test.assertThrows(
+ () => browser.compose.addAttachment(),
+ `browser.compose.addAttachment is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ // Try to use updateAttachment.
+ await browser.test.assertThrows(
+ () => browser.compose.updateAttachment(),
+ `browser.compose.updateAttachment is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ // Try to use removeAttachment.
+ await browser.test.assertThrows(
+ () => browser.compose.removeAttachment(),
+ `browser.compose.removeAttachment is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_attachment_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.compose.onAttachmentAdded.addListener(async (tab, attachment) => {
+ browser.test.sendMessage("attachment added", {
+ eventCount: ++eventCounter,
+ attachment,
+ });
+ });
+
+ browser.compose.onAttachmentRemoved.addListener(
+ async (tab, attachmentId) => {
+ browser.test.sendMessage("attachment removed", {
+ eventCount: ++eventCounter,
+ attachmentId,
+ });
+ }
+ );
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "compose.attachment@mochi.test" },
+ },
+ },
+ });
+
+ async function addAttachment(ordinal) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.name = `${ordinal}.txt`;
+ attachment.url = `data:text/plain,I'm the ${ordinal} attachment!`;
+ attachment.size = attachment.url.length - 16;
+
+ await composeWindow.AddAttachments([attachment]);
+ return attachment;
+ }
+
+ async function removeAttachment(attachment) {
+ let item =
+ composeWindow.gAttachmentBucket.findItemForAttachment(attachment);
+ await composeWindow.RemoveAttachments([item]);
+ }
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "compose.onAttachmentAdded",
+ "compose.onAttachmentRemoved",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger events without terminating the background first.
+
+ let rawFirstAttachment = await addAttachment("first");
+ let addedFirst = await extension.awaitMessage("attachment added");
+ Assert.equal(
+ "first.txt",
+ rawFirstAttachment.name,
+ "Created attachment should be correct"
+ );
+ Assert.equal(
+ "first.txt",
+ addedFirst.attachment.name,
+ "Attachment returned by onAttachmentAdded should be correct"
+ );
+ Assert.equal(1, addedFirst.eventCount, "Event counter should be correct");
+
+ await removeAttachment(rawFirstAttachment);
+
+ let removedFirst = await extension.awaitMessage("attachment removed");
+ Assert.equal(
+ addedFirst.attachment.id,
+ removedFirst.attachmentId,
+ "Attachment id returned by onAttachmentRemoved should be correct"
+ );
+ Assert.equal(2, removedFirst.eventCount, "Event counter should be correct");
+
+ // Terminate background and re-trigger onAttachmentAdded event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ let rawSecondAttachment = await addAttachment("second");
+ let addedSecond = await extension.awaitMessage("attachment added");
+ Assert.equal(
+ "second.txt",
+ rawSecondAttachment.name,
+ "Created attachment should be correct"
+ );
+ Assert.equal(
+ "second.txt",
+ addedSecond.attachment.name,
+ "Attachment returned by onAttachmentAdded should be correct"
+ );
+ Assert.equal(1, addedSecond.eventCount, "Event counter should be correct");
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ // Terminate background and re-trigger onAttachmentRemoved event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ await removeAttachment(rawSecondAttachment);
+ let removedSecond = await extension.awaitMessage("attachment removed");
+ Assert.equal(
+ addedSecond.attachment.id,
+ removedSecond.attachmentId,
+ "Attachment id returned by onAttachmentRemoved should be correct"
+ );
+ Assert.equal(1, removedSecond.eventCount, "Event counter should be correct");
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js
new file mode 100644
index 0000000000..26f4d0ab5e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+addIdentity(account);
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testAttachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+
+ let newTab = await browser.compose.beginNew({
+ attachments: [
+ { file: new File(["one"], "attachment1.txt") },
+ { file: new File(["two"], "attachment-två.txt") },
+ ],
+ });
+
+ let attachments = await browser.compose.listAttachments(newTab.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment1.txt", attachments[0].name);
+ browser.test.assertEq("attachment-två.txt", attachments[1].name);
+
+ let replyTab = await browser.compose.beginReply(messages[0].id, {
+ attachments: [
+ { file: new File(["three"], "attachment3.txt") },
+ { file: new File(["four"], "attachment4.txt") },
+ ],
+ });
+
+ attachments = await browser.compose.listAttachments(replyTab.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment3.txt", attachments[0].name);
+ browser.test.assertEq("attachment4.txt", attachments[1].name);
+
+ let forwardTab = await browser.compose.beginForward(
+ messages[1].id,
+ "forwardAsAttachment",
+ {
+ attachments: [
+ { file: new File(["five"], "attachment5.txt") },
+ { file: new File(["six"], "attachment6.txt") },
+ ],
+ }
+ );
+
+ attachments = await browser.compose.listAttachments(forwardTab.id);
+ browser.test.assertEq(3, attachments.length);
+ browser.test.assertEq(`${messages[1].subject}.eml`, attachments[0].name);
+ browser.test.assertEq("attachment5.txt", attachments[1].name);
+ browser.test.assertEq("attachment6.txt", attachments[2].name);
+
+ // Forward inline adds attachments differently, so check it works too.
+
+ let forwardTab2 = await browser.compose.beginForward(
+ messages[2].id,
+ "forwardInline",
+ {
+ attachments: [
+ { file: new File(["seven"], "attachment7.txt") },
+ { file: new File(["eight"], "attachment-åtta.txt") },
+ ],
+ }
+ );
+
+ attachments = await browser.compose.listAttachments(forwardTab2.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment7.txt", attachments[0].name);
+ browser.test.assertEq("attachment-åtta.txt", attachments[1].name);
+
+ let newTab2 = await browser.compose.beginNew(messages[3].id, {
+ attachments: [
+ { file: new File(["nine"], "attachment9.txt") },
+ { file: new File(["ten"], "attachment10.txt") },
+ ],
+ });
+
+ attachments = await browser.compose.listAttachments(newTab2.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment9.txt", attachments[0].name);
+ browser.test.assertEq("attachment10.txt", attachments[1].name);
+
+ await browser.tabs.remove(newTab.id);
+ await browser.tabs.remove(replyTab.id);
+ await browser.tabs.remove(forwardTab.id);
+ await browser.tabs.remove(forwardTab2.id);
+ await browser.tabs.remove(newTab2.id);
+
+ browser.test.notifyPass();
+ },
+ manifest: {
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js
new file mode 100644
index 0000000000..3b454a9c8b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js
@@ -0,0 +1,397 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(2);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+let defaultIdentity = addIdentity(account);
+defaultIdentity.composeHtml = true;
+let nonDefaultIdentity = addIdentity(account);
+nonDefaultIdentity.composeHtml = false;
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testBody() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ browser.test.assertEq(
+ 2,
+ popAccount.identities.length,
+ "number of identities"
+ );
+ let [htmlIdentity, plainTextIdentity] = popAccount.identities;
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let message0 = await browser.messages.getFull(messages[0].id);
+ let message0body = message0.parts[0].body;
+
+ // Editor content of a newly opened composeWindow without setting a body.
+ let defaultHTML = "<body><p><br></p></body>";
+ // Editor content after composeWindow.SetComposeDetails() has been used
+ // to clear the body.
+ let setEmptyHTML = "<body><br></body>";
+ let plainTextBodyTag =
+ '<body style="font-family: -moz-fixed; white-space: pre-wrap; width: 72ch;">';
+ let tests = [
+ {
+ // No arguments.
+ funcName: "beginNew",
+ arguments: [],
+ expected: {
+ isHTML: true,
+ htmlIncludes: defaultHTML,
+ plainTextIs: "\n",
+ },
+ },
+ {
+ // Empty arguments.
+ funcName: "beginNew",
+ arguments: [{}],
+ expected: {
+ isHTML: true,
+ htmlIncludes: defaultHTML,
+ plainTextIs: "\n",
+ },
+ },
+ {
+ // Empty HTML.
+ funcName: "beginNew",
+ arguments: [{ body: "" }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: setEmptyHTML,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty plain text.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "" }],
+ expected: {
+ isHTML: false,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty enforced plain text with default identity.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "", isPlainText: true }],
+ expected: {
+ isHTML: false,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty HTML for plaintext identity.
+ funcName: "beginNew",
+ arguments: [{ body: "", identityId: plainTextIdentity.id }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: setEmptyHTML,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty plain text for plaintext identity.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "", identityId: plainTextIdentity.id }],
+ expected: {
+ isHTML: false,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty HTML for plaintext identity enforcing HTML.
+ funcName: "beginNew",
+ arguments: [
+ { body: "", identityId: plainTextIdentity.id, isPlainText: false },
+ ],
+ expected: {
+ isHTML: true,
+ htmlIncludes: setEmptyHTML,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty plain text and isPlainText.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "", isPlainText: true }],
+ expected: { isHTML: false, plainTextIs: "" },
+ },
+ {
+ // Non-empty HTML.
+ funcName: "beginNew",
+ arguments: [{ body: "<p>I'm an HTML message!</p>" }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "<body><p>I'm an HTML message!</p></body>",
+ plainTextIs: "I'm an HTML message!",
+ },
+ },
+ {
+ // Non-empty plain text.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "I'm a plain text message!" }],
+ expected: {
+ isHTML: false,
+ htmlIncludes: plainTextBodyTag + "I'm a plain text message!</body>",
+ plainTextIs: "I'm a plain text message!",
+ },
+ },
+ {
+ // Non-empty plain text and isPlainText.
+ funcName: "beginNew",
+ arguments: [
+ {
+ plainTextBody: "I'm a plain text message!",
+ isPlainText: true,
+ },
+ ],
+ expected: {
+ isHTML: false,
+ htmlIncludes: plainTextBodyTag + "I'm a plain text message!</body>",
+ plainTextIs: "I'm a plain text message!",
+ },
+ },
+ {
+ // HTML body and plain text body without isPlainText. Use default format.
+ funcName: "beginNew",
+ arguments: [{ body: "I am HTML", plainTextBody: "I am TEXT" }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "I am HTML",
+ plainTextIs: "I am HTML",
+ },
+ },
+ {
+ // HTML body and plain text body with isPlainText. Use the specified
+ // format.
+ funcName: "beginNew",
+ arguments: [
+ {
+ body: "I am HTML",
+ plainTextBody: "I am TEXT",
+ isPlainText: true,
+ },
+ ],
+ expected: {
+ isHTML: false,
+ plainTextIs: "I am TEXT",
+ },
+ },
+ {
+ // Providing an HTML body only and isPlainText = true. Conflicting and
+ // thus invalid.
+ funcName: "beginNew",
+ arguments: [{ body: "I am HTML", isPlainText: true }],
+ throws: true,
+ },
+ {
+ // Providing a plain text body only and isPlainText = false. Conflicting
+ // and thus invalid.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "I am TEXT", isPlainText: false }],
+ throws: true,
+ },
+ {
+ // HTML body only and isPlainText false.
+ funcName: "beginNew",
+ arguments: [{ body: "I am HTML", isPlainText: false }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "I am HTML",
+ plainTextIs: "I am HTML",
+ },
+ },
+ {
+ // Edit as new.
+ funcName: "beginNew",
+ arguments: [messages[0].id],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Edit as new with plaintext identity
+ funcName: "beginNew",
+ arguments: [messages[0].id, { identityId: plainTextIdentity.id }],
+ expected: {
+ isHTML: false,
+ plainTextIs: message0body,
+ },
+ },
+ {
+ // Edit as new with default identity enforcing HTML
+ funcName: "beginNew",
+ arguments: [messages[0].id, { isPlainText: false }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Edit as new with plaintext identity enforcing HTML by setting a body.
+ funcName: "beginNew",
+ arguments: [
+ messages[0].id,
+ {
+ body: "<p>This is some HTML text</p>",
+ identityId: plainTextIdentity.id,
+ },
+ ],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "<p>This is some HTML text</p>",
+ },
+ },
+ {
+ // Edit as new with html identity enforcing plain text by setting a plainTextBody.
+ funcName: "beginNew",
+ arguments: [
+ messages[0].id,
+ {
+ plainTextBody: "This is some plain text",
+ identityId: htmlIdentity.id,
+ },
+ ],
+ expected: {
+ isHTML: false,
+ plainText: "This is some plain text",
+ },
+ },
+ {
+ // ForwardInline with plaintext identity enforcing HTML
+ funcName: "beginForward",
+ arguments: [
+ messages[0].id,
+ { identityId: plainTextIdentity.id, isPlainText: false },
+ ],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Reply.
+ funcName: "beginReply",
+ arguments: [messages[0].id],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Forward inline.
+ funcName: "beginForward",
+ arguments: [messages[0].id],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Forward as attachment.
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment"],
+ expected: {
+ isHTML: true,
+ htmlIncludes: defaultHTML,
+ plainText: "",
+ },
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ try {
+ await browser.compose[test.funcName](...test.arguments);
+ if (test.throws) {
+ browser.test.fail(
+ "calling beginNew with these arguments should throw"
+ );
+ }
+ } catch (ex) {
+ if (test.throws) {
+ browser.test.succeed("expected exception thrown");
+ } else {
+ browser.test.fail(`unexpected exception thrown: ${ex.message}`);
+ }
+ }
+
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+ if (test.expected) {
+ browser.test.sendMessage("checkBody", test.expected);
+ await window.waitForMessage();
+ }
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+ extension.onMessage("checkBody", async expected => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ is(composeWindows[0].IsHTMLEditor(), expected.isHTML, "composition mode");
+
+ let editor = composeWindows[0].GetCurrentEditor();
+ // Get the actual message body. Fold Windows line-endings \r\n to \n.
+ let actualHTML = editor
+ .outputToString("text/html", Ci.nsIDocumentEncoder.OutputRaw)
+ .replace(/\r/g, "");
+ let actualPlainText = editor
+ .outputToString("text/plain", Ci.nsIDocumentEncoder.OutputRaw)
+ .replace(/\r/g, "");
+ if ("htmlIncludes" in expected) {
+ info(actualHTML);
+ ok(
+ actualHTML.includes(expected.htmlIncludes.replace(/\r/g, "")),
+ `HTML content is correct (${actualHTML} vs ${expected.htmlIncludes})`
+ );
+ }
+ if ("plainTextIs" in expected) {
+ is(
+ actualPlainText,
+ expected.plainTextIs.replace(/\r/g, ""),
+ "plainText content is correct"
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js
new file mode 100644
index 0000000000..6a763d5c43
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js
@@ -0,0 +1,141 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(2);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+let defaultIdentity = addIdentity(account);
+defaultIdentity.composeHtml = true;
+let nonDefaultIdentity = addIdentity(account);
+nonDefaultIdentity.composeHtml = false;
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+/* Test if line breaks in HTML are ignored (see bug 1691254). */
+add_task(async function testBR() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let body = `<html><head>\r\n\r\n \r\n<meta http-equiv="content-type" content="text/html; charset=UTF-8">\r\n\r\n </head><body>\r\n \r\n<p><font face="monospace">This is some <br> HTML text</font><br>\r\n </p>\r\n\r\n \r\n\r\n\r\n</body></html>\r\n\r\n\r\n`;
+ let tests = [
+ {
+ description: "Begin new.",
+ funcName: "beginNew",
+ arguments: [{ body }],
+ },
+ {
+ description: "Edit as new.",
+ funcName: "beginNew",
+ arguments: [messages[0].id, { body }],
+ },
+ {
+ description: "Reply default.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, { body }],
+ },
+ {
+ description: "Reply as replyToSender.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToSender", { body }],
+ },
+ {
+ description: "Reply as replyToList.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToList", { body }],
+ },
+ {
+ description: "Reply as replyToAll.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToAll", { body }],
+ },
+ {
+ description: "Forward default.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, { body }],
+ },
+ {
+ description: "Forward inline.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardInline", { body }],
+ },
+ {
+ description: "Forward as attachment.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment", { body }],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose[test.funcName](...test.arguments);
+
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+ browser.test.sendMessage("checkBody", test);
+ await window.waitForMessage();
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+ extension.onMessage("checkBody", async test => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ is(composeWindows[0].IsHTMLEditor(), true, "composition mode");
+
+ let editor = composeWindows[0].GetCurrentEditor();
+ let actualHTML = editor.outputToString(
+ "text/html",
+ Ci.nsIDocumentEncoder.OutputRaw
+ );
+ let brCounts = (actualHTML.match(/<br>/g) || []).length;
+ is(
+ brCounts,
+ 2,
+ `[${test.description}] Number of br tags in html is correct (${actualHTML}).`
+ );
+
+ let eqivCounts = (actualHTML.match(/http-equiv/g) || []).length;
+ is(
+ eqivCounts,
+ 1,
+ `[${test.description}] Number of http-equiv meta tags in html is correct (${actualHTML}).`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js
new file mode 100644
index 0000000000..621947609d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js
@@ -0,0 +1,339 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(() => {
+ let account = createAccount("pop3");
+ createAccount("local");
+ MailServices.accounts.defaultAccount = account;
+
+ addIdentity(account);
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 4);
+});
+
+/* Test if getComposeDetails() is waiting until the entire init procedure of
+ * the composeWindow has finished, before returning values. */
+add_task(async function testComposerIsReady() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Forward default.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, details],
+ },
+ {
+ description: "Forward inline.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardInline", details],
+ },
+ {
+ description: "Forward as attachment.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment", details],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let expectedDetails = test.arguments[test.arguments.length - 1];
+
+ // Test with windows.onCreated
+ {
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdWindow] = await createdWindowPromise;
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let actualDetails = await browser.compose.getComposeDetails(tab.id);
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After windows.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ // Test the windows API being able to return the messageCompose window as
+ // the current one.
+ await window.waitForCondition(async () => {
+ let win = await browser.windows.get(createdWindow.id);
+ return win.focused;
+ }, `Window should have received focus.`);
+
+ let composeWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(composeWindow.type, "messageCompose");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ // Test with tabs.onCreated
+ {
+ let createdTabPromise = window.waitForEvent("tabs.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdTab] = await createdTabPromise;
+ let actualDetails = await browser.compose.getComposeDetails(
+ createdTab.id
+ );
+
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After tabs.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let createdWindow = await browser.windows.get(createdTab.windowId);
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/* Test the compose API accessing the forwarded message added by beginForward. */
+add_task(async function testBeginForward() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Forward as attachment.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment", details],
+ expectedAttachments: [
+ {
+ name: "Big Meeting Today.eml",
+ type: "message/rfc822",
+ size: 281,
+ content: "Hello Bob Bell!",
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+
+ let tab = await browser.compose[test.funcName](...test.arguments);
+ let attachments = await browser.compose.listAttachments(tab.id);
+ browser.test.assertEq(
+ test.expectedAttachments.length,
+ attachments.length,
+ `Should have the expected number of attachments`
+ );
+ for (let i = 0; i < attachments.length; i++) {
+ let file = await browser.compose.getAttachmentFile(attachments[i].id);
+ for (let [property, value] of Object.entries(
+ test.expectedAttachments[i]
+ )) {
+ if (property == "content") {
+ let content = await file.text();
+ browser.test.assertTrue(
+ content.includes(value),
+ `Attachment body should include ${value}`
+ );
+ } else {
+ browser.test.assertEq(
+ value,
+ file[property],
+ `Attachment should have the correct value for ${property}`
+ );
+ }
+ }
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(tab.windowId);
+ await removedWindowPromise;
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/* The forward inline code path uses a hacky way to identify the correct window
+ * after it has been opened via MailServices.compose.OpenComposeWindow. Test it.*/
+add_task(async function testBeginForwardInlineMixUp() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ // Test opening different messages.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[1].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[2].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[3].id, "forwardInline")
+ );
+
+ let foundIds = new Set();
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < 4; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened compose window should have been fulfilled for message ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let details = await browser.compose.getComposeDetails(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages[i].id,
+ details.relatedMessageId,
+ `Should see the correct message in compose window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ // Test opening identical messages.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+
+ let foundIds = new Set();
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < 4; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened compose window should have been fulfilled for message ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let details = await browser.compose.getComposeDetails(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages[0].id,
+ details.relatedMessageId,
+ `Should see the correct message in compose window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js
new file mode 100644
index 0000000000..7d360b7920
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+addIdentity(account);
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testHeaders() {
+ let files = {
+ "background.js": async () => {
+ async function checkHeaders(expected) {
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+ browser.test.sendMessage("checkHeaders", expected);
+ await window.waitForMessage();
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let addressBook = await browser.addressBooks.create({
+ name: "Baker Street",
+ });
+ let contacts = {
+ sherlock: await browser.contacts.create(addressBook, {
+ DisplayName: "Sherlock Holmes",
+ PrimaryEmail: "sherlock@bakerstreet.invalid",
+ }),
+ john: await browser.contacts.create(addressBook, {
+ DisplayName: "John Watson",
+ PrimaryEmail: "john@bakerstreet.invalid",
+ }),
+ };
+ let list = await browser.mailingLists.create(addressBook, {
+ name: "Holmes and Watson",
+ description: "Tenants221B",
+ });
+ await browser.mailingLists.addMember(list, contacts.sherlock);
+ await browser.mailingLists.addMember(list, contacts.john);
+
+ let createdWindowPromise;
+
+ // Start a new message.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ await checkHeaders({});
+
+ // Start a new message, with a subject and recipients as strings.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: "Sherlock Holmes <sherlock@bakerstreet.invalid>",
+ cc: "John Watson <john@bakerstreet.invalid>",
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+ cc: ["John Watson <john@bakerstreet.invalid>"],
+ subject: "Did you miss me?",
+ });
+
+ // Start a new message, with a subject and recipients as string arrays.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+ cc: ["John Watson <john@bakerstreet.invalid>"],
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+ cc: ["John Watson <john@bakerstreet.invalid>"],
+ subject: "Did you miss me?",
+ });
+
+ // Start a new message, with a subject and recipients as contacts.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: contacts.sherlock, type: "contact" }],
+ cc: [{ id: contacts.john, type: "contact" }],
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+ cc: ["John Watson <john@bakerstreet.invalid>"],
+ subject: "Did you miss me?",
+ });
+
+ // Start a new message, with a subject and recipients as a mailing list.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: list, type: "mailingList" }],
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ to: ["Holmes and Watson <Tenants221B>"],
+ subject: "Did you miss me?",
+ });
+
+ // Reply to a message.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginReply(messages[0].id);
+ await checkHeaders({
+ to: [messages[0].author.replace(/"/g, "")],
+ subject: `Re: ${messages[0].subject}`,
+ });
+
+ // Forward a message.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginForward(
+ messages[1].id,
+ "forwardAsAttachment",
+ {
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ }
+ );
+ await checkHeaders({
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ subject: `Fwd: ${messages[1].subject}`,
+ });
+
+ // Forward a message inline. This uses a different code path.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginForward(messages[2].id, "forwardInline", {
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ });
+ await checkHeaders({
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ subject: `Fwd: ${messages[2].subject}`,
+ });
+
+ await browser.addressBooks.delete(addressBook);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkHeaders", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js
new file mode 100644
index 0000000000..34ee180582
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+let defaultIdentity = addIdentity(account);
+defaultIdentity.composeHtml = true;
+let nonDefaultIdentity = addIdentity(account);
+nonDefaultIdentity.composeHtml = false;
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testIdentity() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ browser.test.assertEq(
+ 2,
+ popAccount.identities.length,
+ "number of identities"
+ );
+ let [defaultIdentity, nonDefaultIdentity] = popAccount.identities;
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ browser.test.log(defaultIdentity.id);
+ browser.test.log(nonDefaultIdentity.id);
+
+ let funcs = [
+ { name: "beginNew", args: [] },
+ { name: "beginReply", args: [messages[0].id] },
+ { name: "beginForward", args: [messages[1].id, "forwardAsAttachment"] },
+ // Uses a different code path.
+ { name: "beginForward", args: [messages[2].id, "forwardInline"] },
+ { name: "beginNew", args: [messages[3].id] },
+ ];
+ let tests = [
+ { args: [], isDefault: true },
+ {
+ args: [{ identityId: defaultIdentity.id }],
+ isDefault: true,
+ },
+ {
+ args: [{ identityId: nonDefaultIdentity.id }],
+ isDefault: false,
+ },
+ ];
+ for (let func of funcs) {
+ browser.test.log(func.name);
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test.args));
+ let tab = await browser.compose[func.name](
+ ...func.args.concat(test.args)
+ );
+ browser.test.assertEq("object", typeof tab);
+ browser.test.assertEq("number", typeof tab.id);
+ await window.sendMessage("checkIdentity", test.isDefault);
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkIdentity", async isDefault => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ is(
+ composeWindows[0].getCurrentIdentityKey(),
+ isDefault ? defaultIdentity.key : nonDefaultIdentity.key
+ );
+ composeWindows[0].close();
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js
new file mode 100644
index 0000000000..298da47578
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(() => {
+ let account = createAccount("pop3");
+ createAccount("local");
+ MailServices.accounts.defaultAccount = account;
+
+ addIdentity(account);
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 4);
+});
+
+/* Test if getComposeDetails() is waiting until the entire init procedure of
+ * the composeWindow has finished, before returning values. */
+add_task(async function testComposerIsReady() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Begin new.",
+ funcName: "beginNew",
+ arguments: [details],
+ },
+ {
+ description: "Edit as new.",
+ funcName: "beginNew",
+ arguments: [messages[0].id, details],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let expectedDetails = test.arguments[test.arguments.length - 1];
+
+ // Test with windows.onCreated
+ {
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdWindow] = await createdWindowPromise;
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let actualDetails = await browser.compose.getComposeDetails(tab.id);
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After windows.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ // Test the windows API being able to return the messageCompose window as
+ // the current one.
+ await window.waitForCondition(async () => {
+ let win = await browser.windows.get(createdWindow.id);
+ return win.focused;
+ }, `Window should have received focus.`);
+
+ let composeWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(composeWindow.type, "messageCompose");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ // Test with tabs.onCreated
+ {
+ let createdTabPromise = window.waitForEvent("tabs.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdTab] = await createdTabPromise;
+ let actualDetails = await browser.compose.getComposeDetails(
+ createdTab.id
+ );
+
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After tabs.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let createdWindow = await browser.windows.get(createdTab.windowId);
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js
new file mode 100644
index 0000000000..979901d2a5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(() => {
+ let account = createAccount("pop3");
+ createAccount("local");
+ MailServices.accounts.defaultAccount = account;
+
+ addIdentity(account);
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 4);
+});
+
+/* Test if getComposeDetails() is waiting until the entire init procedure of
+ * the composeWindow has finished, before returning values. */
+add_task(async function testComposerIsReady() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Reply default.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, details],
+ },
+ {
+ description: "Reply as replyToSender.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToSender", details],
+ },
+ {
+ description: "Reply as replyToList.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToList", details],
+ },
+ {
+ description: "Reply as replyToAll.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToAll", details],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let expectedDetails = test.arguments[test.arguments.length - 1];
+
+ // Test with windows.onCreated
+ {
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdWindow] = await createdWindowPromise;
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let actualDetails = await browser.compose.getComposeDetails(tab.id);
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After windows.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ // Test the windows API being able to return the messageCompose window as
+ // the current one.
+ await window.waitForCondition(async () => {
+ let win = await browser.windows.get(createdWindow.id);
+ return win.focused;
+ }, `Window should have received focus.`);
+
+ let composeWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(composeWindow.type, "messageCompose");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ // Test with tabs.onCreated
+ {
+ let createdTabPromise = window.waitForEvent("tabs.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdTab] = await createdTabPromise;
+ let actualDetails = await browser.compose.getComposeDetails(
+ createdTab.id
+ );
+
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After tabs.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let createdWindow = await browser.windows.get(createdTab.windowId);
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js
new file mode 100644
index 0000000000..17d6a968ed
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+var gServer;
+var gOutbox;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ let popAccount = createAccount("pop3");
+ let localAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = popAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ popAccount.addIdentity(identity);
+ popAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ let rootFolder = localAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("Sent", null);
+ MailServices.accounts.setSpecialFolders();
+ gOutbox = rootFolder.getChildNamed("Outbox");
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ });
+});
+
+add_task(async function testIsReflexive() {
+ let files = {
+ "background.js": async () => {
+ function trimContent(content) {
+ let data = content.replaceAll("\r\n", "\n").split("\n");
+ while (data[data.length - 1] == "") {
+ data.pop();
+ }
+ return data.join("\n");
+ }
+
+ // Create a plain text message.
+ let createdTextWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ plainTextBody: "This is some PLAIN text.",
+ isPlainText: true,
+ to: "rcpt@invalid.foo",
+ subject: "Test message",
+ });
+ let [createdTextWindow] = await createdTextWindowPromise;
+ let [createdTextTab] = await browser.tabs.query({
+ windowId: createdTextWindow.id,
+ });
+
+ // Call getComposeDetails() to trigger the actual bug.
+ let details = await browser.compose.getComposeDetails(createdTextTab.id);
+ browser.test.assertEq("This is some PLAIN text.", details.plainTextBody);
+
+ // Send the message.
+ let removedTextWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.compose.sendMessage(createdTextTab.id);
+ await removedTextWindowPromise;
+
+ // Find the message in the send folder.
+ let accounts = await browser.accounts.list();
+ let account = accounts.find(a => a.folders.find(f => f.type == "sent"));
+ let { messages } = await browser.messages.list(
+ account.folders.find(f => f.type == "sent")
+ );
+
+ // Read the message.
+ browser.test.assertEq(
+ "Test message",
+ messages[0].subject,
+ "Should find the sent message"
+ );
+ let message = await browser.messages.getFull(messages[0].id);
+ let content = trimContent(message.parts[0].body);
+
+ // Test that the first line is not an empty line.
+ browser.test.assertEq(
+ "This is some PLAIN text.",
+ content,
+ "The content should not start with an empty line"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "compose.send", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js
new file mode 100644
index 0000000000..394a7906c4
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+addIdentity(account);
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function test_update_plaintext_before_send() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ // Setup onBeforeSend listener.
+
+ let listener = async tab => {
+ let details1 = await browser.compose.getComposeDetails(tab.id);
+ details1.plainTextBody =
+ "Pre Text\n\n" + details1.plainTextBody + "\n\nPost Text";
+ await browser.compose.setComposeDetails(tab.id, details1);
+ await new Promise(resolve => window.setTimeout(resolve));
+
+ let details2 = await browser.compose.getComposeDetails(tab.id);
+ browser.test.assertEq(
+ details1.plainTextBody,
+ details2.plainTextBody,
+ "PlainTextBody should be correct after updated in onBeforeSend"
+ );
+
+ return {};
+ };
+ browser.compose.onBeforeSend.addListener(listener);
+
+ // Reply to a message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ let tab = await browser.compose.beginReply(messages[0].id, {
+ isPlainText: true,
+ });
+ await createdWindowPromise;
+
+ // Send message and trigger onBeforeSend event.
+
+ await new Promise(resolve => window.setTimeout(resolve));
+ let closedWindowPromise = window.waitForEvent("windows.onRemoved");
+ await browser.compose.sendMessage(tab.id, { mode: "sendLater" });
+ await closedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead", "compose.send"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_details.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_details.js
new file mode 100644
index 0000000000..3eb16102c5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_details.js
@@ -0,0 +1,725 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account);
+defaultIdentity.attachVCard = false;
+nonDefaultIdentity.attachVCard = true;
+
+let gRootFolder = account.incomingServer.rootFolder;
+
+gRootFolder.createSubfolder("test", null);
+let gTestFolder = gRootFolder.getChildNamed("test");
+createMessages(gTestFolder, 4);
+
+// TODO: Figure out why naming this folder drafts is problematic.
+gRootFolder.createSubfolder("something", null);
+let gDraftsFolder = gRootFolder.getChildNamed("something");
+gDraftsFolder.flags = Ci.nsMsgFolderFlags.Drafts;
+createMessages(gDraftsFolder, 2);
+let gDrafts = [...gDraftsFolder.messages];
+
+// Verifies ComposeDetails of a given composer can be applied to a different
+// composer, even if they have different compose formats. The composer should pick
+// the matching body/plaintextBody value, if both are specified. The value for
+// isPlainText is ignored by setComposeDetails.
+add_task(async function testIsReflexive() {
+ let files = {
+ "background.js": async () => {
+ // Start a new TEXT message.
+ let createdTextWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ plainTextBody: "This is some PLAIN text.",
+ isPlainText: true,
+ });
+ let [createdTextWindow] = await createdTextWindowPromise;
+ let [createdTextTab] = await browser.tabs.query({
+ windowId: createdTextWindow.id,
+ });
+
+ // Get details, TEXT message.
+ let textDetails = await browser.compose.getComposeDetails(
+ createdTextTab.id
+ );
+ browser.test.assertTrue(textDetails.isPlainText);
+ browser.test.assertTrue(
+ textDetails.body.includes("This is some PLAIN text")
+ );
+ browser.test.assertEq(
+ "This is some PLAIN text.",
+ textDetails.plainTextBody
+ );
+
+ // Start a new HTML message.
+ let createdHtmlWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ body: "<p>This is some <i>HTML</i> text.</p>",
+ isPlainText: false,
+ });
+ let [createdHtmlWindow] = await createdHtmlWindowPromise;
+ let [createdHtmlTab] = await browser.tabs.query({
+ windowId: createdHtmlWindow.id,
+ });
+
+ // Get details, HTML message.
+ let htmlDetails = await browser.compose.getComposeDetails(
+ createdHtmlTab.id
+ );
+ browser.test.assertFalse(htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes("<p>This is some <i>HTML</i> text.</p>")
+ );
+ browser.test.assertEq(
+ "This is some /HTML/ text.",
+ htmlDetails.plainTextBody
+ );
+
+ // Set HTML details on HTML composer. It should not throw.
+ await browser.compose.setComposeDetails(createdHtmlTab.id, htmlDetails);
+
+ // Set TEXT details on TEXT composer. It should not throw.
+ await browser.compose.setComposeDetails(createdTextTab.id, textDetails);
+
+ // Set TEXT details on HTML composer and verify the changed content.
+ await browser.compose.setComposeDetails(createdHtmlTab.id, textDetails);
+ let htmlDetails2 = await browser.compose.getComposeDetails(
+ createdHtmlTab.id
+ );
+ browser.test.assertFalse(htmlDetails2.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails2.body.includes("This is some PLAIN text")
+ );
+ browser.test.assertEq(
+ "This is some PLAIN text.",
+ htmlDetails2.plainTextBody
+ );
+
+ // Set HTML details on TEXT composer and verify the changed content.
+ await browser.compose.setComposeDetails(createdTextTab.id, htmlDetails);
+ let textDetails2 = await browser.compose.getComposeDetails(
+ createdTextTab.id
+ );
+ browser.test.assertTrue(textDetails2.isPlainText);
+ browser.test.assertTrue(
+ textDetails2.body.includes("This is some /HTML/ text.")
+ );
+ browser.test.assertEq(
+ "This is some /HTML/ text.",
+ textDetails2.plainTextBody
+ );
+
+ // Clean up.
+
+ let removedHtmlWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdHtmlWindow.id);
+ await removedHtmlWindowPromise;
+
+ let removedTextWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdTextWindow.id);
+ await removedTextWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testType() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length, "number of accounts");
+
+ let testFolder = accounts[0].folders.find(f => f.name == "test");
+ let messages = (await browser.messages.list(testFolder)).messages;
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let draftFolder = accounts[0].folders.find(f => f.name == "something");
+ let drafts = (await browser.messages.list(draftFolder)).messages;
+ browser.test.assertEq(2, drafts.length, "number of drafts");
+
+ async function checkComposer(tab, expected) {
+ browser.test.assertEq("object", typeof tab, "type of tab");
+ browser.test.assertEq("number", typeof tab.id, "type of tab ID");
+ browser.test.assertEq(
+ "number",
+ typeof tab.windowId,
+ "type of window ID"
+ );
+
+ let details = await browser.compose.getComposeDetails(tab.id);
+ browser.test.assertEq(expected.type, details.type, "type of composer");
+ browser.test.assertEq(
+ expected.relatedMessageId,
+ details.relatedMessageId,
+ `related message id (${details.type})`
+ );
+ await browser.windows.remove(tab.windowId);
+ }
+
+ let tests = [
+ {
+ funcName: "beginNew",
+ args: [],
+ expected: { type: "new", relatedMessageId: null },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[0].id],
+ expected: { type: "reply", relatedMessageId: messages[0].id },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[1].id, "replyToAll"],
+ expected: { type: "reply", relatedMessageId: messages[1].id },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[2].id, "replyToList"],
+ expected: { type: "reply", relatedMessageId: messages[2].id },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[3].id, "replyToSender"],
+ expected: { type: "reply", relatedMessageId: messages[3].id },
+ },
+ {
+ funcName: "beginForward",
+ args: [messages[0].id],
+ expected: { type: "forward", relatedMessageId: messages[0].id },
+ },
+ {
+ funcName: "beginForward",
+ args: [messages[1].id, "forwardAsAttachment"],
+ expected: { type: "forward", relatedMessageId: messages[1].id },
+ },
+ // Uses a different code path.
+ {
+ funcName: "beginForward",
+ args: [messages[2].id, "forwardInline"],
+ expected: { type: "forward", relatedMessageId: messages[2].id },
+ },
+ {
+ funcName: "beginNew",
+ args: [messages[3].id],
+ expected: { type: "new", relatedMessageId: messages[3].id },
+ },
+ ];
+ for (let test of tests) {
+ browser.test.log(test.funcName);
+ let tab = await browser.compose[test.funcName](...test.args);
+ await checkComposer(tab, test.expected);
+ }
+
+ browser.tabs.onCreated.addListener(async tab => {
+ // Bug 1702957, if composeWindow.GetComposeDetails() is not delayed
+ // until the compose window is ready, it will overwrite the compose
+ // fields.
+ let details = await browser.compose.getComposeDetails(tab.id);
+ browser.test.assertEq(
+ "Johnny Jones <johnny@jones.invalid>",
+ details.to.pop(),
+ "Check Recipients in draft after calling getComposeDetails()"
+ );
+
+ let window = await browser.windows.get(tab.windowId);
+ if (window.type == "messageCompose") {
+ await checkComposer(tab, {
+ type: "draft",
+ relatedMessageId: drafts[0].id,
+ });
+ browser.test.notifyPass("Finish");
+ }
+ });
+ browser.test.sendMessage("openDrafts");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+
+ // The first part of the test is done in the background script using the
+ // compose API to open compose windows. For the second part we need to open
+ // a draft, which is not possible with the compose API.
+ await extension.awaitMessage("openDrafts");
+ window.ComposeMessage(
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ gDraftsFolder,
+ [gDraftsFolder.generateMessageURI(gDrafts[0].messageKey)]
+ );
+
+ await extension.awaitFinish("Finish");
+ await extension.unload();
+});
+
+add_task(async function testFcc() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(createdTab, expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+
+ browser.test.assertEq(
+ expected.overrideDefaultFcc,
+ state.overrideDefaultFcc,
+ "overrideDefaultFcc should be correct"
+ );
+
+ if (expected.overrideDefaultFccFolder) {
+ window.assertDeepEqual(
+ state.overrideDefaultFccFolder,
+ expected.overrideDefaultFccFolder,
+ "overrideDefaultFccFolder should be correct"
+ );
+ } else {
+ browser.test.assertEq(
+ expected.overrideDefaultFccFolder,
+ state.overrideDefaultFccFolder,
+ "overrideDefaultFccFolder should be correct"
+ );
+ }
+
+ if (expected.additionalFccFolder) {
+ window.assertDeepEqual(
+ state.additionalFccFolder,
+ expected.additionalFccFolder,
+ "additionalFccFolder should be correct"
+ );
+ } else {
+ browser.test.assertEq(
+ expected.additionalFccFolder,
+ state.additionalFccFolder,
+ "additionalFccFolder should be correct"
+ );
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ let [account] = await browser.accounts.list();
+ let folder1 = account.folders.find(f => f.name == "Trash");
+ let folder2 = account.folders.find(f => f.name == "something");
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: false,
+ overrideDefaultFccFolder: null,
+ additionalFccFolder: "",
+ });
+
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFcc: true,
+ }),
+ "Setting overrideDefaultFcc to true requires setting overrideDefaultFccFolder as well",
+ "browser.compose.setComposeDetails() should reject setting overrideDefaultFcc to true."
+ );
+
+ // Set folders.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+
+ // Setting overrideDefaultFcc true while it is already true should not change any values.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFcc: true,
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+
+ // A no-op should not change any values.
+ await browser.compose.setComposeDetails(createdTab.id, {});
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+
+ // Disable fcc.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFccFolder: "",
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: "",
+ additionalFccFolder: folder2,
+ });
+
+ // Disable additional fcc.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ additionalFccFolder: "",
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: "",
+ additionalFccFolder: "",
+ });
+
+ // Clear override.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFcc: false,
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: false,
+ overrideDefaultFccFolder: null,
+ additionalFccFolder: "",
+ });
+
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFccFolder: {
+ path: "/bad",
+ accountId: folder1.accountId,
+ },
+ }),
+ `Invalid MailFolder: {accountId:${folder1.accountId}, path:/bad}`,
+ "browser.compose.setComposeDetails() should reject, if an invalid folder is set as overrideDefaultFccFolder."
+ );
+
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, {
+ additionalFccFolder: { path: "/bad", accountId: folder1.accountId },
+ }),
+ `Invalid MailFolder: {accountId:${folder1.accountId}, path:/bad}`,
+ "browser.compose.setComposeDetails() should reject, if an invalid folder is set as additionalFccFolder."
+ );
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testSimpleDetails() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(createdTab, expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+
+ if (expected.priority) {
+ browser.test.assertEq(
+ expected.priority,
+ state.priority,
+ "priority should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("returnReceipt")) {
+ browser.test.assertEq(
+ expected.returnReceipt,
+ state.returnReceipt,
+ "returnReceipt should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("deliveryStatusNotification")) {
+ browser.test.assertEq(
+ expected.deliveryStatusNotification,
+ state.deliveryStatusNotification,
+ "deliveryStatusNotification should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("attachVCard")) {
+ browser.test.assertEq(
+ expected.attachVCard,
+ state.attachVCard,
+ "attachVCard should be correct"
+ );
+ }
+
+ if (expected.deliveryFormat) {
+ browser.test.assertEq(
+ expected.deliveryFormat,
+ state.deliveryFormat,
+ "deliveryFormat should be correct"
+ );
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length, "number of accounts");
+ let localAccount = accounts.find(a => a.type == "none");
+ browser.test.assertEq(
+ 2,
+ localAccount.identities.length,
+ "number of identities"
+ );
+ let [defaultIdentity, nonDefaultIdentity] = localAccount.identities;
+
+ let expected = {
+ priority: "normal",
+ returnReceipt: false,
+ deliveryStatusNotification: false,
+ deliveryFormat: "auto",
+ attachVCard: false,
+ identityId: defaultIdentity.id,
+ };
+
+ async function changeDetail(key, value, _expected = {}) {
+ await browser.compose.setComposeDetails(createdTab.id, {
+ [key]: value,
+ });
+ expected[key] = value;
+ for (let [k, v] of Object.entries(_expected)) {
+ expected[k] = v;
+ }
+ await checkWindow(createdTab, expected);
+ }
+
+ // Confirm initial condition.
+ await checkWindow(createdTab, expected);
+
+ // Changing the identity without having made any changes, should load the
+ // defaults of the second identity.
+ await changeDetail("identityId", nonDefaultIdentity.id, {
+ attachVCard: true,
+ });
+
+ // Switching back should restore the defaults of the first identity.
+ await changeDetail("identityId", defaultIdentity.id, {
+ attachVCard: false,
+ });
+
+ await changeDetail("priority", "highest");
+ await changeDetail("deliveryFormat", "html");
+ await changeDetail("returnReceipt", true);
+ await changeDetail("deliveryFormat", "plaintext");
+ await changeDetail("priority", "lowest");
+ await changeDetail("attachVCard", true);
+ await changeDetail("priority", "high");
+ await changeDetail("deliveryFormat", "both");
+ await changeDetail("deliveryStatusNotification", true);
+ await changeDetail("priority", "low");
+
+ await changeDetail("priority", "normal");
+ await changeDetail("deliveryFormat", "auto");
+ await changeDetail("attachVCard", false);
+ await changeDetail("returnReceipt", false);
+ await changeDetail("deliveryStatusNotification", false);
+
+ // Changing the identity should not load the defaults of the second identity,
+ // after the values had been changed.
+ await changeDetail("identityId", nonDefaultIdentity.id);
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testAutoComplete() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(createdTab, expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+
+ for (let [id, value] of Object.entries(expected.pills)) {
+ browser.test.assertEq(
+ value,
+ state[id].length ? state[id][0] : "",
+ `value for ${id} should be correct`
+ );
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ // Start a new message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ // Create a test contact.
+ let [addressBook] = await browser.addressBooks.list(true);
+ let contactId = await browser.contacts.create(addressBook.id, {
+ PrimaryEmail: "autocomplete@invalid",
+ DisplayName: "Autocomplete Test",
+ });
+
+ // Confirm the addrTo field has focus and addrTo and replyTo fields are empty.
+ await checkWindow(createdTab, {
+ activeElement: "toAddrInput",
+ pills: { to: "", replyTo: "" },
+ values: { toAddrInput: "", replyAddrInput: "" },
+ });
+
+ // Set the replyTo field, which should not break autocomplete for the currently active addrTo
+ // field.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ replyTo: "test@user.net",
+ });
+
+ // Confirm the addrTo field has focus and replyTo field is set.
+ await checkWindow(createdTab, {
+ activeElement: "toAddrInput",
+ pills: { to: "", replyTo: "test@user.net" },
+ values: { toAddrInput: "", replyAddrInput: "" },
+ });
+
+ // Manually type "Autocomplete" into the active field, which should be the toAddr field and it
+ // should autocomplete.
+ await window.sendMessage("typeIntoActiveAddrField", "Autocomplete");
+
+ // Confirm the addrTo field has focus and replyTo field is set and the addrTo field has been
+ // autocompleted.
+ await checkWindow(createdTab, {
+ activeElement: "toAddrInput",
+ pills: { to: "", replyTo: "test@user.net" },
+ values: {
+ toAddrInput: "Autocomplete Test <autocomplete@invalid>",
+ replyAddrInput: "",
+ },
+ });
+
+ // Clean up.
+ await browser.contacts.delete(contactId);
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "addressBooks"],
+ },
+ });
+
+ extension.onMessage("typeIntoActiveAddrField", async value => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ for (const s of value) {
+ EventUtils.synthesizeKey(s, {}, composeWindows[0]);
+ await new Promise(r => composeWindows[0].setTimeout(r));
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ let composeDocument = composeWindows[0].document;
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ Assert.equal(
+ composeDocument.activeElement.id,
+ expected.activeElement,
+ `Active element should be correct`
+ );
+
+ for (let [id, value] of Object.entries(expected.values)) {
+ await TestUtils.waitForCondition(
+ () => composeDocument.getElementById(id).value == value,
+ `Value of field ${id} should be correct`
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js
new file mode 100644
index 0000000000..84b4b22019
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js
@@ -0,0 +1,469 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account);
+let gRootFolder = account.incomingServer.rootFolder;
+
+gRootFolder.createSubfolder("test", null);
+let gTestFolder = gRootFolder.getChildNamed("test");
+createMessages(gTestFolder, 4);
+
+add_task(async function testPlainTextBody() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+ for (let field of ["isPlainText"]) {
+ if (field in expected) {
+ browser.test.assertEq(
+ expected[field],
+ state[field],
+ `Check value for ${field}`
+ );
+ }
+ }
+ for (let field of ["plainTextBody"]) {
+ if (field in expected) {
+ browser.test.assertEq(
+ JSON.stringify(expected[field]),
+ JSON.stringify(state[field]),
+ `Check value for ${field}`
+ );
+ }
+ }
+ }
+
+ // Start a new message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({ isPlainText: true });
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await checkWindow({ isPlainText: true });
+
+ let tests = [
+ {
+ // Set plaintextBody with Windows style newlines. The return value of
+ // the API is independent of the used OS and only returns LF endings.
+ input: { isPlainText: true, plainTextBody: "123\r\n456\r\n789" },
+ expected: { isPlainText: true, plainTextBody: "123\n456\n789" },
+ },
+ {
+ // Set plaintextBody with Linux style newlines. The return value of
+ // the API is independent of the used OS and only returns LF endings.
+ input: { isPlainText: true, plainTextBody: "ABC\nDEF\nGHI" },
+ expected: { isPlainText: true, plainTextBody: "ABC\nDEF\nGHI" },
+ },
+ {
+ // Bug 1792551 without newline at the end.
+ input: { isPlainText: true, plainTextBody: "123456 \n Hello " },
+ expected: { isPlainText: true, plainTextBody: "123456 \n Hello " },
+ },
+ {
+ // Bug 1792551 without newline at the end.
+ input: { isPlainText: true, plainTextBody: "123456 &nbsp; \n " },
+ expected: { isPlainText: true, plainTextBody: "123456 &nbsp; \n " },
+ },
+ {
+ // Bug 1792551 with a newline at the end.
+ input: { isPlainText: true, plainTextBody: "123456 \n Hello \n" },
+ expected: { isPlainText: true, plainTextBody: "123456 \n Hello \n" },
+ },
+ ];
+ for (let test of tests) {
+ browser.test.log(`Checking input: ${JSON.stringify(test.input)}`);
+ await browser.compose.setComposeDetails(createdTab.id, test.input);
+ await checkWindow(test.expected);
+ }
+
+ browser.test.log("Replace plainTextBody with empty string");
+ await browser.compose.setComposeDetails(createdTab.id, {
+ isPlainText: true,
+ plainTextBody: "Lorem ipsum",
+ });
+ await checkWindow({ isPlainText: true, plainTextBody: "Lorem ipsum" });
+ await browser.compose.setComposeDetails(createdTab.id, {
+ isPlainText: true,
+ plainTextBody: "",
+ });
+ await checkWindow({ isPlainText: true, plainTextBody: "" });
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testBody() {
+ // Open an compose window with HTML body.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.composeFields.body = "<p>This is some <i>HTML</i> text.</p>";
+
+ let htmlWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let htmlWindow = await htmlWindowPromise;
+ await BrowserTestUtils.waitForEvent(htmlWindow, "load");
+
+ // Open another compose window with plain text body.
+
+ params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.format = Ci.nsIMsgCompFormat.PlainText;
+ params.composeFields.body = "This is some plain text.";
+
+ let plainTextComposeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let plainTextWindow = await plainTextComposeWindowPromise;
+ await BrowserTestUtils.waitForEvent(plainTextWindow, "load");
+
+ // Run the extension.
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let windows = await browser.windows.getAll({
+ populate: true,
+ windowTypes: ["messageCompose"],
+ });
+ let [htmlTabId, plainTextTabId] = windows.map(w => w.tabs[0].id);
+
+ let plainTextBodyTag =
+ '<body style="font-family: -moz-fixed; white-space: pre-wrap; width: 72ch;">';
+
+ // Get details, HTML message.
+
+ let htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes("<p>This is some <i>HTML</i> text.</p>")
+ );
+ browser.test.assertEq(
+ "This is some /HTML/ text.",
+ htmlDetails.plainTextBody
+ );
+
+ // Set details, HTML message.
+
+ await browser.compose.setComposeDetails(htmlTabId, {
+ body: htmlDetails.body.replace("<i>HTML</i>", "<code>HTML</code>"),
+ });
+ htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes("<p>This is some <code>HTML</code> text.</p>")
+ );
+ browser.test.assertTrue(
+ "This is some HTML text.",
+ htmlDetails.plainTextBody
+ );
+
+ // Get details, plain text message.
+
+ let plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(
+ plainTextBodyTag + "This is some plain text.</body>"
+ )
+ );
+ browser.test.assertEq(
+ "This is some plain text.",
+ plainTextDetails.plainTextBody
+ );
+
+ // Set details, plain text message.
+
+ await browser.compose.setComposeDetails(plainTextTabId, {
+ plainTextBody:
+ plainTextDetails.plainTextBody + "\nIndeed, it is plain.",
+ });
+ plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(
+ plainTextBodyTag +
+ "This is some plain text.<br>Indeed, it is plain.</body>"
+ )
+ );
+ browser.test.assertEq(
+ "This is some plain text.\nIndeed, it is plain.",
+ // Fold Windows line-endings \r\n to \n.
+ plainTextDetails.plainTextBody.replace(/\r/g, "")
+ );
+
+ // Some things that should fail.
+
+ try {
+ await browser.compose.setComposeDetails(plainTextTabId, {
+ body: "Providing conflicting format settings.",
+ isPlainText: true,
+ });
+ browser.test.fail(
+ "calling setComposeDetails with these arguments should throw"
+ );
+ } catch (ex) {
+ browser.test.succeed(`expected exception thrown: ${ex.message}`);
+ }
+ try {
+ await browser.compose.setComposeDetails(htmlTabId, {
+ plainTextBody: "Providing conflicting format settings.",
+ isPlainText: false,
+ });
+ browser.test.fail(
+ "calling setComposeDetails with these arguments should throw"
+ );
+ } catch (ex) {
+ browser.test.succeed(`expected exception thrown: ${ex.message}`);
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Check the HTML message was edited.
+
+ ok(htmlWindow.gMsgCompose.composeHTML);
+ let htmlDocument = htmlWindow.GetCurrentEditor().document;
+ info(htmlDocument.body.innerHTML);
+ is(htmlDocument.querySelectorAll("i").length, 0, "<i> was removed");
+ is(htmlDocument.querySelectorAll("code").length, 1, "<code> was added");
+
+ // Close the HTML message.
+
+ let closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(htmlWindow),
+ ];
+ Assert.ok(
+ htmlWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ htmlWindow.close();
+ await Promise.all(closePromises);
+
+ // Check the plain text message was edited.
+
+ ok(!plainTextWindow.gMsgCompose.composeHTML);
+ let plainTextDocument = plainTextWindow.GetCurrentEditor().document;
+ info(plainTextDocument.body.innerHTML);
+ ok(/Indeed, it is plain\./.test(plainTextDocument.body.innerHTML));
+
+ // Close the plain text message.
+
+ closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(plainTextWindow),
+ ];
+ Assert.ok(
+ plainTextWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ plainTextWindow.close();
+ await Promise.all(closePromises);
+});
+
+add_task(async function testCJK() {
+ let longCJKString = "안".repeat(400);
+
+ // Open an compose window with HTML body.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.composeFields.body = longCJKString;
+
+ let htmlWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let htmlWindow = await htmlWindowPromise;
+ await BrowserTestUtils.waitForEvent(htmlWindow, "load");
+
+ // Open another compose window with plain text body.
+
+ params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.format = Ci.nsIMsgCompFormat.PlainText;
+ params.composeFields.body = longCJKString;
+
+ let plainTextComposeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let plainTextWindow = await plainTextComposeWindowPromise;
+ await BrowserTestUtils.waitForEvent(plainTextWindow, "load");
+
+ // Run the extension.
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let longCJKString = "안".repeat(400);
+ let windows = await browser.windows.getAll({
+ populate: true,
+ windowTypes: ["messageCompose"],
+ });
+ let [htmlTabId, plainTextTabId] = windows.map(w => w.tabs[0].id);
+
+ let plainTextBodyTag =
+ '<body style="font-family: -moz-fixed; white-space: pre-wrap; width: 72ch;">';
+
+ // Get details, HTML message.
+
+ let htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes(longCJKString),
+ "getComposeDetails.body from html composer returned CJK correctly"
+ );
+ browser.test.assertEq(
+ longCJKString,
+ htmlDetails.plainTextBody,
+ "getComposeDetails.plainTextBody from html composer returned CJK correctly"
+ );
+
+ // Set details, HTML message.
+
+ await browser.compose.setComposeDetails(htmlTabId, {
+ body: longCJKString,
+ });
+ htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes(longCJKString),
+ "getComposeDetails.body from html composer returned CJK correctly as set by setComposeDetails"
+ );
+ browser.test.assertTrue(
+ longCJKString,
+ htmlDetails.plainTextBody,
+ "getComposeDetails.plainTextBody from html composer returned CJK correctly as set by setComposeDetails"
+ );
+
+ // Get details, plain text message.
+
+ let plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(plainTextBodyTag + longCJKString),
+ "getComposeDetails.body from text composer returned CJK correctly"
+ );
+ browser.test.assertEq(
+ longCJKString,
+ plainTextDetails.plainTextBody,
+ "getComposeDetails.plainTextBody from text composer returned CJK correctly"
+ );
+
+ // Set details, plain text message.
+
+ await browser.compose.setComposeDetails(plainTextTabId, {
+ plainTextBody: longCJKString,
+ });
+ plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(plainTextBodyTag + longCJKString),
+ "getComposeDetails.body from text composer returned CJK correctly as set by setComposeDetails"
+ );
+ browser.test.assertEq(
+ longCJKString,
+ // Fold Windows line-endings \r\n to \n.
+ plainTextDetails.plainTextBody.replace(/\r/g, ""),
+ "getComposeDetails.plainTextBody from text composer returned CJK correctly as set by setComposeDetails"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Close the HTML message.
+
+ let closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(htmlWindow),
+ ];
+ Assert.ok(
+ htmlWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ htmlWindow.close();
+ await Promise.all(closePromises);
+
+ // Close the plain text message.
+
+ closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(plainTextWindow),
+ ];
+ Assert.ok(
+ plainTextWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ plainTextWindow.close();
+ await Promise.all(closePromises);
+}).__skipMe = AppConstants.platform == "linux" && AppConstants.DEBUG; // Permanent failure on CI, bug 1766758.
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js
new file mode 100644
index 0000000000..c5a60f307a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js
@@ -0,0 +1,727 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account);
+let gRootFolder = account.incomingServer.rootFolder;
+
+gRootFolder.createSubfolder("test", null);
+let gTestFolder = gRootFolder.getChildNamed("test");
+createMessages(gTestFolder, 4);
+
+add_task(async function testHeaders() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+ for (let field of [
+ "to",
+ "cc",
+ "bcc",
+ "replyTo",
+ "followupTo",
+ "newsgroups",
+ ]) {
+ if (field in expected) {
+ browser.test.assertEq(
+ expected[field].length,
+ state[field].length,
+ `${field} has the right number of values`
+ );
+ for (let i = 0; i < expected[field].length; i++) {
+ browser.test.assertEq(expected[field][i], state[field][i]);
+ }
+ } else {
+ browser.test.assertEq(0, state[field].length, `${field} is empty`);
+ }
+ }
+
+ if (expected.from) {
+ // From will always return a value, only check if explicitly requested.
+ browser.test.assertEq(expected.from, state.from, "from is correct");
+ }
+
+ if (expected.subject) {
+ browser.test.assertEq(
+ expected.subject,
+ state.subject,
+ "subject is correct"
+ );
+ } else {
+ browser.test.assertTrue(!state.subject, "subject is empty");
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ let [account] = await browser.accounts.list();
+ let [defaultIdentity, nonDefaultIdentity] = account.identities;
+
+ let addressBook = await browser.addressBooks.create({
+ name: "Baker Street",
+ });
+ let contacts = {
+ sherlock: await browser.contacts.create(addressBook, {
+ DisplayName: "Sherlock Holmes",
+ PrimaryEmail: "sherlock@bakerstreet.invalid",
+ }),
+ john: await browser.contacts.create(addressBook, {
+ DisplayName: "John Watson",
+ PrimaryEmail: "john@bakerstreet.invalid",
+ }),
+ empty: await browser.contacts.create(addressBook, {
+ DisplayName: "Jim Moriarty",
+ PrimaryEmail: "",
+ }),
+ };
+ let list = await browser.mailingLists.create(addressBook, {
+ name: "Holmes and Watson",
+ description: "Tenants221B",
+ });
+ await browser.mailingLists.addMember(list, contacts.sherlock);
+ await browser.mailingLists.addMember(list, contacts.john);
+
+ let identityChanged = null;
+ browser.compose.onIdentityChanged.addListener((tab, identityId) => {
+ identityChanged = identityId;
+ });
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await checkWindow({ identityId: defaultIdentity.id });
+
+ let tests = [
+ {
+ // Change the identity and check default from.
+ input: { identityId: nonDefaultIdentity.id },
+ expected: {
+ identityId: nonDefaultIdentity.id,
+ from: "mochitest@localhost",
+ },
+ expectIdentityChanged: nonDefaultIdentity.id,
+ },
+ {
+ // Don't change the identity.
+ input: {},
+ expected: {
+ identityId: nonDefaultIdentity.id,
+ from: "mochitest@localhost",
+ },
+ },
+ {
+ // Change the identity back again.
+ input: { identityId: defaultIdentity.id },
+ expected: {
+ identityId: defaultIdentity.id,
+ from: "mochitest@localhost",
+ },
+ expectIdentityChanged: defaultIdentity.id,
+ },
+ {
+ // Single input, string.
+ input: { to: "Greg Lestrade <greg@bakerstreet.invalid>" },
+ expected: { to: ["Greg Lestrade <greg@bakerstreet.invalid>"] },
+ },
+ {
+ // Empty string. Done here so we have something to clear.
+ input: { to: "" },
+ expected: {},
+ },
+ {
+ // Single input, array with string.
+ input: { to: ["John Watson <john@bakerstreet.invalid>"] },
+ expected: { to: ["John Watson <john@bakerstreet.invalid>"] },
+ },
+ {
+ // Name with a comma, not quoted per RFC 822. This is how
+ // getComposeDetails returns names with a comma.
+ input: { to: ["Holmes, Mycroft <mycroft@bakerstreet.invalid>"] },
+ expected: { to: ["Holmes, Mycroft <mycroft@bakerstreet.invalid>"] },
+ },
+ {
+ // Name with a comma, quoted per RFC 822. This should work too.
+ input: { to: [`"Holmes, Mycroft" <mycroft@bakerstreet.invalid>`] },
+ expected: { to: ["Holmes, Mycroft <mycroft@bakerstreet.invalid>"] },
+ },
+ {
+ // Name and address with non-ASCII characters.
+ input: { to: ["Jïm Morïarty <morïarty@bakerstreet.invalid>"] },
+ expected: { to: ["Jïm Morïarty <morïarty@bakerstreet.invalid>"] },
+ },
+ {
+ // Empty array. Done here so we have something to clear.
+ input: { to: [] },
+ expected: {},
+ },
+ {
+ // Single input, array with contact.
+ input: { to: [{ id: contacts.sherlock, type: "contact" }] },
+ expected: { to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"] },
+ },
+ {
+ // Null input. This should not clear the field.
+ input: { to: null },
+ expected: { to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"] },
+ },
+ {
+ // Single input, array with mailing list.
+ input: { to: [{ id: list, type: "mailingList" }] },
+ expected: { to: ["Holmes and Watson <Tenants221B>"] },
+ },
+ {
+ // Multiple inputs, string.
+ input: {
+ to: "Molly Hooper <molly@bakerstreet.invalid>, Mrs Hudson <mrs_hudson@bakerstreet.invalid>",
+ },
+ expected: {
+ to: [
+ "Molly Hooper <molly@bakerstreet.invalid>",
+ "Mrs Hudson <mrs_hudson@bakerstreet.invalid>",
+ ],
+ },
+ },
+ {
+ // Multiple inputs, array with strings.
+ input: {
+ to: [
+ "Irene Adler <irene@bakerstreet.invalid>",
+ "Mary Watson <mary@bakerstreet.invalid>",
+ ],
+ },
+ expected: {
+ to: [
+ "Irene Adler <irene@bakerstreet.invalid>",
+ "Mary Watson <mary@bakerstreet.invalid>",
+ ],
+ },
+ },
+ {
+ // Multiple inputs, mixed.
+ input: {
+ to: [
+ { id: contacts.sherlock, type: "contact" },
+ "Mycroft Holmes <mycroft@bakerstreet.invalid>",
+ ],
+ },
+ expected: {
+ to: [
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>",
+ "Mycroft Holmes <mycroft@bakerstreet.invalid>",
+ ],
+ },
+ },
+ {
+ // A newsgroup, string.
+ input: {
+ to: "",
+ newsgroups: "invalid.fake.newsgroup",
+ },
+ expected: {
+ newsgroups: ["invalid.fake.newsgroup"],
+ },
+ },
+ {
+ // Multiple newsgroups, string.
+ input: {
+ newsgroups: "invalid.fake.newsgroup, invalid.real.newsgroup",
+ },
+ expected: {
+ newsgroups: ["invalid.fake.newsgroup", "invalid.real.newsgroup"],
+ },
+ },
+ {
+ // A newsgroup, array with string.
+ input: {
+ newsgroups: ["invalid.real.newsgroup"],
+ },
+ expected: {
+ newsgroups: ["invalid.real.newsgroup"],
+ },
+ },
+ {
+ // Multiple newsgroup, array with string.
+ input: {
+ newsgroups: ["invalid.fake.newsgroup", "invalid.real.newsgroup"],
+ },
+ expected: {
+ newsgroups: ["invalid.fake.newsgroup", "invalid.real.newsgroup"],
+ },
+ },
+ {
+ // Change the subject.
+ input: {
+ newsgroups: "",
+ subject: "This is a test",
+ },
+ expected: {
+ subject: "This is a test",
+ },
+ },
+ {
+ // Clear the subject.
+ input: {
+ subject: "",
+ },
+ expected: {},
+ },
+ {
+ // Override from with string address
+ input: { from: "Mycroft Holmes <mycroft@bakerstreet.invalid>" },
+ expected: { from: "Mycroft Holmes <mycroft@bakerstreet.invalid>" },
+ },
+ {
+ // Override from with contact id
+ input: { from: { id: contacts.sherlock, type: "contact" } },
+ expected: { from: "Sherlock Holmes <sherlock@bakerstreet.invalid>" },
+ },
+ {
+ // Override from with multiple string address
+ input: {
+ from: "Mycroft Holmes <mycroft@bakerstreet.invalid>, Mary Watson <mary@bakerstreet.invalid>",
+ },
+ expected: {
+ errorDescription:
+ "Setting from to multiple addresses should throw.",
+ errorRejected:
+ "ComposeDetails.from: Exactly one address instead of 2 is required.",
+ },
+ },
+ {
+ // Override from with empty string address 1
+ input: { from: "Mycroft Holmes <>" },
+ expected: {
+ errorDescription:
+ "Setting from to a display name without address should throw (#1).",
+ errorRejected: "ComposeDetails.from: Invalid address: ",
+ },
+ },
+ {
+ // Override from with empty string address 2
+ input: { from: "Mycroft Holmes" },
+ expected: {
+ errorDescription:
+ "Setting from to a display name without address should throw (#2).",
+ errorRejected:
+ "ComposeDetails.from: Invalid address: Mycroft Holmes",
+ },
+ },
+ {
+ // Override from with contact id with empty address
+ input: { from: { id: contacts.empty, type: "contact" } },
+ expected: {
+ errorDescription:
+ "Setting from to a contact with an empty PrimaryEmail should throw.",
+ errorRejected: `ComposeDetails.from: Contact does not have a valid email address: ${contacts.empty}`,
+ },
+ },
+ {
+ // Override from with invalid contact id
+ input: { from: { id: "1234", type: "contact" } },
+ expected: {
+ errorDescription:
+ "Setting from to a contact with an invalid contact id should throw.",
+ errorRejected:
+ "ComposeDetails.from: contact with id=1234 could not be found.",
+ },
+ },
+ {
+ // Override from with mailinglist id
+ input: { from: { id: list, type: "mailingList" } },
+ expected: {
+ errorDescription: "Setting from to a mailing list should throw.",
+ errorRejected: "ComposeDetails.from: Mailing list not allowed.",
+ },
+ },
+ {
+ // From may not be cleared.
+ input: { from: "" },
+ expected: {
+ errorDescription: "Setting from to an empty string should throw.",
+ errorRejected:
+ "ComposeDetails.from: Address must not be set to an empty string.",
+ },
+ },
+ ];
+ for (let test of tests) {
+ browser.test.log(`Checking input: ${JSON.stringify(test.input)}`);
+
+ if (test.expected.errorRejected) {
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, test.input),
+ test.expected.errorRejected,
+ test.expected.errorDescription
+ );
+ continue;
+ }
+
+ await browser.compose.setComposeDetails(createdTab.id, test.input);
+ await checkWindow(test.expected);
+
+ if (test.expectIdentityChanged) {
+ browser.test.assertEq(
+ test.expectIdentityChanged,
+ identityChanged,
+ "onIdentityChanged fired"
+ );
+ } else {
+ browser.test.assertEq(
+ null,
+ identityChanged,
+ "onIdentityChanged not fired"
+ );
+ }
+ identityChanged = null;
+ }
+
+ // Change the identity through the UI to check onIdentityChanged works.
+
+ browser.test.log("Checking external identity change");
+ await window.sendMessage("changeIdentity", nonDefaultIdentity.id);
+ browser.test.assertEq(
+ nonDefaultIdentity.id,
+ identityChanged,
+ "onIdentityChanged fired"
+ );
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ await browser.addressBooks.delete(addressBook);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("changeIdentity", newIdentity => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ let composeDocument = composeWindows[0].document;
+
+ let identityList = composeDocument.getElementById("msgIdentity");
+ let identityItem = identityList.querySelector(
+ `[identitykey="${newIdentity}"]`
+ );
+ ok(identityItem);
+ identityList.selectedItem = identityItem;
+ composeWindows[0].LoadIdentity(false);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_onIdentityChanged_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.compose.onIdentityChanged.addListener(async (tab, identityId) => {
+ browser.test.sendMessage("identity changed", {
+ eventCount: ++eventCounter,
+ identityId,
+ });
+ });
+
+ browser.compose.onComposeStateChanged.addListener(async (tab, state) => {
+ browser.test.sendMessage("compose state changed", {
+ eventCount: ++eventCounter,
+ state,
+ });
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "compose", "messagesRead"],
+ browser_specific_settings: { gecko: { id: "compose@mochi.test" } },
+ },
+ });
+
+ function changeIdentity(newIdentity) {
+ let composeDocument = composeWindow.document;
+
+ let identityList = composeDocument.getElementById("msgIdentity");
+ let identityItem = identityList.querySelector(
+ `[identitykey="${newIdentity}"]`
+ );
+ ok(identityItem);
+ identityList.selectedItem = identityItem;
+ composeWindow.LoadIdentity(false);
+ }
+
+ function setToAddr(to) {
+ composeWindow.SetComposeDetails({ to });
+ }
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "compose.onIdentityChanged",
+ "compose.onComposeStateChanged",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger events without terminating the background first.
+
+ changeIdentity(nonDefaultIdentity.key);
+ {
+ let rv = await extension.awaitMessage("identity changed");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ identityId: nonDefaultIdentity.key,
+ },
+ rv,
+ "The non-primed onIdentityChanged event should return the correct values"
+ );
+ }
+
+ setToAddr("user@invalid.net");
+ {
+ let rv = await extension.awaitMessage("compose state changed");
+ Assert.deepEqual(
+ {
+ eventCount: 2,
+ state: {
+ canSendNow: true,
+ canSendLater: true,
+ },
+ },
+ rv,
+ "The non-primed onComposeStateChanged should return the correct values"
+ );
+ }
+
+ // Terminate background and re-trigger onIdentityChanged event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ changeIdentity(defaultIdentity.key);
+ {
+ let rv = await extension.awaitMessage("identity changed");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ identityId: defaultIdentity.key,
+ },
+ rv,
+ "The primed onIdentityChanged event should return the correct values"
+ );
+ }
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ // Terminate background and re-trigger onComposeStateChanged event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ setToAddr("invalid");
+ {
+ let rv = await extension.awaitMessage("compose state changed");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ state: {
+ canSendNow: false,
+ canSendLater: false,
+ },
+ },
+ rv,
+ "The primed onComposeStateChanged should return the correct values"
+ );
+ }
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
+
+add_task(async function testCustomHeaders() {
+ let files = {
+ "background.js": async () => {
+ async function checkCustomHeaders(tab, expectedCustomHeaders) {
+ let [testHeader] = await window.sendMessage("getTestHeader");
+ browser.test.assertEq(
+ "CannotTouchThis",
+ testHeader,
+ "Should include the test header."
+ );
+
+ let details = await browser.compose.getComposeDetails(tab.id);
+
+ browser.test.assertEq(
+ expectedCustomHeaders.length,
+ details.customHeaders.length,
+ "Should have the correct number of custom headers"
+ );
+ for (let i = 0; i < expectedCustomHeaders.length; i++) {
+ browser.test.assertEq(
+ expectedCustomHeaders[i].name,
+ details.customHeaders[i].name,
+ "Should have the correct header name"
+ );
+ browser.test.assertEq(
+ expectedCustomHeaders[i].value,
+ details.customHeaders[i].value,
+ "Should have the correct header value"
+ );
+ }
+ }
+
+ // Start a new message with custom headers.
+ let customHeaders = [{ name: "X-TEST1", value: "some header" }];
+ let tab = await browser.compose.beginNew(null, { customHeaders });
+
+ // Add a header which does not start with X- and should not be touched by
+ // the API.
+ await window.sendMessage("addTestHeader");
+
+ let expectedHeaders = [{ name: "X-Test1", value: "some header" }];
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Update details without changing headers.
+ await browser.compose.setComposeDetails(tab.id, {});
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Update existing header and add a new one.
+ customHeaders = [
+ { name: "X-TEST1", value: "this is header #1" },
+ { name: "X-TEST2", value: "this is header #2" },
+ { name: "X-TEST3", value: "this is header #3" },
+ { name: "X-TEST4", value: "this is header #4" },
+ ];
+ await browser.compose.setComposeDetails(tab.id, { customHeaders });
+ expectedHeaders = [
+ { name: "X-Test1", value: "this is header #1" },
+ { name: "X-Test2", value: "this is header #2" },
+ { name: "X-Test3", value: "this is header #3" },
+ { name: "X-Test4", value: "this is header #4" },
+ ];
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Update existing header and remove some of the others. Test support for
+ // empty headers.
+ customHeaders = [
+ { name: "X-TEST2", value: "this is a header" },
+ { name: "X-TEST3", value: "" },
+ ];
+ await browser.compose.setComposeDetails(tab.id, { customHeaders });
+ expectedHeaders = [
+ { name: "X-Test2", value: "this is a header" },
+ { name: "X-Test3", value: "" },
+ ];
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Clear headers.
+ customHeaders = [];
+ await browser.compose.setComposeDetails(tab.id, { customHeaders });
+ await checkCustomHeaders(tab, []);
+
+ // Should throw for invalid custom headers.
+ customHeaders = [
+ { name: "TEST2", value: "this is an invalid custom header" },
+ ];
+ await browser.test.assertThrows(
+ () => browser.compose.setComposeDetails(tab.id, { customHeaders }),
+ 'Type error for parameter details (Error processing customHeaders.0.name: String "TEST2" must match /^X-.*$/) for compose.setComposeDetails.',
+ "Should throw for invalid custom headers"
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(tab.windowId);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("addTestHeader", () => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ composeWindow.gMsgCompose.compFields.setHeader(
+ "ATestHeader",
+ "CannotTouchThis"
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getTestHeader", () => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let value = composeWindow.gMsgCompose.compFields.getHeader("ATestHeader");
+ extension.sendMessage(value);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js
new file mode 100644
index 0000000000..e77e5f47bf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js
@@ -0,0 +1,214 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+
+add_task(async function test_dictionaries() {
+ let files = {
+ "background.js": async () => {
+ function verifyDictionaries(dictionaries, expected) {
+ browser.test.assertEq(
+ Object.values(expected).length,
+ Object.values(dictionaries).length,
+ "Should find the correct number of installed dictionaries"
+ );
+ browser.test.assertEq(
+ Object.values(expected).filter(active => active).length,
+ Object.values(dictionaries).filter(active => active).length,
+ "Should find the correct number of active dictionaries"
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(
+ Object.keys(expected)[i],
+ Object.keys(dictionaries)[i],
+ "Should find the correct dictionary"
+ );
+ }
+ }
+ async function setDictionaries(newActiveDictionaries, expected) {
+ let changes = new Promise(resolve => {
+ let listener = (tab, dictionaries) => {
+ browser.compose.onActiveDictionariesChanged.removeListener(
+ listener
+ );
+ resolve({ tab, dictionaries });
+ };
+ browser.compose.onActiveDictionariesChanged.addListener(listener);
+ });
+
+ await browser.compose.setActiveDictionaries(
+ createdTab.id,
+ newActiveDictionaries
+ );
+ let eventData = await changes;
+ verifyDictionaries(expected.dictionaries, eventData.dictionaries);
+
+ browser.test.assertEq(
+ expected.tab.id,
+ eventData.tab.id,
+ "Should find the correct tab"
+ );
+
+ let dictionaries = await browser.compose.getActiveDictionaries(
+ createdTab.id
+ );
+ verifyDictionaries(expected.dictionaries, dictionaries);
+ }
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await browser.test.assertRejects(
+ browser.compose.setActiveDictionaries(createdTab.id, ["invalid"]),
+ `Dictionary not found: invalid`,
+ "should reject for invalid dictionaries"
+ );
+
+ await setDictionaries([], {
+ dictionaries: { "en-US": false },
+ tab: createdTab,
+ });
+ await setDictionaries(["en-US"], {
+ dictionaries: { "en-US": true },
+ tab: createdTab,
+ });
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_onActiveDictionariesChanged_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onActiveDictionariesChanged.addListener(
+ async (tab, dictionaries) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(
+ "onActiveDictionariesChanged received",
+ dictionaries
+ );
+ }
+ }
+ );
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.dictionary@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onActiveDictionariesChanged"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ async function setActiveDictionaries(activeDictionaries) {
+ let installedDictionaries = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList();
+
+ for (let dict of activeDictionaries) {
+ if (!installedDictionaries.includes(dict)) {
+ throw new Error(`Dictionary not found: ${dict}`);
+ }
+ }
+
+ await composeWindow.ComposeChangeLanguage(activeDictionaries);
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onActiveDictionariesChanged without terminating the background first.
+
+ setActiveDictionaries(["en-US"]);
+ let newActiveDictionary1 = await extension.awaitMessage(
+ "onActiveDictionariesChanged received"
+ );
+ Assert.equal(
+ newActiveDictionary1["en-US"],
+ true,
+ "Returned active dictionary should be correct"
+ );
+
+ // Terminate background and re-trigger onActiveDictionariesChanged.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ setActiveDictionaries([]);
+ let newActiveDictionary2 = await extension.awaitMessage(
+ "onActiveDictionariesChanged received"
+ );
+ Assert.equal(
+ newActiveDictionary2["en-US"],
+ false,
+ "Returned active dictionary should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
new file mode 100644
index 0000000000..285c4df33f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
@@ -0,0 +1,1010 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account, "nondefault@invalid");
+
+// A local outbox is needed so we can use "send later".
+let localAccount = createAccount("local");
+let outbox = localAccount.incomingServer.rootFolder.getChildNamed("outbox");
+
+function messagesInOutbox(count) {
+ info(`Checking for ${count} messages in outbox`);
+
+ count -= [...outbox.messages].length;
+ if (count <= 0) {
+ return Promise.resolve();
+ }
+
+ info(`Waiting for ${count} messages in outbox`);
+ return new Promise(resolve => {
+ MailServices.mfn.addListener(
+ {
+ msgAdded(msgHdr) {
+ if (--count == 0) {
+ MailServices.mfn.removeListener(this);
+ resolve();
+ }
+ },
+ },
+ MailServices.mfn.msgAdded
+ );
+ });
+}
+
+add_task(async function testCancel() {
+ let files = {
+ "background.js": async () => {
+ async function beginSend(sendExpected, lockExpected) {
+ await window.sendMessage("beginSend");
+ return checkIfSent(sendExpected, lockExpected);
+ }
+
+ function checkIfSent(sendExpected, lockExpected = null) {
+ return window.sendMessage("checkIfSent", sendExpected, lockExpected);
+ }
+
+ function checkWindow(expected) {
+ return window.sendMessage("checkWindow", expected);
+ }
+
+ // Open a compose window with a message. The message will never send
+ // because we removed the sending function, so we can attempt to send
+ // it over and over.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({ to: ["test@test.invalid"], subject: "Test" });
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send the message. No listeners exist, so sending should continue.
+
+ await beginSend(true);
+
+ // Add a non-cancelling listener. Sending should continue.
+
+ let listener1 = tab => {
+ listener1.tab = tab;
+ return {};
+ };
+ browser.compose.onBeforeSend.addListener(listener1);
+ await beginSend(true);
+ browser.test.assertEq(tab.id, listener1.tab.id, "listener1 was fired");
+ browser.compose.onBeforeSend.removeListener(listener1);
+ delete listener1.tab;
+
+ // Add a cancelling listener. Sending should not continue.
+
+ let listener2 = tab => {
+ listener2.tab = tab;
+ return { cancel: true };
+ };
+ browser.compose.onBeforeSend.addListener(listener2);
+ await beginSend(false, false);
+ browser.test.assertEq(tab.id, listener2.tab.id, "listener2 was fired");
+ browser.compose.onBeforeSend.removeListener(listener2);
+ delete listener2.tab;
+ await beginSend(true); // Removing the listener worked.
+
+ // Add a listener returning a Promise. Resolve the Promise to unblock.
+ // Sending should continue.
+
+ let listener3 = tab => {
+ listener3.tab = tab;
+ return new Promise(resolve => {
+ listener3.resolve = resolve;
+ });
+ };
+ browser.compose.onBeforeSend.addListener(listener3);
+ await beginSend(false, true);
+ browser.test.assertEq(tab.id, listener3.tab.id, "listener3 was fired");
+ listener3.resolve({ cancel: false });
+ await checkIfSent(true);
+ browser.compose.onBeforeSend.removeListener(listener3);
+ delete listener3.tab;
+
+ // Add a listener returning a Promise. Resolve the Promise to cancel.
+ // Sending should not continue.
+
+ let listener4 = tab => {
+ listener4.tab = tab;
+ return new Promise(resolve => {
+ listener4.resolve = resolve;
+ });
+ };
+ browser.compose.onBeforeSend.addListener(listener4);
+ await beginSend(false, true);
+ browser.test.assertEq(tab.id, listener4.tab.id, "listener4 was fired");
+ listener4.resolve({ cancel: true });
+ await checkIfSent(false, false);
+ browser.compose.onBeforeSend.removeListener(listener4);
+ delete listener4.tab;
+ await beginSend(true); // Removing the listener worked.
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.assertTrue(
+ !listener1.tab,
+ "listener1 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener2.tab,
+ "listener2 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener3.tab,
+ "listener3 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener4.tab,
+ "listener4 was not fired after removal"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ // We can't allow sending to actually happen, this is a test. For every
+ // compose window that opens, replace the function which does the actual
+ // sending with one that only records when it has been called.
+ let didTryToSendMessage = false;
+ let windowListenerRemoved = false;
+ ExtensionSupport.registerWindowListener("mochitest", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow(window) {
+ window.CompleteGenericSendMessage = function (msgType) {
+ didTryToSendMessage = true;
+ Services.obs.notifyObservers(
+ {
+ composeWindow: window,
+ },
+ "mail:composeSendProgressStop"
+ );
+ };
+ },
+ });
+ registerCleanupFunction(() => {
+ if (!windowListenerRemoved) {
+ ExtensionSupport.unregisterWindowListener("mochitest");
+ }
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Now)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkIfSent", async (sendExpected, lockExpected) => {
+ // Wait a moment to see if send happens asynchronously.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ is(didTryToSendMessage, sendExpected, "did try to send a message");
+
+ if (lockExpected !== null) {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ is(composeWindows[0].gWindowLocked, lockExpected, "window is locked");
+ }
+
+ didTryToSendMessage = false;
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ ExtensionSupport.unregisterWindowListener("mochitest");
+ windowListenerRemoved = true;
+});
+
+add_task(async function testChangeDetails() {
+ let files = {
+ "background.js": async () => {
+ function beginSend() {
+ return window.sendMessage("beginSend");
+ }
+
+ function checkWindow(expected) {
+ return window.sendMessage("checkWindow", expected);
+ }
+
+ let accounts = await browser.accounts.list();
+ // If this test is run alone, the order of accounts is different compared
+ // to running all tests. We need the account with the 2 added identities.
+ let account = accounts.find(a => a.identities.length == 2);
+ let [defaultIdentity, nonDefaultIdentity] = account.identities;
+
+ // Add a listener that changes the headers and body. Sending should
+ // continue and the headers should change. This is largely the same code
+ // as tested in browser_ext_compose_details.js, so just test that the
+ // changes happen.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener5 = (tab, details) => {
+ listener5.tab = tab;
+ listener5.details = details;
+ return {
+ details: {
+ identityId: nonDefaultIdentity.id,
+ to: ["to@test5.invalid"],
+ cc: ["cc@test5.invalid"],
+ subject: "Changed by listener5",
+ body: "New body from listener5.",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener5);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener5.tab.id, "listener5 was fired");
+ browser.test.assertEq(defaultIdentity.id, listener5.details.identityId);
+ browser.test.assertEq(1, listener5.details.to.length);
+ browser.test.assertEq(
+ "test@test.invalid",
+ listener5.details.to[0],
+ "listener5 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener5.details.subject,
+ "listener5 subject correct"
+ );
+ browser.compose.onBeforeSend.removeListener(listener5);
+ delete listener5.tab;
+
+ // Do the same thing, but this time with a Promise.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+ [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+
+ [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener6 = (tab, details) => {
+ listener6.tab = tab;
+ listener6.details = details;
+ return new Promise(resolve => {
+ listener6.resolve = resolve;
+ });
+ };
+ browser.compose.onBeforeSend.addListener(listener6);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener6.tab.id, "listener6 was fired");
+ browser.test.assertEq(defaultIdentity.id, listener6.details.identityId);
+ browser.test.assertEq(1, listener6.details.to.length);
+ browser.test.assertEq(
+ "test@test.invalid",
+ listener6.details.to[0],
+ "listener6 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener6.details.subject,
+ "listener6 subject correct"
+ );
+ listener6.resolve({
+ details: {
+ identityId: nonDefaultIdentity.id,
+ to: ["to@test6.invalid"],
+ cc: ["cc@test6.invalid"],
+ subject: "Changed by listener6",
+ body: "New body from listener6.",
+ },
+ });
+ browser.compose.onBeforeSend.removeListener(listener6);
+ delete listener6.tab;
+
+ browser.test.assertTrue(
+ !listener5.tab,
+ "listener5 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener6.tab,
+ "listener6 was not fired after removal"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let body = composeWindow
+ .GetCurrentEditor()
+ .outputToString("text/plain", Ci.nsIDocumentEncoder.OutputRaw);
+ is(body, expected.body);
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ await messagesInOutbox(2);
+
+ let outboxMessages = [...outbox.messages];
+ ok(outboxMessages.length > 0);
+ let sentMessage5 = outboxMessages.shift();
+ is(sentMessage5.author, "nondefault@invalid", "author was changed");
+ is(sentMessage5.subject, "Changed by listener5", "subject was changed");
+ is(sentMessage5.recipients, "to@test5.invalid", "to was changed");
+ is(sentMessage5.ccList, "cc@test5.invalid", "cc was changed");
+
+ await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(sentMessage5, null, (msgHdr, mimeMessage) => {
+ is(
+ // Fold Windows line-endings \r\n to \n.
+ mimeMessage.parts[0].body.replace(/\r/g, ""),
+ "New body from listener5.\n"
+ );
+ resolve();
+ });
+ });
+
+ ok(outboxMessages.length > 0);
+ let sentMessage6 = outboxMessages.shift();
+ is(sentMessage6.author, "nondefault@invalid", "author was changed");
+ is(sentMessage6.subject, "Changed by listener6", "subject was changed");
+ is(sentMessage6.recipients, "to@test6.invalid", "to was changed");
+ is(sentMessage6.ccList, "cc@test6.invalid", "cc was changed");
+
+ await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(sentMessage6, null, (msgHdr, mimeMessage) => {
+ is(
+ // Fold Windows line-endings \r\n to \n.
+ mimeMessage.parts[0].body.replace(/\r/g, ""),
+ "New body from listener6.\n"
+ );
+ resolve();
+ });
+ });
+
+ ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage5, sentMessage6],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function testChangeAttachments() {
+ let files = {
+ "background.js": async () => {
+ // Add a listener that changes attachments. Sending should continue and
+ // the attachments should change.
+
+ let tab = await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ attachments: [
+ { file: new File(["remove"], "remove.txt") },
+ { file: new File(["change"], "change.txt") },
+ ],
+ });
+
+ let listener12 = async (tab, details) => {
+ let attachments = await browser.compose.listAttachments(tab.id);
+ browser.test.assertEq("remove.txt", attachments[0].name);
+ browser.test.assertEq("change.txt", attachments[1].name);
+
+ await browser.compose.removeAttachment(tab.id, attachments[0].id);
+ await browser.compose.updateAttachment(tab.id, attachments[1].id, {
+ name: "changed.txt",
+ });
+ await browser.compose.addAttachment(tab.id, {
+ file: new File(["added"], "added.txt"),
+ });
+
+ attachments = await browser.compose.listAttachments(tab.id);
+ browser.test.assertEq("changed.txt", attachments[0].name);
+ browser.test.assertEq("added.txt", attachments[1].name);
+
+ listener12.tab = tab;
+ };
+ browser.compose.onBeforeSend.addListener(listener12);
+
+ await window.sendMessage("beginSend");
+ browser.test.assertEq(tab.id, listener12.tab.id, "listener12 completed");
+ browser.compose.onBeforeSend.removeListener(listener12);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ let sendPromise = BrowserTestUtils.waitForEvent(
+ composeWindows[0],
+ "aftersend"
+ );
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ await sendPromise;
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ await messagesInOutbox(1);
+
+ let outboxMessages = [...outbox.messages];
+ ok(outboxMessages.length > 0);
+ let sentMessage12 = outboxMessages.shift();
+
+ await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(sentMessage12, null, (msgHdr, mimeMessage) => {
+ Assert.equal(mimeMessage.parts.length, 1);
+ Assert.equal(mimeMessage.parts[0].parts.length, 3);
+ Assert.equal(mimeMessage.parts[0].parts[1].name, "changed.txt");
+ Assert.equal(mimeMessage.parts[0].parts[2].name, "added.txt");
+ resolve();
+ });
+ });
+
+ ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage12],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function testListExpansion() {
+ let files = {
+ "background.js": async () => {
+ function beginSend() {
+ return window.sendMessage("beginSend");
+ }
+
+ function checkWindow(expected) {
+ return window.sendMessage("checkWindow", expected);
+ }
+
+ let addressBook = await browser.addressBooks.create({
+ name: "Baker Street",
+ });
+ let contacts = {
+ sherlock: await browser.contacts.create(addressBook, {
+ DisplayName: "Sherlock Holmes",
+ PrimaryEmail: "sherlock@bakerstreet.invalid",
+ }),
+ john: await browser.contacts.create(addressBook, {
+ DisplayName: "John Watson",
+ PrimaryEmail: "john@bakerstreet.invalid",
+ }),
+ };
+ let list = await browser.mailingLists.create(addressBook, {
+ name: "Holmes and Watson",
+ description: "Tenants221B",
+ });
+ await browser.mailingLists.addMember(list, contacts.sherlock);
+ await browser.mailingLists.addMember(list, contacts.john);
+
+ // Add a listener that changes the headers. Sending should continue and
+ // the headers should change. The mailing list should be expanded in both
+ // the To: and Bcc: headers.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: list, type: "mailingList" }],
+ subject: "Test",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["Holmes and Watson <Tenants221B>"],
+ subject: "Test",
+ });
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener7 = (tab, details) => {
+ listener7.tab = tab;
+ listener7.details = details;
+ return {
+ details: {
+ bcc: details.to,
+ subject: "Changed by listener7",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener7);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener7.tab.id, "listener7 was fired");
+ browser.test.assertEq(1, listener7.details.to.length);
+ browser.test.assertEq(
+ "Holmes and Watson <Tenants221B>",
+ listener7.details.to[0],
+ "listener7 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener7.details.subject,
+ "listener7 subject correct"
+ );
+ browser.compose.onBeforeSend.removeListener(listener7);
+
+ // Return nothing from the listener. The mailing list should be expanded.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: list, type: "mailingList" }],
+ subject: "Test",
+ });
+ [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["Holmes and Watson <Tenants221B>"],
+ subject: "Test",
+ });
+
+ [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener8 = (tab, details) => {
+ listener8.tab = tab;
+ listener8.details = details;
+ };
+ browser.compose.onBeforeSend.addListener(listener8);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener8.tab.id, "listener8 was fired");
+ browser.test.assertEq(1, listener8.details.to.length);
+ browser.test.assertEq(
+ "Holmes and Watson <Tenants221B>",
+ listener8.details.to[0],
+ "listener8 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener8.details.subject,
+ "listener8 subject correct"
+ );
+ browser.compose.onBeforeSend.removeListener(listener8);
+
+ await browser.addressBooks.delete(addressBook);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks", "compose"],
+ },
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ await messagesInOutbox(2);
+
+ let outboxMessages = [...outbox.messages];
+ ok(outboxMessages.length > 0);
+ let sentMessage7 = outboxMessages.shift();
+ is(sentMessage7.subject, "Changed by listener7", "subject was changed");
+ is(
+ sentMessage7.recipients,
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>, John Watson <john@bakerstreet.invalid>",
+ "list in unchanged field was expanded"
+ );
+ is(
+ sentMessage7.bccList,
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>, John Watson <john@bakerstreet.invalid>",
+ "list in changed field was expanded"
+ );
+
+ ok(outboxMessages.length > 0);
+ let sentMessage8 = outboxMessages.shift();
+ is(sentMessage8.subject, "Test", "subject was not changed");
+ is(
+ sentMessage8.recipients,
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>, John Watson <john@bakerstreet.invalid>",
+ "list in unchanged field was expanded"
+ );
+
+ ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage7, sentMessage8],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function testMultipleListeners() {
+ let extensionA = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let listener9 = (tab, details) => {
+ browser.test.log("listener9 was fired");
+ browser.test.sendMessage("listener9", details);
+ browser.compose.onBeforeSend.removeListener(listener9);
+ return {
+ details: {
+ to: ["recipient2@invalid"],
+ subject: "Changed by listener9",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener9);
+
+ await browser.compose.beginNew({
+ to: "recipient1@invalid",
+ subject: "Initial subject",
+ });
+ browser.test.sendMessage("ready");
+ },
+ manifest: { permissions: ["compose"] },
+ });
+
+ let extensionB = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let listener10 = (tab, details) => {
+ browser.test.log("listener10 was fired");
+ browser.test.sendMessage("listener10", details);
+ browser.compose.onBeforeSend.removeListener(listener10);
+ return {
+ details: {
+ to: ["recipient3@invalid"],
+ subject: "Changed by listener10",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener10);
+
+ let listener11 = (tab, details) => {
+ browser.test.log("listener11 was fired");
+ browser.test.sendMessage("listener11", details);
+ browser.compose.onBeforeSend.removeListener(listener11);
+ return {
+ details: {
+ to: ["recipient4@invalid"],
+ subject: "Changed by listener11",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener11);
+ browser.test.sendMessage("ready");
+ },
+ manifest: { permissions: ["compose"] },
+ });
+
+ await extensionA.startup();
+ await extensionB.startup();
+
+ await extensionA.awaitMessage("ready");
+ await extensionB.awaitMessage("ready");
+
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ Assert.equal(composeWindows.length, 1);
+ Assert.equal(composeWindows[0].document.readyState, "complete");
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+
+ let listener9Details = await extensionA.awaitMessage("listener9");
+ Assert.equal(listener9Details.to.length, 1);
+ Assert.equal(
+ listener9Details.to[0],
+ "recipient1@invalid",
+ "listener9 recipient correct"
+ );
+ Assert.equal(
+ listener9Details.subject,
+ "Initial subject",
+ "listener9 subject correct"
+ );
+
+ let listener10Details = await extensionB.awaitMessage("listener10");
+ Assert.equal(listener10Details.to.length, 1);
+ Assert.equal(
+ listener10Details.to[0],
+ "recipient2@invalid",
+ "listener10 recipient correct"
+ );
+ Assert.equal(
+ listener10Details.subject,
+ "Changed by listener9",
+ "listener10 subject correct"
+ );
+
+ let listener11Details = await extensionB.awaitMessage("listener11");
+ Assert.equal(listener11Details.to.length, 1);
+ Assert.equal(
+ listener11Details.to[0],
+ "recipient3@invalid",
+ "listener11 recipient correct"
+ );
+ Assert.equal(
+ listener11Details.subject,
+ "Changed by listener10",
+ "listener11 subject correct"
+ );
+
+ await extensionA.unload();
+ await extensionB.unload();
+
+ await messagesInOutbox(1);
+
+ let outboxMessages = [...outbox.messages];
+ Assert.ok(outboxMessages.length > 0);
+ let sentMessage = outboxMessages.shift();
+ Assert.equal(
+ sentMessage.subject,
+ "Changed by listener11",
+ "subject was changed"
+ );
+ Assert.equal(
+ sentMessage.recipients,
+ "recipient4@invalid",
+ "recipient was changed"
+ );
+
+ Assert.ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function test_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onBeforeSend.addListener((tab, details) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onBeforeSend received", details);
+ }
+
+ // Let us abort, so we do not have to re-open the compose window for
+ // multiple tests.
+ return {
+ cancel: true,
+ };
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onBeforeSend@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onBeforeSend"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ function beginSend() {
+ composeWindow.GenericSendMessage(Ci.nsIMsgCompDeliverMode.Now).catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onBeforeSend without terminating the background first.
+
+ composeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ beginSend();
+ let firstDetails = await extension.awaitMessage("onBeforeSend received");
+ Assert.equal(
+ "first@invalid.net",
+ firstDetails.to,
+ "Returned details should be correct"
+ );
+
+ // Terminate background and re-trigger onBeforeSend.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ composeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ beginSend();
+ let secondDetails = await extension.awaitMessage("onBeforeSend received");
+ Assert.equal(
+ "second@invalid.net",
+ secondDetails.to,
+ "Returned details should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js
new file mode 100644
index 0000000000..7e779e5798
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js
@@ -0,0 +1,416 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+var gServer;
+var gLocalRootFolder;
+let gPopAccount;
+let gLocalAccount;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ gPopAccount = createAccount("pop3");
+ gLocalAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = gPopAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ gPopAccount.addIdentity(identity);
+ gPopAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ gLocalRootFolder = gLocalAccount.incomingServer.rootFolder;
+ gLocalRootFolder.createSubfolder("Sent", null);
+ gLocalRootFolder.createSubfolder("Drafts", null);
+ gLocalRootFolder.createSubfolder("Fcc", null);
+ MailServices.accounts.setSpecialFolders();
+
+ requestLongerTimeout(4);
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ });
+});
+
+// Helper function to test saving messages.
+async function runTest(config) {
+ let files = {
+ "background.js": async () => {
+ let [config] = await window.sendMessage("getConfig");
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let localAccount = accounts.find(a => a.type == "none");
+ let fccFolder = localAccount.folders.find(f => f.name == "Fcc");
+ browser.test.assertTrue(
+ !!fccFolder,
+ "should find the additional fcc folder"
+ );
+
+ // Prepare test data.
+ let allDetails = [];
+ for (let i = 0; i < 5; i++) {
+ allDetails.push({
+ to: [`test${i}@test.invalid`],
+ subject: `Test${i} save as ${config.expected.mode}`,
+ additionalFccFolder:
+ config.expected.fcc.length > 1 ? fccFolder : null,
+ });
+ }
+
+ // Open multiple compose windows.
+ for (let details of allDetails) {
+ details.tab = await browser.compose.beginNew(details);
+ }
+
+ // Add onAfterSave listener
+ let collectedEventsMap = new Map();
+ function onAfterSaveListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSave.addListener(onAfterSaveListener);
+
+ // Initiate saving of all compose windows at the same time.
+ let allPromises = [];
+ for (let details of allDetails) {
+ allPromises.push(
+ browser.compose.saveMessage(details.tab.id, config.mode)
+ );
+ }
+
+ // Wait until all messages have been saved.
+ let allRv = await Promise.all(allPromises);
+
+ for (let i = 0; i < allDetails.length; i++) {
+ let rv = allRv[i];
+ let details = allDetails[i];
+ // Find the message with a matching headerMessageId.
+
+ browser.test.assertEq(
+ config.expected.mode,
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ config.expected.fcc.length,
+ rv.messages.length,
+ "Should find the correct number of saved messages for this save operation."
+ );
+
+ // Check expected FCC folders.
+ for (let i = 0; i < config.expected.fcc.length; i++) {
+ // Read the actual messages in the fcc folder.
+ let savedMessages = await window.sendMessage(
+ "getMessagesInFolder",
+ `${config.expected.fcc[i]}`
+ );
+ // Find the currently processed message.
+ let savedMessage = savedMessages.find(
+ m => m.messageId == rv.messages[i].headerMessageId
+ );
+ // Compare saved message to original message.
+ browser.test.assertEq(
+ details.subject,
+ savedMessage.subject,
+ "The subject of the message in the fcc folder should be correct."
+ );
+
+ // Check returned details.
+ browser.test.assertEq(
+ details.subject,
+ rv.messages[i].subject,
+ "The subject of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ details.to[0],
+ rv.messages[i].recipients[0],
+ "The recipients of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ `/${config.expected.fcc[i]}`,
+ rv.messages[i].folder.path,
+ "The saved message should be in the correct folder."
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(details.tab.id);
+ await removedWindowPromise;
+ }
+
+ // Check onAfterSave listener
+ browser.compose.onAfterSave.removeListener(onAfterSaveListener);
+ browser.test.assertEq(
+ allDetails.length,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSave events"
+ );
+ let collectedEvents = [...collectedEventsMap.values()];
+ for (let detail of allDetails) {
+ let msg = collectedEvents.find(
+ e => e.messages[0].subject == detail.subject
+ );
+ browser.test.assertTrue(
+ msg,
+ "Should have received an onAfterSave event for every single message"
+ );
+ }
+ browser.test.assertEq(
+ collectedEventsMap.size,
+ collectedEvents.filter(e => e.mode == config.expected.mode).length,
+ "All events should have the correct mode."
+ );
+
+ // Remove all saved messages.
+ for (let fcc of config.expected.fcc) {
+ await window.sendMessage("clearMessagesInFolder", fcc);
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.save", "messagesRead", "accountsRead"],
+ },
+ });
+
+ extension.onMessage("getConfig", async () => {
+ extension.sendMessage(config);
+ });
+
+ extension.onMessage("getMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages].map(m => {
+ let { subject, messageId, recipients } = m;
+ return { subject, messageId, recipients };
+ });
+ extension.sendMessage(...messages);
+ });
+
+ extension.onMessage("clearMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages];
+ await new Promise(resolve => {
+ folder.deleteMessages(
+ messages,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+
+ Assert.equal(0, [...folder.messages].length, "folder should be empty");
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ gServer.resetTest();
+}
+
+// Test with default save mode.
+add_task(async function test_default() {
+ await runTest({
+ mode: null,
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts"],
+ },
+ });
+});
+
+// Test with default save mode and additional fcc.
+add_task(async function test_default_with_additional_fcc() {
+ await runTest({
+ mode: null,
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts", "Fcc"],
+ },
+ });
+});
+
+// Test with draft save mode.
+add_task(async function test_saveAsDraft() {
+ await runTest({
+ mode: { mode: "draft" },
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts"],
+ },
+ });
+});
+
+// Test with draft save mode and additional fcc.
+add_task(async function test_saveAsDraft_with_additional_fcc() {
+ await runTest({
+ mode: { mode: "draft" },
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts", "Fcc"],
+ },
+ });
+});
+
+// Test onAfterSave when saving drafts for MV3
+add_task(async function test_onAfterSave_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onAfterSave.addListener((tab, saveInfo) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onAfterSave received", saveInfo);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onAfterSave@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onAfterSave"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onAfterSave without terminating the background first.
+
+ composeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ composeWindow.SaveAsDraft();
+ let firstSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "draft",
+ firstSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // Terminate background and re-trigger onAfterSave.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ composeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ composeWindow.SaveAsDraft();
+ let secondSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "draft",
+ secondSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js
new file mode 100644
index 0000000000..d9ce180011
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js
@@ -0,0 +1,432 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+var gServer;
+var gLocalRootFolder;
+let gPopAccount;
+let gLocalAccount;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ gPopAccount = createAccount("pop3");
+ gLocalAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = gPopAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ gPopAccount.addIdentity(identity);
+ gPopAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ gLocalRootFolder = gLocalAccount.incomingServer.rootFolder;
+ gLocalRootFolder.createSubfolder("Sent", null);
+ gLocalRootFolder.createSubfolder("Templates", null);
+ gLocalRootFolder.createSubfolder("Fcc", null);
+ MailServices.accounts.setSpecialFolders();
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ });
+});
+
+add_task(async function test_no_permission() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let tab = await browser.compose.beginNew(details);
+
+ // Send now. It should fail due to the missing compose.send permission.
+ await browser.test.assertThrows(
+ () => browser.compose.saveMessage(tab.id),
+ /browser.compose.saveMessage is not a function/,
+ "browser.compose.saveMessage() should reject, if the permission compose.save is not granted."
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(tab.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+// Helper function to test saving messages.
+async function runTest(config) {
+ let files = {
+ "background.js": async () => {
+ let [config] = await window.sendMessage("getConfig");
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let localAccount = accounts.find(a => a.type == "none");
+ let fccFolder = localAccount.folders.find(f => f.name == "Fcc");
+ browser.test.assertTrue(
+ !!fccFolder,
+ "should find the additional fcc folder"
+ );
+
+ // Prepare test data.
+ let allDetails = [];
+ for (let i = 0; i < 5; i++) {
+ allDetails.push({
+ to: [`test${i}@test.invalid`],
+ subject: `Test${i} save as ${config.expected.mode}`,
+ additionalFccFolder:
+ config.expected.fcc.length > 1 ? fccFolder : null,
+ });
+ }
+
+ // Open multiple compose windows.
+ for (let details of allDetails) {
+ details.tab = await browser.compose.beginNew(details);
+ }
+
+ // Add onAfterSave listener
+ let collectedEventsMap = new Map();
+ function onAfterSaveListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSave.addListener(onAfterSaveListener);
+
+ // Initiate saving of all compose windows at the same time.
+ let allPromises = [];
+ for (let details of allDetails) {
+ allPromises.push(
+ browser.compose.saveMessage(details.tab.id, config.mode)
+ );
+ }
+
+ // Wait until all messages have been saved.
+ let allRv = await Promise.all(allPromises);
+
+ for (let i = 0; i < allDetails.length; i++) {
+ let rv = allRv[i];
+ let details = allDetails[i];
+ // Find the message with a matching headerMessageId.
+
+ browser.test.assertEq(
+ config.expected.mode,
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ config.expected.fcc.length,
+ rv.messages.length,
+ "Should find the correct number of saved messages for this save operation."
+ );
+
+ // Check expected FCC folders.
+ for (let i = 0; i < config.expected.fcc.length; i++) {
+ // Read the actual messages in the fcc folder.
+ let savedMessages = await window.sendMessage(
+ "getMessagesInFolder",
+ `${config.expected.fcc[i]}`
+ );
+ // Find the currently processed message.
+ let savedMessage = savedMessages.find(
+ m => m.messageId == rv.messages[i].headerMessageId
+ );
+ // Compare saved message to original message.
+ browser.test.assertEq(
+ details.subject,
+ savedMessage.subject,
+ "The subject of the message in the fcc folder should be correct."
+ );
+
+ // Check returned details.
+ browser.test.assertEq(
+ details.subject,
+ rv.messages[i].subject,
+ "The subject of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ details.to[0],
+ rv.messages[i].recipients[0],
+ "The recipients of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ `/${config.expected.fcc[i]}`,
+ rv.messages[i].folder.path,
+ "The saved message should be in the correct folder."
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(details.tab.id);
+ await removedWindowPromise;
+ }
+
+ // Check onAfterSave listener
+ browser.compose.onAfterSave.removeListener(onAfterSaveListener);
+ browser.test.assertEq(
+ allDetails.length,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSave events"
+ );
+ let collectedEvents = [...collectedEventsMap.values()];
+ for (let detail of allDetails) {
+ let msg = collectedEvents.find(
+ e => e.messages[0].subject == detail.subject
+ );
+ browser.test.assertTrue(
+ msg,
+ "Should have received an onAfterSave event for every single message"
+ );
+ }
+ browser.test.assertEq(
+ collectedEventsMap.size,
+ collectedEvents.filter(e => e.mode == config.expected.mode).length,
+ "All events should have the correct mode."
+ );
+
+ // Remove all saved messages.
+ for (let fcc of config.expected.fcc) {
+ await window.sendMessage("clearMessagesInFolder", fcc);
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.save", "messagesRead", "accountsRead"],
+ },
+ });
+
+ extension.onMessage("getConfig", async () => {
+ extension.sendMessage(config);
+ });
+
+ extension.onMessage("getMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages].map(m => {
+ let { subject, messageId, recipients } = m;
+ return { subject, messageId, recipients };
+ });
+ extension.sendMessage(...messages);
+ });
+
+ extension.onMessage("clearMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages];
+ await new Promise(resolve => {
+ folder.deleteMessages(
+ messages,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+
+ Assert.equal(0, [...folder.messages].length, "folder should be empty");
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ gServer.resetTest();
+}
+
+// Test with template save mode.
+add_task(async function test_saveAsTemplate() {
+ await runTest({
+ mode: { mode: "template" },
+ expected: {
+ mode: "template",
+ fcc: ["Templates"],
+ },
+ });
+});
+
+// Test with template save mode and additional fcc
+add_task(async function test_saveAsTemplate_with_additional_fcc() {
+ await runTest({
+ mode: { mode: "template" },
+ expected: {
+ mode: "template",
+ fcc: ["Templates", "Fcc"],
+ },
+ });
+});
+
+// Test onAfterSave when saving templates for MV3
+add_task(async function test_onAfterSave_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onAfterSave.addListener((tab, saveInfo) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onAfterSave received", saveInfo);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onAfterSave@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onAfterSave"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onAfterSave without terminating the background first.
+
+ composeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ composeWindow.SaveAsTemplate();
+ let firstSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "template",
+ firstSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // Terminate background and re-trigger onAfterSave.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ composeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ composeWindow.SaveAsTemplate();
+ let secondSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "template",
+ secondSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js
new file mode 100644
index 0000000000..4fd983e8e5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js
@@ -0,0 +1,733 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+function tracksentMessages(aSubject, aTopic, aMsgID) {
+ // The aMsgID starts with < and ends with > which is not used by the API.
+ let headerMessageId = aMsgID.replace(/^<|>$/g, "");
+ gSentMessages.push(headerMessageId);
+}
+
+var gServer;
+var gOutbox;
+var gSentMessages = [];
+let gPopAccount;
+let gLocalAccount;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ gPopAccount = createAccount("pop3");
+ gLocalAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = gPopAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ gPopAccount.addIdentity(identity);
+ gPopAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ let rootFolder = gLocalAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("Sent", null);
+ MailServices.accounts.setSpecialFolders();
+ gOutbox = rootFolder.getChildNamed("Outbox");
+
+ Services.obs.addObserver(tracksentMessages, "mail:composeSendSucceeded");
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ Services.obs.removeObserver(tracksentMessages, "mail:composeSendSucceeded");
+ });
+});
+
+add_task(async function test_no_permission() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let tab = await browser.compose.beginNew(details);
+
+ // Send now. It should fail due to the missing compose.send permission.
+ await browser.test.assertThrows(
+ () => browser.compose.sendMessage(tab.id),
+ /browser.compose.sendMessage is not a function/,
+ "browser.compose.sendMessage() should reject, if the permission compose.send is not granted."
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(tab.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_fail() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ browser.compose.onBeforeSend.addListener(() => {
+ return { cancel: true };
+ });
+
+ // Add onAfterSend listener
+ let collectedEventsMap = new Map();
+ function onAfterSendListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSend.addListener(onAfterSendListener);
+
+ // Send now. It should fail due to the aborting onBeforeSend listener.
+ await browser.test.assertRejects(
+ browser.compose.sendMessage(tab.id),
+ /Send aborted by an onBeforeSend event/,
+ "browser.compose.sendMessage() should reject, if the message could not be send."
+ );
+
+ // Check onAfterSend listener
+ browser.compose.onAfterSend.removeListener(onAfterSendListener);
+ browser.test.assertEq(
+ 1,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSend events"
+ );
+ browser.test.assertEq(
+ "Send aborted by an onBeforeSend event",
+ collectedEventsMap.get(tab.id).error,
+ "Should have received the correct error"
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_send() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Add onAfterSend listener
+ let collectedEventsMap = new Map();
+ function onAfterSendListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSend.addListener(onAfterSendListener);
+
+ // Send now.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let rv = await browser.compose.sendMessage(tab.id);
+ let [sentMessages] = await window.sendMessage("getSentMessages");
+
+ browser.test.assertEq(
+ 1,
+ sentMessages.length,
+ "Number of total messages sent should be correct."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[0],
+ rv.headerMessageId,
+ "The headerMessageId of last message sent should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[0],
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ // Window should have closed after send.
+ await removedWindowPromise;
+
+ // Check onAfterSend listener
+ browser.compose.onAfterSend.removeListener(onAfterSendListener);
+ browser.test.assertEq(
+ 1,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSend events"
+ );
+ browser.test.assertTrue(
+ collectedEventsMap.has(tab.id),
+ "The received event should belong to the correct tab."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ collectedEventsMap.get(tab.id).mode,
+ "The received event should have the correct mode."
+ );
+ browser.test.assertEq(
+ rv.headerMessageId,
+ collectedEventsMap.get(tab.id).headerMessageId,
+ "The received event should have the correct headerMessageId."
+ );
+ browser.test.assertEq(
+ rv.headerMessageId,
+ collectedEventsMap.get(tab.id).messages[0].headerMessageId,
+ "The message in the received event should have the correct headerMessageId."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_sendDefault() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["sendDefault@test.invalid"],
+ subject: "Test sendDefault",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send via default mode, which should be sendNow.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let rv = await browser.compose.sendMessage(tab.id, { mode: "default" });
+ let [sentMessages] = await window.sendMessage("getSentMessages");
+
+ browser.test.assertEq(
+ 2,
+ sentMessages.length,
+ "Number of total messages sent should be correct."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[1],
+ rv.headerMessageId,
+ "The headerMessageId of last message sent should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[1],
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ // Window should have closed after send.
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ gServer.resetTest();
+});
+
+add_task(async function test_sendNow() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["sendNow@test.invalid"],
+ subject: "Test sendNow",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send via sendNow mode.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let rv = await browser.compose.sendMessage(tab.id, { mode: "sendNow" });
+ let [sentMessages] = await window.sendMessage("getSentMessages");
+
+ browser.test.assertEq(
+ 3,
+ sentMessages.length,
+ "Number of total messages sent should be correct."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[2],
+ rv.headerMessageId,
+ "The headerMessageId of last message sent should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[2],
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ // Window should have closed after send.
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_sendLater() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["sendLater@test.invalid"],
+ subject: "Test sendLater",
+ };
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send Later.
+
+ let rv = await browser.compose.sendMessage(tab.id, { mode: "sendLater" });
+ let [outboxMessage] = await window.sendMessage(
+ "checkMessagesInOutbox",
+ details
+ );
+
+ browser.test.assertEq(
+ "sendLater",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ outboxMessage,
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ await window.sendMessage("clearMessagesInOutbox");
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send", "messagesRead", "accountsRead"],
+ },
+ });
+
+ extension.onMessage("checkMessagesInOutbox", async expected => {
+ // Check if the sendLater request did put the message in the outbox.
+ let outboxMessages = [...gOutbox.messages];
+ Assert.ok(outboxMessages.length == 1);
+ let sentMessage = outboxMessages.shift();
+ Assert.equal(sentMessage.subject, expected.subject, "subject is correct");
+ Assert.equal(sentMessage.recipients, expected.to, "recipient is correct");
+ extension.sendMessage(sentMessage.messageId);
+ });
+
+ extension.onMessage("clearMessagesInOutbox", async () => {
+ let outboxMessages = [...gOutbox.messages];
+ await new Promise(resolve => {
+ gOutbox.deleteMessages(
+ outboxMessages,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+
+ Assert.equal(0, [...gOutbox.messages].length, "outbox should be empty");
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_onComposeStateChanged() {
+ let files = {
+ "background.js": async () => {
+ let numberOfEvents = 0;
+ browser.compose.onComposeStateChanged.addListener(async (tab, state) => {
+ numberOfEvents++;
+ browser.test.log(`State #${numberOfEvents}: ${JSON.stringify(state)}`);
+ switch (numberOfEvents) {
+ case 1:
+ // The fresh created composer has no recipient, send is disabled.
+ browser.test.assertEq(false, state.canSendNow);
+ browser.test.assertEq(false, state.canSendLater);
+ break;
+
+ case 2:
+ // The composer updated its initial details data, send is enabled.
+ browser.test.assertEq(true, state.canSendNow);
+ browser.test.assertEq(true, state.canSendLater);
+ break;
+
+ case 3:
+ // The recipient has been invalidated, send is disabled.
+ browser.test.assertEq(false, state.canSendNow);
+ browser.test.assertEq(false, state.canSendLater);
+ break;
+
+ case 4:
+ // The recipient has been reverted, send is enabled.
+ browser.test.assertEq(true, state.canSendNow);
+ browser.test.assertEq(true, state.canSendLater);
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ break;
+ }
+ });
+
+ // The call to beginNew should create two onComposeStateChanged events,
+ // one after the empty window has been created and one after the initial
+ // details have been set.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ let createdTab = await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test part 1",
+ body: "Original body.",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ // Trigger an onComposeStateChanged event by invalidating the recipient.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ to: ["test"],
+ subject: "Test part 2",
+ });
+
+ // Trigger an onComposeStateChanged event by reverting the recipient.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ to: ["test@test.invalid"],
+ subject: "Test part 3",
+ });
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+// Test onAfterSend for MV3
+add_task(async function test_onAfterSend_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onAfterSend.addListener(async (tab, sendInfo) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onAfterSend received", sendInfo);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onAfterSend@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onAfterSend"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onAfterSend without terminating the background first.
+
+ let firstComposeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(firstComposeWindow);
+ firstComposeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ firstComposeWindow.SetComposeDetails({ subject: "First message" });
+ firstComposeWindow.SendMessage();
+ let firstSaveInfo = await extension.awaitMessage("onAfterSend received");
+ Assert.equal(
+ "sendNow",
+ firstSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // Terminate background and re-trigger onAfterSend.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+ let secondComposeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(secondComposeWindow);
+ secondComposeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ secondComposeWindow.SetComposeDetails({ subject: "Second message" });
+ secondComposeWindow.SendMessage();
+ let secondSaveInfo = await extension.awaitMessage("onAfterSend received");
+ Assert.equal(
+ "sendNow",
+ secondSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js b/comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js
new file mode 100644
index 0000000000..50ae11ffab
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js
@@ -0,0 +1,438 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CONTENT_PAGE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html";
+const UNCHANGED_VALUES = {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: null,
+ textContent: "\n This is text.\n This is a link with text.\n \n\n\n",
+};
+
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
+add_task(async function testInsertRemoveCSS() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ active: true });
+
+ await browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitMessage(); // insertCSS with code
+ await checkContent(tab.browser, { backgroundColor: "rgb(0, 255, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage(); // removeCSS with code
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+ extension.sendMessage();
+
+ await extension.awaitMessage(); // insertCSS with file
+ await checkContent(tab.browser, { backgroundColor: "rgb(0, 128, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished"); // removeCSS with file
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests browser.tabs.insertCSS fails without the host permission. */
+add_task(async function testInsertRemoveCSSNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ active: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: darkred; }",
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ file: "test.css",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests browser.tabs.executeScript. */
+add_task(async function testExecuteScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.tabs.query({ active: true });
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, { file: "test.js" });
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitMessage(); // executeScript with code
+ await checkContent(tab.browser, { foo: "bar" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished"); // executeScript with file
+ await checkContent(tab.browser, {
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests browser.tabs.executeScript fails without the host permission. */
+add_task(async function testExecuteScriptNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.tabs.query({ active: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { file: "test.js" }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ file: "test.js",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests the messenger alias is available. */
+add_task(async function testExecuteScriptAlias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.tabs.query({ active: true });
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.textContent = messenger.runtime.getManifest().applications.gecko.id;`,
+ });
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ applications: { gecko: { id: "content_scripts@mochitest" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkContent(tab.browser, { textContent: "content_scripts@mochitest" });
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/**
+ * Tests browser.contentScripts.register correctly adds CSS and JavaScript to
+ * message composition windows opened after it was called. Also tests calling
+ * `unregister` on the returned object.
+ */
+add_task(async function testRegister() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let registeredScript = await browser.contentScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ matches: ["*://mochi.test/*"],
+ });
+ await window.sendMessage();
+
+ await registeredScript.unregister();
+ await window.sendMessage();
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ // Tab 1: loads before the script is registered.
+ let tab1 = window.openContentTab(CONTENT_PAGE + "?tab1");
+ await awaitBrowserLoaded(tab1.browser, CONTENT_PAGE + "?tab1");
+
+ await extension.startup();
+
+ await extension.awaitMessage(); // register
+ await checkContent(tab1.browser, UNCHANGED_VALUES);
+
+ // Tab 2: loads after the script is registered.
+ let tab2 = window.openContentTab(CONTENT_PAGE + "?tab2");
+ await awaitBrowserLoaded(tab2.browser, CONTENT_PAGE + "?tab2");
+ // Despite the fact we've just waited for the page to load, sometimes the
+ // content script mechanism gets triggered late. Wait a moment.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+ await checkContent(tab2.browser, {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ extension.sendMessage();
+ await extension.awaitMessage(); // unregister
+
+ await checkContent(tab2.browser, {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ // Tab 3: loads after the script is unregistered.
+ let tab3 = window.openContentTab(CONTENT_PAGE + "?tab3");
+ await awaitBrowserLoaded(tab3.browser, CONTENT_PAGE + "?tab3");
+ await checkContent(tab3.browser, UNCHANGED_VALUES);
+
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Tab 2 should have the CSS removed.
+ await checkContent(tab2.browser, {
+ backgroundColor: UNCHANGED_VALUES.backgroundColor,
+ color: UNCHANGED_VALUES.color,
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+/** Tests content_scripts in the manifest with permission work. */
+add_task(async function testManifest() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.css": "body { background-color: lime; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ },
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["test.css"],
+ js: ["test.js"],
+ },
+ ],
+ },
+ });
+
+ // Tab 1: loads before the script is registered.
+ let tab1 = window.openContentTab(CONTENT_PAGE + "?tab1");
+ await awaitBrowserLoaded(tab1.browser, CONTENT_PAGE + "?tab1");
+ // Despite the fact we've just waited for the page to load, sometimes the
+ // content script mechanism gets triggered late. Wait a moment.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ await extension.startup();
+
+ await checkContent(tab1.browser, UNCHANGED_VALUES);
+
+ // Tab 2: loads after the script is registered.
+ let tab2 = window.openContentTab(CONTENT_PAGE + "?tab2");
+ await awaitBrowserLoaded(tab2.browser, CONTENT_PAGE + "?tab2");
+ // Despite the fact we've just waited for the page to load, sometimes the
+ // content script mechanism gets triggered late. Wait a moment.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ await checkContent(tab2.browser, {
+ backgroundColor: "rgb(0, 255, 0)",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await extension.unload();
+
+ // Tab 2 should have the CSS removed.
+ await checkContent(tab2.browser, {
+ backgroundColor: UNCHANGED_VALUES.backgroundColor,
+ textContent: "Hey look, the script ran!",
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+/** Tests content_scripts match patterns in the manifest. */
+add_task(async function testManifestNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ },
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["*://example.org/*"],
+ css: ["test.css"],
+ js: ["test.js"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_content_handler.js b/comm/mail/components/extensions/test/browser/browser_ext_content_handler.js
new file mode 100644
index 0000000000..bfd4b1e787
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_content_handler.js
@@ -0,0 +1,334 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const getCommonFiles = async () => {
+ return {
+ "utils.js": await getUtilsJS(),
+ "common.js": () => {
+ window.CreateTabPromise = class {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ let createListener = tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ resolve(tab);
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ });
+ }
+ async done() {
+ return this.promise;
+ }
+ };
+
+ window.UpdateTabPromise = class {
+ constructor(options) {
+ this.logWindowId = options?.logWindowId;
+ this.promise = new Promise(resolve => {
+ let updateLog = new Map();
+ let updateListener = (tabId, changes, tab) => {
+ let id = this.logWindowId ? tab.windowId : tabId;
+
+ if (changes?.url != "about:blank") {
+ let log = updateLog.get(id) || {};
+
+ if (changes.url) {
+ log.url = changes.url;
+ }
+ // The complete is only valid, if we have seen a url (which was
+ // not "about:blank")
+ if (log.url && changes?.status == "complete") {
+ log.complete = true;
+ }
+
+ updateLog.set(id, log);
+ if (log.url && log.complete) {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(updateLog);
+ }
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ });
+ }
+ async verify(id, url) {
+ // The updatePromise resolves after we have seen the "complete" state
+ // and a url.
+ let updateLog = await this.promise;
+ browser.test.assertEq(
+ 1,
+ updateLog.size,
+ `Should have seen exactly one tab being updated - ${JSON.stringify(
+ Array.from(updateLog)
+ )}`
+ );
+ browser.test.assertTrue(
+ updateLog.has(id),
+ `Updates must belong to the current tab ${id}`
+ );
+ browser.test.assertEq(
+ url,
+ updateLog.get(id).url,
+ "Should have seen the correct url loaded."
+ );
+ }
+ };
+ },
+ "background.js": async () => {
+ // Open a local extension page and click a handler link. They are all
+ // expected to open in a new tab.
+ let testSelectors = ["#link1", "#link2", "#link3", "#link4"];
+ for (let linkSelector of testSelectors) {
+ await window.expectLinkOpenInNewTab(
+ browser.runtime.getURL("test.html"),
+ linkSelector,
+ browser.runtime.getURL("handler.html#ext%2Btest%3Apayload")
+ );
+ }
+ browser.test.notifyPass();
+ },
+ "handler.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>This is an example page</p>
+ </body>
+ </html>`,
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <ul>
+ <li><a id="link1" href="ext+test:payload">extension handler without target</a>
+ <li><a id="link2" href="ext+test:payload" target = "_self">extension handler with _self target</a>
+ <li><a id="link3" href="ext+test:payload" target = "_blank">extension handler with _blank target</a>
+ <li><a id="link4" href="ext+test:payload" target = "_other">extension handler with _other target</a>
+ </ul>
+ </body>
+ </html>`,
+ };
+};
+
+const subtest_clickInBrowser = async (extension, getBrowser) => {
+ async function clickLink(linkSelector, browser) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+ await synthesizeMouseAtCenterAndRetry(linkSelector, {}, browser);
+ }
+
+ await extension.startup();
+
+ let testSelectors = ["#link1", "#link2", "#link3", "#link4"];
+
+ for (let expectedSelector of testSelectors) {
+ // Wait for click on link (new tab)
+ let { linkSelector } = await extension.awaitMessage("click");
+ Assert.equal(
+ expectedSelector,
+ linkSelector,
+ `Test should click on the correct link.`
+ );
+ await clickLink(linkSelector, getBrowser());
+ await extension.sendMessage();
+ }
+
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders.test0.URI);
+});
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "tabFunctions.js": async () => {
+ let openTestTab = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise();
+ let testTab = await browser.tabs.create({ url });
+ await createdTestTab.done();
+ await updatedTestTab.verify(testTab.id, url);
+ return testTab;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkSelector,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkSelector });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.tabs.remove(testTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "common.js", "tabFunctions.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "windowFunctions.js": async () => {
+ let openTestWin = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise({
+ logWindowId: true,
+ });
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await createdTestTab.done();
+ await updatedTestTab.verify(testWindow.id, url);
+ return testWindow;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkSelector,
+ expectedUrl
+ ) => {
+ let testWindow = await openTestWin(testUrl);
+
+ // Click a link in testWindow to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkSelector });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.windows.remove(testWindow.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "windowFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+});
+
+add_task(async function test_mail3pane() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "mail3paneFunctions.js": async () => {
+ let updateTestTab = async url => {
+ let updatedTestTab = new window.UpdateTabPromise();
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await updatedTestTab.verify(mailTabs[0].id, url);
+ return mailTabs[0];
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkSelector,
+ expectedUrl
+ ) => {
+ await updateTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkSelector });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "mail3paneFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js b/comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js
new file mode 100644
index 0000000000..49d9340b3b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js
@@ -0,0 +1,250 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. *
+ */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+const getCommonFiles = async () => {
+ return {
+ "utils.js": await getUtilsJS(),
+ "example.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p id="description">This is text.</p>
+ </body>
+ </html>`,
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p id="description">This is text.</p>
+ <ul>
+ <li><a id="link" href="example.html">link to example page</a>
+ </ul>
+ </body>
+ </html>`,
+ };
+};
+
+const subtest_clickOpenInBrowserContextMenu = async (extension, getBrowser) => {
+ function waitForLoad(browser, expectedUrl) {
+ return awaitBrowserLoaded(browser, url => url.endsWith(expectedUrl));
+ }
+
+ async function testMenuNavItems(description, browser, expected) {
+ let menuId = browser.getAttribute("context");
+ let menu = browser.ownerGlobal.top.document.getElementById(menuId);
+ await rightClickOnContent(menu, "#description", browser);
+ for (let [key, value] of Object.entries(expected)) {
+ Assert.ok(
+ menu.querySelector(key),
+ `[${description}] ${key} menu item should exist`
+ );
+ switch (value) {
+ case "disabled":
+ case "enabled":
+ Assert.ok(
+ menu.querySelector(key).hasAttribute("disabled") ==
+ (value == "disabled"),
+ `[${description}] ${key} menu item should have the correct disabled state`
+ );
+ break;
+ case "hidden":
+ case "shown":
+ Assert.ok(
+ menu.querySelector(key).hidden == (value == "hidden"),
+ `[${description}] ${key} menu item should have the correct hidden state`
+ );
+ break;
+ }
+ }
+ // Wait a moment to make the test not fail.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 125));
+ menu.hidePopup();
+ }
+
+ async function clickLink(browser) {
+ await synthesizeMouseAtCenterAndRetry("#link", {}, browser);
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("contextClick");
+ let browser = getBrowser();
+
+ // Wait till test.html is fully loaded and check the state of the nav items.
+ await waitForLoad(browser, "test.html");
+ await testMenuNavItems("after initial load", browser, {
+ "#browserContext-back": browser.webNavigation.canGoBack
+ ? "enabled"
+ : "disabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "shown",
+ "#browserContext-stop": "hidden",
+ });
+
+ // Click on a link to load example.html and wait till page load has started.
+ // The navigation items should have the stop item shown.
+ let startLoadPromise = BrowserTestUtils.browserStarted(browser);
+ await clickLink(browser);
+ await startLoadPromise;
+ await testMenuNavItems("before link load", browser, {
+ "#browserContext-back": browser.webNavigation.canGoBack
+ ? "enabled"
+ : "disabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "hidden",
+ "#browserContext-stop": "shown",
+ });
+
+ // Wait till example.html is fully loaded and check the state of the nav
+ // items.
+ await waitForLoad(browser, "example.html");
+ await testMenuNavItems("after link load", browser, {
+ "#browserContext-back": "enabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "shown",
+ "#browserContext-stop": "hidden",
+ });
+
+ // Navigate back and wait till the load of test.html has started. The
+ // navigation items should have the stop item shown.
+ startLoadPromise = BrowserTestUtils.browserStarted(browser);
+ browser.webNavigation.goBack();
+ await startLoadPromise;
+ await testMenuNavItems("before navigate back load", browser, {
+ "#browserContext-back": "enabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "hidden",
+ "#browserContext-stop": "shown",
+ });
+
+ // Wait till test.html is fully loaded and check the state of the nav items.
+ await waitForLoad(browser, "test.html");
+ await testMenuNavItems("after navigate back load", browser, {
+ "#browserContext-back": browser.webNavigation.canGoBack
+ ? "enabled"
+ : "disabled",
+ "#browserContext-forward": "enabled",
+ "#browserContext-reload": "shown",
+ "#browserContext-stop": "hidden",
+ });
+
+ await extension.sendMessage();
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_setup(() => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders.test0.URI);
+});
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const url = "test.html";
+ let testTab = await browser.tabs.create({ url });
+ await window.sendMessage("contextClick");
+ await browser.tabs.remove(testTab.id);
+
+ browser.test.notifyPass();
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const url = "test.html";
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await window.sendMessage("contextClick");
+ await browser.windows.remove(testWindow.id);
+
+ browser.test.notifyPass();
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+});
+
+add_task(async function test_mail3pane() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const url = "test.html";
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await window.sendMessage("contextClick");
+
+ browser.test.notifyPass();
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentAbout3Pane.webBrowser
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js
new file mode 100644
index 0000000000..7d0cf00e12
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js
@@ -0,0 +1,898 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account, rootFolder, subFolders;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ rootFolder.createSubfolder("test2", null);
+ subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 10);
+ createMessages(subFolders.test2, 50);
+
+ tabmail.currentTabInfo.folder = rootFolder;
+ tabmail.currentAbout3Pane.displayFolder(subFolders.test1.URI);
+ await ensure_table_view();
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 10);
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+ });
+ await new Promise(resolve => executeSoon(resolve));
+});
+
+add_task(async function test_update() {
+ async function background() {
+ async function checkCurrent(expected) {
+ let [current] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ window.assertDeepEqual(expected, current);
+
+ // Check if getCurrent() returns the same.
+ let current2 = await browser.mailTabs.getCurrent();
+ window.assertDeepEqual(expected, current2);
+ }
+
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+
+ await browser.mailTabs.update({ displayedFolder: folders[0] });
+ let expected = {
+ sortType: "date",
+ sortOrder: "ascending",
+ viewType: "groupedByThread",
+ layout: "standard",
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ displayedFolder: folders[0],
+ };
+ delete expected.displayedFolder.subFolders;
+
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+
+ expected.sortOrder = "descending";
+ for (let value of ["date", "subject", "author"]) {
+ await browser.mailTabs.update({
+ sortType: value,
+ sortOrder: "descending",
+ });
+ expected.sortType = value;
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+ expected.sortOrder = "ascending";
+ for (let value of ["author", "subject", "date"]) {
+ await browser.mailTabs.update({
+ sortType: value,
+ sortOrder: "ascending",
+ });
+ expected.sortType = value;
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+
+ for (let key of ["folderPaneVisible", "messagePaneVisible"]) {
+ for (let value of [false, true]) {
+ await browser.mailTabs.update({ [key]: value });
+ expected[key] = value;
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+ }
+ for (let value of ["wide", "vertical", "standard"]) {
+ await browser.mailTabs.update({ layout: value });
+ expected.layout = value;
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+
+ // Test all possible switch combination.
+ for (let viewType of [
+ "ungrouped",
+ "groupedByThread",
+ "ungrouped",
+ "groupedBySortType",
+ "groupedByThread",
+ "groupedBySortType",
+ "ungrouped",
+ ]) {
+ await browser.mailTabs.update({ viewType });
+ expected.viewType = viewType;
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+
+ let selectedMessages = await browser.mailTabs.getSelectedMessages();
+ browser.test.assertEq(null, selectedMessages.id);
+ browser.test.assertEq(0, selectedMessages.messages.length);
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkRealLayout", async expected => {
+ let intValue = ["standard", "wide", "vertical"].indexOf(expected.layout);
+ is(Services.prefs.getIntPref("mail.pane_config.dynamic"), intValue);
+ await check3PaneState(
+ expected.folderPaneVisible,
+ expected.messagePaneVisible
+ );
+ Assert.equal(
+ "/" + (tabmail.currentTabInfo.folder.URI || "").split("/").pop(),
+ expected.displayedFolder.path,
+ "Should display the correct folder"
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkRealSort", expected => {
+ const sortTypes = {
+ date: Ci.nsMsgViewSortType.byDate,
+ subject: Ci.nsMsgViewSortType.bySubject,
+ author: Ci.nsMsgViewSortType.byAuthor,
+ };
+
+ let { primarySortType, primarySortOrder } =
+ tabmail.currentAbout3Pane.gViewWrapper;
+
+ Assert.equal(
+ primarySortOrder,
+ Ci.nsMsgViewSortOrder[expected.sortOrder],
+ `sort order should be ${expected.sortOrder}`
+ );
+ Assert.equal(
+ primarySortType,
+ sortTypes[expected.sortType],
+ `sort type should be ${expected.sortType}`
+ );
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkRealView", expected => {
+ const viewTypes = {
+ groupedBySortType: {
+ showGroupedBySort: true,
+ showThreaded: false,
+ showUnthreaded: false,
+ },
+ groupedByThread: {
+ showGroupedBySort: false,
+ showThreaded: true,
+ showUnthreaded: false,
+ },
+ ungrouped: {
+ showGroupedBySort: false,
+ showThreaded: false,
+ showUnthreaded: true,
+ },
+ };
+
+ let { showThreaded, showUnthreaded, showGroupedBySort } =
+ tabmail.currentAbout3Pane.gViewWrapper;
+
+ Assert.equal(
+ showThreaded,
+ viewTypes[expected.viewType].showThreaded,
+ `Correct value for showThreaded for viewType <${expected.viewType}>`
+ );
+ Assert.equal(
+ showUnthreaded,
+ viewTypes[expected.viewType].showUnthreaded,
+ `Correct value for showUnthreaded for viewType <${expected.viewType}>`
+ );
+ Assert.equal(
+ showGroupedBySort,
+ viewTypes[expected.viewType].showGroupedBySort,
+ `Correct value for showGroupedBySort for viewType <${expected.viewType}>`
+ );
+ extension.sendMessage();
+ });
+
+ await check3PaneState(true, true);
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_displayedFolderChanged() {
+ async function background() {
+ let [accountId] = await window.waitForMessage();
+
+ let [current] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(accountId, current.displayedFolder.accountId);
+ browser.test.assertEq("/", current.displayedFolder.path);
+
+ async function selectFolder(newFolderPath) {
+ let changeListener = window.waitForEvent(
+ "mailTabs.onDisplayedFolderChanged"
+ );
+ browser.test.sendMessage("selectFolder", newFolderPath);
+ let [tab, folder] = await changeListener;
+ browser.test.assertEq(current.id, tab.id);
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq(newFolderPath, folder.path);
+ }
+ await selectFolder("/test1");
+ await selectFolder("/test2");
+ await selectFolder("/");
+
+ async function selectFolderByUpdate(newFolderPath) {
+ let changeListener = window.waitForEvent(
+ "mailTabs.onDisplayedFolderChanged"
+ );
+ browser.mailTabs.update({
+ displayedFolder: { accountId, path: newFolderPath },
+ });
+ let [tab, folder] = await changeListener;
+ browser.test.assertEq(current.id, tab.id);
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq(newFolderPath, folder.path);
+ }
+ await selectFolderByUpdate("/test1");
+ await selectFolderByUpdate("/test2");
+ await selectFolderByUpdate("/");
+ await selectFolderByUpdate("/test1");
+
+ await new Promise(resolve => setTimeout(resolve));
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let folderMap = new Map([
+ ["/", rootFolder],
+ ["/test1", subFolders.test1],
+ ["/test2", subFolders.test2],
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("selectFolder", async newFolderPath => {
+ tabmail.currentTabInfo.folder = folderMap.get(newFolderPath);
+ await new Promise(resolve => executeSoon(resolve));
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_selectedMessagesChanged() {
+ async function background() {
+ function checkMessageList(expectedId, expectedCount, actual) {
+ if (expectedId) {
+ browser.test.assertEq(36, actual.id.length);
+ } else {
+ browser.test.assertEq(null, actual.id);
+ }
+ browser.test.assertEq(expectedCount, actual.messages.length);
+ }
+
+ // Because of bad design, we must wait for the WebExtensions mechanism to load ext-mailTabs.js,
+ // or when we call addListener below, it won't happen before the event is fired.
+ // This only applies if none of the earlier tests are run, but I'm saving you from wasting
+ // time figuring out what's going on like I did.
+ await browser.mailTabs.query({});
+
+ async function selectMessages(...newMessages) {
+ let selectPromise = window.waitForEvent(
+ "mailTabs.onSelectedMessagesChanged"
+ );
+ browser.test.sendMessage("selectMessage", newMessages);
+ let [, messageList] = await selectPromise;
+ return messageList;
+ }
+
+ let messageList;
+ messageList = await selectMessages(3);
+ checkMessageList(false, 1, messageList);
+ messageList = await selectMessages(7);
+ checkMessageList(false, 1, messageList);
+ messageList = await selectMessages(4, 6);
+ checkMessageList(false, 2, messageList);
+ messageList = await selectMessages();
+ checkMessageList(false, 0, messageList);
+ messageList = await selectMessages(
+ 2,
+ 3,
+ 5,
+ 7,
+ 11,
+ 13,
+ 17,
+ 19,
+ 23,
+ 29,
+ 31,
+ 37
+ );
+ checkMessageList(true, 10, messageList);
+ messageList = await browser.messages.continueList(messageList.id);
+ checkMessageList(false, 2, messageList);
+ messageList = await browser.mailTabs.getSelectedMessages();
+ checkMessageList(true, 10, messageList);
+ messageList = await browser.messages.continueList(messageList.id);
+ checkMessageList(false, 2, messageList);
+
+ await new Promise(resolve => setTimeout(resolve));
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ tabmail.currentTabInfo.folder = subFolders.test2;
+ tabmail.currentTabInfo.messagePaneVisible = true;
+
+ extension.onMessage("selectMessage", newMessages => {
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = newMessages;
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_background_tab() {
+ async function background() {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let allTabs = await browser.tabs.query({});
+ let queryTabs = await browser.tabs.query({ mailTab: true });
+ let allMailTabs = await browser.mailTabs.query({});
+
+ browser.test.assertEq(4, allTabs.length);
+ browser.test.assertEq(2, queryTabs.length);
+ browser.test.assertEq(2, allMailTabs.length);
+
+ browser.test.assertEq(accountId, allMailTabs[0].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[0].displayedFolder.path);
+
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertTrue(allMailTabs[1].active);
+
+ // Check the initial state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ await browser.mailTabs.update(allMailTabs[0].id, {
+ folderPaneVisible: false,
+ messagePaneVisible: false,
+ displayedFolder: folders.find(f => f.name == "test2"),
+ });
+
+ // Should be in the same state, since we're updating a background tab.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ allMailTabs = await browser.mailTabs.query({});
+ browser.test.assertEq(2, allMailTabs.length);
+
+ browser.test.assertEq(accountId, allMailTabs[0].displayedFolder.accountId);
+ browser.test.assertEq("/test2", allMailTabs[0].displayedFolder.path);
+
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertTrue(allMailTabs[1].active);
+
+ // Switch to the other mail tab.
+ await browser.tabs.update(allMailTabs[0].id, { active: true });
+
+ // Should have changed to the updated state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: false,
+ folderPaneVisible: false,
+ displayedFolder: "/test2",
+ });
+
+ await browser.mailTabs.update(allMailTabs[0].id, {
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test2",
+ });
+
+ // Switch back to the first mail tab.
+ await browser.tabs.update(allMailTabs[1].id, { active: true });
+
+ // Should be in the same state it was in.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+
+ extension.onMessage("checkRealLayout", async expected => {
+ await check3PaneState(
+ expected.folderPaneVisible,
+ expected.messagePaneVisible
+ );
+ Assert.equal(
+ "/" + (tabmail.currentTabInfo.folder.URI || "").split("/").pop(),
+ expected.displayedFolder,
+ "Should display the correct folder"
+ );
+ extension.sendMessage();
+ });
+
+ window.openContentTab("about:buildconfig");
+ window.openContentTab("about:mozilla");
+ tabmail.openTab("mail3PaneTab", { folderURI: subFolders.test1.URI });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test1.URI
+ );
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.closeOtherTabs(0);
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_get_and_query() {
+ async function background() {
+ async function checkTab(expected) {
+ // Check mailTabs.get().
+ let mailTab = await browser.mailTabs.get(expected.tab.id);
+ browser.test.assertEq(expected.tab.id, mailTab.id);
+
+ // Check if a query for all tabs in the same window included the expected tab.
+ let mailTabs = await browser.mailTabs.query({
+ windowId: expected.tab.windowId,
+ });
+ let filteredMailTabs = mailTabs.filter(e => e.id == expected.tab.id);
+ browser.test.assertEq(1, filteredMailTabs.length);
+
+ // Check if a query for the current tab in the given window returns the current tab.
+ if (expected.isCurrentTab) {
+ let currentTabs = await browser.mailTabs.query({
+ active: true,
+ windowId: expected.tab.windowId,
+ });
+ browser.test.assertEq(1, currentTabs.length);
+ browser.test.assertEq(expected.tab.id, currentTabs[0].id);
+ }
+
+ // Check if a query for all tabs in the currentWindow includes the expected tab.
+ if (expected.isCurrentWindow) {
+ let mailTabsCurrentWindow = await browser.mailTabs.query({
+ currentWindow: true,
+ });
+ let filteredMailTabsCurrentWindow = mailTabsCurrentWindow.filter(
+ e => e.id == expected.tab.id
+ );
+ browser.test.assertEq(1, filteredMailTabsCurrentWindow.length);
+ }
+
+ // Check mailTabs.getCurrent() and mailTabs.query({ active: true, currentWindow: true })
+ if (expected.isCurrentTab && expected.isCurrentWindow) {
+ let currentTab = await browser.mailTabs.getCurrent();
+ browser.test.assertEq(expected.tab.id, currentTab.id);
+
+ let currentTabs = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(1, currentTabs.length);
+ browser.test.assertEq(expected.tab.id, currentTabs[0].id);
+ }
+ }
+
+ let [accountId] = await window.waitForMessage();
+ let allTabs = await browser.tabs.query({});
+ let queryMailTabs = await browser.tabs.query({ mailTab: true });
+ let allMailTabs = await browser.mailTabs.query({});
+
+ browser.test.assertEq(8, allTabs.length);
+ browser.test.assertEq(6, queryMailTabs.length);
+ browser.test.assertEq(6, allMailTabs.length);
+
+ // Each window has an active tab.
+ browser.test.assertTrue(allMailTabs[2].active);
+ browser.test.assertTrue(allMailTabs[5].active);
+
+ // Check tabs of window #1.
+ browser.test.assertEq(accountId, allMailTabs[0].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[0].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[2].displayedFolder.accountId);
+ browser.test.assertEq("/test2", allMailTabs[2].displayedFolder.path);
+ // Check tabs of window #2 (active).
+ browser.test.assertEq(accountId, allMailTabs[3].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[3].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[4].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[4].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[5].displayedFolder.accountId);
+ browser.test.assertEq("/test2", allMailTabs[5].displayedFolder.path);
+
+ for (let mailTab of allMailTabs) {
+ await checkTab({
+ tab: mailTab,
+ isCurrentTab: [allMailTabs[2].id, allMailTabs[5].id].includes(
+ mailTab.id
+ ),
+ isCurrentWindow: mailTab.windowId == allMailTabs[5].windowId,
+ });
+ }
+
+ // get(id) should throw if id does not belong to a mail tab.
+ for (let tab of [allTabs[1], allTabs[5]]) {
+ await browser.test.assertRejects(
+ browser.mailTabs.get(tab.id),
+ `Invalid mail tab ID: ${tab.id}`,
+ "It rejects for invalid mail tab ID."
+ );
+ }
+
+ // Switch to the second mail tab in both windows.
+ for (let tab of [allMailTabs[1], allMailTabs[4]]) {
+ await browser.tabs.update(tab.id, { active: true });
+ // Check if the new active tab is returned.
+ await checkTab({
+ tab,
+ isCurrentTab: true,
+ isCurrentWindow: tab.id == allMailTabs[5].id,
+ });
+ }
+
+ // Switch active window to a non-mailtab, getCurrent() and a query for active tab should not return anything.
+ await browser.tabs.update(allTabs[5].id, { active: true });
+ let activeMailTab = await browser.mailTabs.getCurrent();
+ browser.test.assertEq(undefined, activeMailTab);
+ let activeMailTabs = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(0, activeMailTabs.length);
+
+ // A query over all windows should still return the active tab from the inactive window.
+ activeMailTabs = await browser.mailTabs.query({
+ active: true,
+ });
+ browser.test.assertEq(1, activeMailTabs.length);
+ browser.test.assertEq(allMailTabs[1].id, activeMailTabs[0].id);
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+
+ let window2 = await openNewMailWindow();
+ for (let win of [window, window2]) {
+ let winTabmail = win.document.getElementById("tabmail");
+ winTabmail.currentTabInfo.folder = rootFolder;
+ win.openContentTab("about:mozilla");
+ winTabmail.openTab("mail3PaneTab", { folderURI: subFolders.test1.URI });
+ await BrowserTestUtils.waitForEvent(
+ winTabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test1.URI
+ );
+ winTabmail.openTab("mail3PaneTab", { folderURI: subFolders.test2.URI });
+ await BrowserTestUtils.waitForEvent(
+ winTabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test2.URI
+ );
+ }
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(window2);
+
+ tabmail.closeOtherTabs(0);
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_setSelectedMessages() {
+ async function background() {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let allTabs = await browser.tabs.query({});
+ let queryTabs = await browser.tabs.query({ mailTab: true });
+ let allMailTabs = await browser.mailTabs.query({});
+
+ let { messages: messages1 } = await browser.messages.list(
+ folders.find(f => f.path == "/test1")
+ );
+ browser.test.assertTrue(
+ messages1.length > 7,
+ "There should be more than 7 messages in /test1"
+ );
+
+ let { messages: messages2 } = await browser.messages.list(
+ folders.find(f => f.path == "/test2")
+ );
+ browser.test.assertTrue(
+ messages2.length > 4,
+ "There should be more than 4 messages in /test2"
+ );
+
+ browser.test.assertEq(3, allMailTabs.length);
+ browser.test.assertEq(5, allTabs.length);
+ browser.test.assertEq(3, queryTabs.length);
+
+ let foregroundTab = allMailTabs[1].id;
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertTrue(allMailTabs[1].active);
+
+ let backgroundTab = allMailTabs[2].id;
+ browser.test.assertEq(accountId, allMailTabs[2].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[2].displayedFolder.path);
+
+ // Check the initial real state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ // Change the selection in the foreground tab.
+ await browser.mailTabs.setSelectedMessages(foregroundTab, [
+ messages1[6].id,
+ messages1[7].id,
+ ]);
+ // Check the current real state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+ // Check API return value of the foreground tab.
+ let { messages: readMessagesA } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages1[6].id, messages1[7].id],
+ readMessagesA.map(m => m.id)
+ );
+
+ // Change the selection in the background tab.
+ await browser.mailTabs.setSelectedMessages(backgroundTab, [
+ messages2[0].id,
+ messages2[3].id,
+ ]);
+ // Real state should be the same, since we're updating a background tab.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+ // Check unchanged API return value of the foreground tab.
+ let { messages: readMessagesB } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages1[6].id, messages1[7].id],
+ readMessagesB.map(m => m.id)
+ );
+ // Check API return value of the inactive background tab.
+ let { messages: readMessagesC } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ window.assertDeepEqual(
+ [messages2[0].id, messages2[3].id],
+ readMessagesC.map(m => m.id)
+ );
+ // Switch to the background tab.
+ await browser.tabs.update(backgroundTab, { active: true });
+ // Check API return value of the background tab (now active).
+ let { messages: readMessagesD } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ window.assertDeepEqual(
+ [messages2[0].id, messages2[3].id],
+ readMessagesD.map(m => m.id)
+ );
+ // Check real state, should now match the active background tab.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test2",
+ });
+ // Check unchanged API return value of the foreground tab (now inactive).
+ let { messages: readMessagesE } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages1[6].id, messages1[7].id],
+ readMessagesE.map(m => m.id)
+ );
+ // Switch back to the foreground tab.
+ await browser.tabs.update(foregroundTab, { active: true });
+
+ // Change the selection in the foreground tab.
+ await browser.mailTabs.setSelectedMessages(foregroundTab, [
+ messages2[2].id,
+ messages2[4].id,
+ ]);
+ // Check API return value of the foreground tab.
+ let { messages: readMessagesF } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages2[2].id, messages2[4].id],
+ readMessagesF.map(m => m.id)
+ );
+ // Check real state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test2",
+ });
+ // Check API return value of the inactive background tab.
+ let { messages: readMessagesG } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ window.assertDeepEqual(
+ [messages2[0].id, messages2[3].id],
+ readMessagesG.map(m => m.id)
+ );
+
+ // Clear selection in background tab.
+ await browser.mailTabs.setSelectedMessages(backgroundTab, []);
+ // Check API return value of the inactive background tab.
+ let { messages: readMessagesH } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ browser.test.assertEq(0, readMessagesH.length);
+
+ // Clear selection in foreground tab.
+ await browser.mailTabs.setSelectedMessages(foregroundTab, []);
+ // Check API return value of the foreground tab.
+ let { messages: readMessagesI } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ browser.test.assertEq(0, readMessagesI.length);
+
+ // Should throw if messages belong to different folders.
+ await browser.test.assertRejects(
+ browser.mailTabs.setSelectedMessages(foregroundTab, [
+ messages2[2].id,
+ messages1[4].id,
+ ]),
+ `Message ${messages2[2].id} and message ${messages1[4].id} are not in the same folder, cannot select them both.`,
+ "browser.mailTabs.setSelectedMessages() should reject, if the requested message do not belong to the same folder."
+ );
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkRealLayout", async expected => {
+ await check3PaneState(
+ expected.folderPaneVisible,
+ expected.messagePaneVisible
+ );
+ Assert.equal(
+ "/" + (tabmail.currentTabInfo.folder.URI || "").split("/").pop(),
+ expected.displayedFolder,
+ "Should display the correct folder"
+ );
+ extension.sendMessage();
+ });
+
+ window.openContentTab("about:buildconfig");
+ window.openContentTab("about:mozilla");
+ tabmail.openTab("mail3PaneTab", { folderURI: subFolders.test1.URI });
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: true,
+ });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test1.URI
+ );
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.closeOtherTabs(0);
+ tabmail.currentTabInfo.folder = rootFolder;
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js
new file mode 100644
index 0000000000..5cacd6e771
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account, rootFolder, subFolders;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ rootFolder.createSubfolder("test2", null);
+ subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 10);
+ createMessages(subFolders.test2, 50);
+
+ tabmail.currentTabInfo.folder = rootFolder;
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 10);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+ });
+ await new Promise(resolve => executeSoon(resolve));
+});
+
+add_task(async function test_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ for (let eventName of [
+ "onDisplayedFolderChanged",
+ "onSelectedMessagesChanged",
+ ]) {
+ browser.mailTabs[eventName].addListener((...args) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "mailtabs@mochi.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "mailTabs.onDisplayedFolderChanged",
+ "mailTabs.onSelectedMessagesChanged",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Select a folder.
+
+ {
+ tabmail.currentTabInfo.folder = subFolders.test1;
+ let displayInfo = await extension.awaitMessage(
+ "onDisplayedFolderChanged received"
+ );
+ Assert.deepEqual(
+ [
+ {
+ active: true,
+ type: "mail",
+ },
+ { name: "test1", path: "/test1" },
+ ],
+ [
+ {
+ active: displayInfo[0].active,
+ type: displayInfo[0].type,
+ },
+ { name: displayInfo[1].name, path: displayInfo[1].path },
+ ],
+ "The primed onDisplayedFolderChanged event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+ }
+
+ // Select multiple messages.
+
+ {
+ let messages = [...subFolders.test1.messages].slice(0, 5);
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = messages.map(m =>
+ tabmail.currentAbout3Pane.gDBView.findIndexOfMsgHdr(m, false)
+ );
+ let displayInfo = await extension.awaitMessage(
+ "onSelectedMessagesChanged received"
+ );
+ Assert.deepEqual(
+ [
+ "Big Meeting Today",
+ "Small Party Tomorrow",
+ "Huge Shindig Yesterday",
+ "Tiny Wedding In a Fortnight",
+ "Red Document Needs Attention",
+ ],
+ displayInfo[1].messages.map(e => e.subject),
+ "The primed onSelectedMessagesChanged event should return the correct values"
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "mail",
+ },
+ {
+ active: displayInfo[0].active,
+ type: displayInfo[0].type,
+ },
+ "The primed onSelectedMessagesChanged event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js
new file mode 100644
index 0000000000..03e255e5eb
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js
@@ -0,0 +1,424 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+
+ await enforceState({
+ mail: ["write-message", "spacer", "search-bar", "spacer"],
+ });
+ registerCleanupFunction(async () => {
+ await enforceState({});
+ });
+});
+
+async function subtest_action_menu(
+ testWindow,
+ target,
+ expectedInfo,
+ expectedTab,
+ manifest
+) {
+ function checkVisibility(menu, visible) {
+ let removeExtension = menu.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let manageExtension = menu.querySelector(
+ ".customize-context-manageExtension"
+ );
+
+ info(`Check visibility: ${visible}`);
+ is(!removeExtension.hidden, visible, "Remove Extension should be visible");
+ is(!manageExtension.hidden, visible, "Manage Extension should be visible");
+ }
+
+ async function testContextMenuRemoveExtension(extension, menu, element) {
+ let name = "Generated extension";
+ let brand = Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShorterName");
+
+ info(
+ `Choosing 'Remove Extension' in ${menu.id} should show confirm dialog.`
+ );
+ await rightClick(menu, element);
+ await extension.awaitMessage("onShown");
+ let removeExtension = menu.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let promptPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ undefined,
+ {
+ async callback(promptWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == promptWindow,
+ "waiting for prompt to become active"
+ );
+
+ let promptDocument = promptWindow.document;
+ // Check if the correct add-on is being removed.
+ is(promptDocument.title, `Remove ${name}?`);
+ if (
+ !Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)
+ ) {
+ is(
+ promptDocument.getElementById("infoBody").textContent,
+ `Remove ${name} as well as its configuration and data from ${brand}?`
+ );
+ }
+ let acceptButton = promptDocument
+ .querySelector("dialog")
+ .getButton("accept");
+ is(acceptButton.label, "Remove");
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, promptWindow);
+ },
+ }
+ );
+ menu.activateItem(removeExtension);
+ await hiddenPromise;
+ await promptPromise;
+ }
+
+ async function testContextMenuManageExtension(extension, menu, element) {
+ let id = "menus@mochi.test";
+ let tabmail = window.document.getElementById("tabmail");
+
+ info(
+ `Choosing 'Manage Extension' in ${menu.id} should load the management page.`
+ );
+ await rightClick(menu, element);
+ await extension.awaitMessage("onShown");
+ let manageExtension = menu.querySelector(
+ ".customize-context-manageExtension"
+ );
+ let addonManagerPromise = contentTabOpenPromise(tabmail, "about:addons");
+ menu.activateItem(manageExtension);
+ let managerTab = await addonManagerPromise;
+
+ // Check the UI to make sure that the correct view is loaded.
+ let managerWindow = managerTab.linkedBrowser.contentWindow;
+ is(
+ managerWindow.gViewController.currentViewId,
+ `addons://detail/${encodeURIComponent(id)}`,
+ "Expected extension details view in about:addons"
+ );
+ // In HTML about:addons, the default view does not show the inline
+ // options browser, so we should not receive an "options-loaded" event.
+ // (if we do, the test will fail due to the unexpected message).
+
+ is(managerTab.linkedBrowser.currentURI.spec, "about:addons");
+ tabmail.closeTab(managerTab);
+ }
+
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let element = testWindow.document.querySelector(target.elementSelector);
+ let menu = testWindow.document.getElementById(target.menuId);
+
+ await rightClick(menu, element);
+ await checkVisibility(menu, true);
+ await checkShownEvent(
+ extension,
+ { menuIds: [target.context], contexts: [target.context, "all"] },
+ expectedTab
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(extension, expectedInfo, expectedTab);
+ menu.activateItem(
+ menu.querySelector(`#menus_mochi_test-menuitem-_${target.context}`)
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Test the non actionButton element for visibility of the management menu entries.
+ if (target.nonActionButtonSelector) {
+ let nonActionButtonElement = testWindow.document.querySelector(
+ target.nonActionButtonSelector
+ );
+ await rightClick(menu, nonActionButtonElement);
+ await checkVisibility(menu, false);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ await testContextMenuManageExtension(extension, menu, element);
+ await testContextMenuRemoveExtension(extension, menu, element);
+ await extension.unload();
+}
+add_task(async function test_browser_action_menu_mv2() {
+ await subtest_action_menu(
+ window,
+ {
+ menuId: "unifiedToolbarMenu",
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "browser_action",
+ nonActionButtonSelector: `.unified-toolbar .write-message button`,
+ },
+ {
+ menuItemId: "browser_action",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ browser_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_pane_mv2() {
+ let tab = await openMessageInTab(gMessage);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_window_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ nonActionButtonSelector: "#button-attach",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_formattoolbar_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "format-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+
+add_task(async function test_browser_action_menu_mv3() {
+ await subtest_action_menu(
+ window,
+ {
+ menuId: "unifiedToolbarMenu",
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "action",
+ nonActionButtonSelector: `.unified-toolbar .write-message button`,
+ },
+ {
+ menuItemId: "action",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_pane_mv3() {
+ let tab = await openMessageInTab(gMessage);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_window_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ nonActionButtonSelector: "#button-attach",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_formattoolbar_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "format-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js
new file mode 100644
index 0000000000..6768f5dd60
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js
@@ -0,0 +1,179 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_compose(manifest) {
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.composeFields.body = await fetch(`${URL_BASE}/content_body.html`).then(
+ r => r.text()
+ );
+
+ for (let ordinal of ["first", "second", "third", "fourth"]) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.name = `${ordinal}.txt`;
+ attachment.url = `data:text/plain,I'm the ${ordinal} attachment!`;
+ attachment.size = attachment.url.length - 16;
+ params.composeFields.addAttachment(attachment);
+ }
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ let composeDocument = composeWindow.document;
+ await focusWindow(composeWindow);
+
+ info("Test the message being composed.");
+
+ let messagePane = composeWindow.GetCurrentEditorElement();
+
+ await subtest_compose_body(
+ extension,
+ manifest.permissions?.includes("compose"),
+ messagePane,
+ "about:blank?compose",
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ const chromeElementsMap = {
+ msgSubject: "composeSubject",
+ toAddrInput: "composeTo",
+ };
+ for (let elementId of Object.keys(chromeElementsMap)) {
+ info(`Test element ${elementId}.`);
+ await subtest_element(
+ extension,
+ manifest.permissions?.includes("compose"),
+ composeWindow.document.getElementById(elementId),
+ "about:blank?compose",
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ fieldId: chromeElementsMap[elementId],
+ }
+ );
+ }
+
+ info("Test the attachments context menu.");
+
+ composeWindow.toggleAttachmentPane("show");
+ let menu = composeDocument.getElementById("msgComposeAttachmentItemContext");
+ let attachmentBucket = composeDocument.getElementById("attachmentBucket");
+
+ EventUtils.synthesizeMouseAtCenter(
+ attachmentBucket.itemChildren[0],
+ {},
+ composeWindow
+ );
+ await rightClick(menu, attachmentBucket.itemChildren[0], composeWindow);
+ Assert.ok(
+ menu.querySelector("#menus_mochi_test-menuitem-_compose_attachments")
+ );
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["compose_attachments"],
+ contexts: ["compose_attachments", "all"],
+ attachments: manifest.permissions?.includes("compose")
+ ? [{ name: "first.txt", size: 25 }]
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: false }
+ );
+
+ attachmentBucket.addItemToSelection(attachmentBucket.itemChildren[3]);
+ await rightClick(menu, attachmentBucket.itemChildren[0], composeWindow);
+ Assert.ok(
+ menu.querySelector("#menus_mochi_test-menuitem-_compose_attachments")
+ );
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["compose_attachments"],
+ contexts: ["compose_attachments", "all"],
+ attachments: manifest.permissions?.includes("compose")
+ ? [
+ { name: "first.txt", size: 25 },
+ { name: "fourth.txt", size: 26 },
+ ]
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: false }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(composeWindow);
+}
+add_task(async function test_compose_mv2() {
+ return subtest_compose({
+ manifest_version: 2,
+ permissions: ["compose"],
+ });
+});
+add_task(async function test_compose_no_permissions_mv2() {
+ return subtest_compose({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_compose_mv3() {
+ return subtest_compose({
+ manifest_version: 3,
+ permissions: ["compose"],
+ });
+});
+add_task(async function test_compose_no_permissions_mv3() {
+ return subtest_compose({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js
new file mode 100644
index 0000000000..27aac6d5d7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js
@@ -0,0 +1,253 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+ await ensure_table_view();
+});
+
+add_task(async function test_content_mv2() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: gFolders[0].URI,
+ });
+
+ let oldPref = Services.prefs.getStringPref("mailnews.start_page.url");
+ Services.prefs.setStringPref(
+ "mailnews.start_page.url",
+ `${URL_BASE}/content.html`
+ );
+
+ let loadPromise = BrowserTestUtils.browserLoaded(about3Pane.webBrowser);
+ window.goDoCommand("cmd_goStartPage");
+ await loadPromise;
+
+ let extension = await getMenuExtension({
+ manifest_version: 2,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("menus-created");
+ await subtest_content(
+ extension,
+ true,
+ about3Pane.webBrowser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: true,
+ }
+ );
+
+ await extension.unload();
+
+ Services.prefs.setStringPref("mailnews.start_page.url", oldPref);
+});
+add_task(async function test_content_tab_mv2() {
+ let tab = window.openContentTab(`${URL_BASE}/content.html`);
+ await awaitBrowserLoaded(tab.browser);
+
+ let extension = await getMenuExtension({
+ manifest_version: 2,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ tab.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 1,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(0);
+});
+add_task(async function test_content_window_mv2() {
+ let extensionWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/extensionPopup.xhtml",
+ "_blank",
+ "width=800,height=500,resizable",
+ `${URL_BASE}/content.html`
+ );
+ let extensionWindow = await extensionWindowPromise;
+ await focusWindow(extensionWindow);
+ await awaitBrowserLoaded(
+ extensionWindow.browser,
+ url => url != "about:blank"
+ );
+
+ let extension = await getMenuExtension({
+ manifest_version: 2,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ extensionWindow.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(extensionWindow);
+});
+add_task(async function test_content_mv3() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: gFolders[0].URI,
+ });
+
+ let oldPref = Services.prefs.getStringPref("mailnews.start_page.url");
+ Services.prefs.setStringPref(
+ "mailnews.start_page.url",
+ `${URL_BASE}/content.html`
+ );
+
+ let loadPromise = BrowserTestUtils.browserLoaded(about3Pane.webBrowser);
+ window.goDoCommand("cmd_goStartPage");
+ await loadPromise;
+
+ let extension = await getMenuExtension({
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("menus-created");
+ await subtest_content(
+ extension,
+ true,
+ about3Pane.webBrowser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: true,
+ }
+ );
+
+ await extension.unload();
+
+ Services.prefs.setStringPref("mailnews.start_page.url", oldPref);
+});
+add_task(async function test_content_tab_mv3() {
+ let tab = window.openContentTab(`${URL_BASE}/content.html`);
+ await awaitBrowserLoaded(tab.browser);
+
+ let extension = await getMenuExtension({
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ tab.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 1,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(0);
+});
+add_task(async function test_content_window_mv3() {
+ let extensionWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/extensionPopup.xhtml",
+ "_blank",
+ "width=800,height=500,resizable",
+ `${URL_BASE}/content.html`
+ );
+ let extensionWindow = await extensionWindowPromise;
+ await focusWindow(extensionWindow);
+ await awaitBrowserLoaded(
+ extensionWindow.browser,
+ url => url != "about:blank"
+ );
+
+ let extension = await getMenuExtension({
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ extensionWindow.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(extensionWindow);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js
new file mode 100644
index 0000000000..7f55394473
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_folder_pane(manifest) {
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let folderTree = about3Pane.document.getElementById("folderTree");
+ let menu = about3Pane.document.getElementById("folderPaneContext");
+ await rightClick(menu, folderTree.rows[1].querySelector(".container"));
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_folder_pane"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["folder_pane"],
+ contexts: ["folder_pane", "all"],
+ selectedFolder: manifest?.permissions?.includes("accountsRead")
+ ? { accountId: gAccount.key, path: "/Trash" }
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: true }
+ );
+
+ await rightClick(menu, folderTree.rows[0].querySelector(".container"));
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_folder_pane"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["folder_pane"],
+ contexts: ["folder_pane", "all"],
+ selectedAccount: manifest?.permissions?.includes("accountsRead")
+ ? { id: gAccount.key, type: "none" }
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: true }
+ );
+
+ await extension.unload();
+}
+add_task(async function test_folder_pane_mv2() {
+ return subtest_folder_pane({
+ manifest_version: 2,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_folder_pane_no_permissions_mv2() {
+ return subtest_folder_pane({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_folder_pane_mv3() {
+ return subtest_folder_pane({
+ manifest_version: 3,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_folder_pane_no_permissions_mv3() {
+ return subtest_folder_pane({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js
new file mode 100644
index 0000000000..fc851aa09d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js
@@ -0,0 +1,180 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+ await ensure_table_view();
+});
+
+async function subtest_message_panes(manifest) {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: gFolders[0].URI,
+ });
+
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ info("Test the thread pane in the 3-pane tab.");
+
+ let threadTree = about3Pane.document.getElementById("threadTree");
+ let menu = about3Pane.document.getElementById("mailContext");
+ threadTree.selectedIndex = 0;
+ await rightClick(menu, threadTree.getRowAtIndex(0));
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_message_list"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["message_list"],
+ contexts: ["message_list", "all"],
+ displayedFolder: manifest?.permissions?.includes("accountsRead")
+ ? { accountId: gAccount.key, path: "/Trash" }
+ : undefined,
+ selectedMessages: manifest?.permissions?.includes("messagesRead")
+ ? { id: null, messages: [{ subject: gMessage.subject }] }
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: true }
+ );
+
+ info("Test the message pane in the 3-pane tab.");
+
+ let messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ await subtest_content(
+ extension,
+ manifest?.permissions?.includes("messagesRead"),
+ messagePane,
+ /^mailbox\:/,
+ {
+ active: true,
+ index: 0,
+ mailTab: true,
+ }
+ );
+
+ about3Pane.threadTree.selectedIndices = [];
+ await awaitBrowserLoaded(messagePane, "about:blank");
+
+ info("Test the message pane in a tab.");
+
+ await openMessageInTab(gMessage);
+ messagePane = tabmail.currentAboutMessage.getMessagePaneBrowser();
+
+ await subtest_content(
+ extension,
+ manifest?.permissions?.includes("messagesRead"),
+ messagePane,
+ /^mailbox\:/,
+ {
+ active: true,
+ index: 1,
+ mailTab: false,
+ }
+ );
+
+ tabmail.closeOtherTabs(0);
+
+ info("Test the message pane in a separate window.");
+
+ let displayWindow = await openMessageInWindow(gMessage);
+ let displayDocument = displayWindow.document;
+ menu = displayDocument.getElementById("mailContext");
+ messagePane = displayDocument
+ .getElementById("messageBrowser")
+ .contentWindow.getMessagePaneBrowser();
+
+ await subtest_content(
+ extension,
+ manifest?.permissions?.includes("messagesRead"),
+ messagePane,
+ /^mailbox\:/,
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(displayWindow);
+}
+add_task(async function test_message_panes_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ permissions: ["accountsRead", "messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_accounts_permission_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ permissions: ["messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_messages_permission_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_message_panes_no_permissions_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_message_panes_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ permissions: ["accountsRead", "messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_accounts_permission_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ permissions: ["messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_messages_permission_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_message_panes_no_permissions_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js
new file mode 100644
index 0000000000..b957548d92
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_tab(manifest) {
+ async function checkTabEvent(index, active, mailTab) {
+ await rightClick(menu, tabs[index]);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_tab"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ { menuIds: ["tab"], contexts: ["tab"] },
+ { active, index, mailTab }
+ );
+ }
+
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let tabmail = document.getElementById("tabmail");
+ window.openContentTab("about:config");
+ window.openContentTab("about:mozilla");
+ tabmail.openTab("mail3PaneTab", { folderURI: gFolders[0].URI });
+
+ let tabs = document.getElementById("tabmail-tabs").allTabs;
+ let menu = document.getElementById("tabContextMenu");
+
+ await checkTabEvent(0, false, true);
+ await checkTabEvent(1, false, false);
+ await checkTabEvent(2, false, false);
+ await checkTabEvent(3, true, true);
+
+ await extension.unload();
+
+ tabmail.closeOtherTabs(0);
+}
+add_task(async function test_tab_mv2() {
+ await subtest_tab({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_tab_mv3() {
+ await subtest_tab({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js
new file mode 100644
index 0000000000..d9aed69ba3
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js
@@ -0,0 +1,156 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_tools_menu(
+ testWindow,
+ expectedInfo,
+ expectedTab,
+ manifest
+) {
+ let extension = await getMenuExtension(manifest);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let element = testWindow.document.getElementById("tasksMenu");
+ let menu = testWindow.document.getElementById("taskPopup");
+ await leftClick(menu, element);
+ await checkShownEvent(
+ extension,
+ { menuIds: ["tools_menu"], contexts: ["tools_menu"] },
+ expectedTab
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(extension, expectedInfo, expectedTab);
+ menu.activateItem(
+ menu.querySelector("#menus_mochi_test-menuitem-_tools_menu")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+ await extension.unload();
+}
+add_task(async function test_tools_menu_mv2() {
+ let toolbar = window.document.getElementById("toolbar-menubar");
+ let initialState = toolbar.getAttribute("inactive");
+ toolbar.setAttribute("inactive", "false");
+
+ await subtest_tools_menu(
+ window,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ }
+ );
+
+ toolbar.setAttribute("inactive", initialState);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_compose_tools_menu_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_messagewindow_tools_menu_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_tools_menu_mv3() {
+ let toolbar = window.document.getElementById("toolbar-menubar");
+ let initialState = toolbar.getAttribute("inactive");
+ toolbar.setAttribute("inactive", "false");
+
+ await subtest_tools_menu(
+ window,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ }
+ );
+
+ toolbar.setAttribute("inactive", initialState);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_compose_tools_menu_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_messagewindow_tools_menu_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js
new file mode 100644
index 0000000000..7c5612ffc6
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js
@@ -0,0 +1,395 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let gAccount, gFolders, gMessage, gExpectedAttachments;
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const URL_BASE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";
+
+var tabmail = document.getElementById("tabmail");
+var about3Pane = tabmail.currentAbout3Pane;
+var messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+
+/**
+ * Right-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function rightClick(menu, element, win) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(element, { type: "contextmenu" }, win);
+ return shownPromise;
+}
+
+/**
+ * Check the parameters of a browser.onShown event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {Array} expectedInfo.menuIds
+ * @param {Array} expectedInfo.contexts
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkShownEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onShown");
+ Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
+ Assert.deepEqual(info.contexts, expectedInfo.contexts);
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+/**
+ * Check the parameters of a browser.onClicked event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {string?} expectedInfo.menuItemId
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkClickedEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onClicked");
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ if (expectedInfo.menuItemId) {
+ Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+function getExtensionDetails(...permissions) {
+ return {
+ files: {
+ "background.js": async () => {
+ for (let context of [
+ "message_attachments",
+ "all_message_attachments",
+ ]) {
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: context,
+ title: context,
+ contexts: [context],
+ },
+ resolve
+ );
+ });
+ }
+
+ browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser.menus.onClicked.addListener((...args) => {
+ browser.test.sendMessage("onClicked", args);
+ });
+ browser.test.sendMessage("menus-created");
+ },
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "menus@mochi.test",
+ },
+ },
+ background: { scripts: ["background.js"] },
+ permissions: [...permissions, "menus"],
+ },
+ useAddonManager: "temporary",
+ };
+}
+
+add_setup(async function () {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ {
+ body: "I am another but larger attachment. ",
+ filename: "test2.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gFolders[0].URI,
+ messagePaneVisible: true,
+ });
+
+ gExpectedAttachments = [
+ {
+ name: "test1.txt",
+ size: 24,
+ contentType: "text/plain",
+ partName: "1.2",
+ },
+ {
+ name: "test2.txt",
+ size: 36,
+ contentType: "text/plain",
+ partName: "1.3",
+ },
+ ];
+});
+
+// Test a click on an attachment item.
+async function subtest_attachmentItem(
+ extension,
+ win,
+ element,
+ expectedContext,
+ expectedAttachments
+) {
+ let menu = element.ownerGlobal.document.getElementById(
+ expectedContext == "message_attachments"
+ ? "attachmentItemContext"
+ : "attachmentListContext"
+ );
+
+ let expectedShowData = {
+ menuIds: [expectedContext],
+ contexts: [expectedContext, "all"],
+ attachments: expectedAttachments,
+ };
+ let expectedClickData = {
+ attachments: expectedAttachments,
+ };
+ let expectedTab = { active: true, index: 0, mailTab: false };
+
+ let showEventPromise = checkShownEvent(
+ extension,
+ expectedShowData,
+ expectedTab
+ );
+ await rightClick(menu, element, win);
+ let menuItem = menu.querySelector(
+ `#menus_mochi_test-menuitem-_${expectedContext}`
+ );
+ await showEventPromise;
+ Assert.ok(menuItem);
+
+ let clickEventPromise = checkClickedEvent(
+ extension,
+ expectedClickData,
+ expectedTab
+ );
+ menu.activateItem(menuItem);
+ await clickEventPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+}
+
+async function subtest_attachments(
+ extension,
+ win,
+ expectedContext,
+ expectedAttachments
+) {
+ // Test clicking on the attachmentInfo element.
+ let attachmentInfo = win.document.getElementById("attachmentInfo");
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentInfo,
+ expectedContext,
+ expectedAttachments
+ );
+
+ if (expectedAttachments) {
+ win.toggleAttachmentList(true);
+ let attachmentList = win.document.getElementById("attachmentList");
+ Assert.equal(
+ attachmentList.children.length,
+ expectedAttachments.length,
+ "Should see the expected number of attachments."
+ );
+
+ // Test clicking on the individual attachment elements.
+ for (let i = 0; i < attachmentList.children.length; i++) {
+ // Select the attachment.
+ attachmentList.selectItem(attachmentList.children[i]);
+
+ // Run context click check.
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentList.children[i],
+ "message_attachments",
+ [expectedAttachments[i]]
+ );
+ }
+ }
+}
+
+async function subtest_message_panes(
+ permissions,
+ expectedContext,
+ expectedAttachments = null
+) {
+ let extensionDetails = getExtensionDetails(...permissions);
+
+ info("Test the message pane in the 3-pane tab.");
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+
+ info("Test the message pane in a tab.");
+
+ await openMessageInTab(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ tabmail.closeOtherTabs(0);
+
+ info("Test the message pane in a separate window.");
+
+ let displayWindow = await openMessageInWindow(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ displayWindow.messageBrowser.contentWindow,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(displayWindow);
+}
+
+// Tests using a message with one attachment.
+add_task(async function test_message_panes() {
+ gMessage = [...gFolders[0].messages][0];
+ about3Pane.threadTree.selectedIndex = 0;
+ await promiseMessageLoaded(messagePane, gMessage);
+
+ await subtest_message_panes(
+ ["accountsRead", "messagesRead"],
+ "message_attachments",
+ [gExpectedAttachments[0]]
+ );
+});
+add_task(async function test_message_panes_no_accounts_permission() {
+ return subtest_message_panes(["messagesRead"], "message_attachments", [
+ gExpectedAttachments[0],
+ ]);
+});
+add_task(async function test_message_panes_no_messages_permission() {
+ return subtest_message_panes(["accountsRead"], "message_attachments");
+});
+add_task(async function test_message_panes_no_permissions() {
+ return subtest_message_panes([], "message_attachments");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js
new file mode 100644
index 0000000000..7ae4d72a29
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js
@@ -0,0 +1,397 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let gAccount, gFolders, gMessage, gExpectedAttachments;
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const URL_BASE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";
+
+var tabmail = document.getElementById("tabmail");
+var about3Pane = tabmail.currentAbout3Pane;
+var messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+
+/**
+ * Right-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function rightClick(menu, element, win) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(element, { type: "contextmenu" }, win);
+ return shownPromise;
+}
+
+/**
+ * Check the parameters of a browser.onShown event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {Array} expectedInfo.menuIds
+ * @param {Array} expectedInfo.contexts
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkShownEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onShown");
+ Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
+ Assert.deepEqual(info.contexts, expectedInfo.contexts);
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+/**
+ * Check the parameters of a browser.onClicked event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {string?} expectedInfo.menuItemId
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkClickedEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onClicked");
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ if (expectedInfo.menuItemId) {
+ Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+function getExtensionDetails(...permissions) {
+ return {
+ files: {
+ "background.js": async () => {
+ for (let context of [
+ "message_attachments",
+ "all_message_attachments",
+ ]) {
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: context,
+ title: context,
+ contexts: [context],
+ },
+ resolve
+ );
+ });
+ }
+
+ browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser.menus.onClicked.addListener((...args) => {
+ browser.test.sendMessage("onClicked", args);
+ });
+ browser.test.sendMessage("menus-created");
+ },
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "menus@mochi.test",
+ },
+ },
+ background: { scripts: ["background.js"] },
+ permissions: [...permissions, "menus"],
+ },
+ useAddonManager: "temporary",
+ };
+}
+
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ {
+ body: "I am another but larger attachment. ",
+ filename: "test2.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gFolders[0].URI,
+ messagePaneVisible: true,
+ });
+
+ gExpectedAttachments = [
+ {
+ name: "test1.txt",
+ size: 24,
+ contentType: "text/plain",
+ partName: "1.2",
+ },
+ {
+ name: "test2.txt",
+ size: 36,
+ contentType: "text/plain",
+ partName: "1.3",
+ },
+ ];
+});
+
+// Test a click on an attachment item.
+async function subtest_attachmentItem(
+ extension,
+ win,
+ element,
+ expectedContext,
+ expectedAttachments
+) {
+ let menu = element.ownerGlobal.document.getElementById(
+ expectedContext == "message_attachments"
+ ? "attachmentItemContext"
+ : "attachmentListContext"
+ );
+
+ let expectedShowData = {
+ menuIds: [expectedContext],
+ contexts: [expectedContext, "all"],
+ attachments: expectedAttachments,
+ };
+ let expectedClickData = {
+ attachments: expectedAttachments,
+ };
+ let expectedTab = { active: true, index: 0, mailTab: false };
+
+ let showEventPromise = checkShownEvent(
+ extension,
+ expectedShowData,
+ expectedTab
+ );
+ await rightClick(menu, element, win);
+ let menuItem = menu.querySelector(
+ `#menus_mochi_test-menuitem-_${expectedContext}`
+ );
+ await showEventPromise;
+ Assert.ok(menuItem);
+
+ let clickEventPromise = checkClickedEvent(
+ extension,
+ expectedClickData,
+ expectedTab
+ );
+ menu.activateItem(menuItem);
+ await clickEventPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+}
+
+async function subtest_attachments(
+ extension,
+ win,
+ expectedContext,
+ expectedAttachments
+) {
+ // Test clicking on the attachmentInfo element.
+ let attachmentInfo = win.document.getElementById("attachmentInfo");
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentInfo,
+ expectedContext,
+ expectedAttachments
+ );
+
+ if (expectedAttachments) {
+ win.toggleAttachmentList(true);
+ let attachmentList = win.document.getElementById("attachmentList");
+ Assert.equal(
+ attachmentList.children.length,
+ expectedAttachments.length,
+ "Should see the expected number of attachments."
+ );
+
+ // Test clicking on the individual attachment elements.
+ for (let i = 0; i < attachmentList.children.length; i++) {
+ // Select the attachment.
+ attachmentList.selectItem(attachmentList.children[i]);
+
+ // Run context click check.
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentList.children[i],
+ "message_attachments",
+ [expectedAttachments[i]]
+ );
+ }
+ }
+}
+
+async function subtest_message_panes(
+ permissions,
+ expectedContext,
+ expectedAttachments = null
+) {
+ let extensionDetails = getExtensionDetails(...permissions);
+
+ info("Test the message pane in the 3-pane tab.");
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+
+ info("Test the message pane in a tab.");
+
+ await openMessageInTab(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ tabmail.closeOtherTabs(0);
+
+ info("Test the message pane in a separate window.");
+
+ let displayWindow = await openMessageInWindow(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ displayWindow.messageBrowser.contentWindow,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(displayWindow);
+}
+
+// Tests using a message with two attachment.
+add_task(async function test_message_panes() {
+ gMessage = [...gFolders[0].messages][1];
+ about3Pane.threadTree.selectedIndex = 1;
+ await promiseMessageLoaded(messagePane, gMessage);
+
+ await subtest_message_panes(
+ ["accountsRead", "messagesRead"],
+ "all_message_attachments",
+ gExpectedAttachments
+ );
+});
+add_task(async function test_message_panes_no_accounts_permission() {
+ return subtest_message_panes(
+ ["messagesRead"],
+ "all_message_attachments",
+ gExpectedAttachments
+ );
+});
+add_task(async function test_message_panes_no_messages_permission() {
+ return subtest_message_panes(["accountsRead"], "all_message_attachments");
+});
+add_task(async function test_message_panes_no_permissions() {
+ return subtest_message_panes([], "all_message_attachments");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js
new file mode 100644
index 0000000000..4d0660597a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js
@@ -0,0 +1,405 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+async function subtest_action_popup_menu(
+ testWindow,
+ target,
+ expectedInfo,
+ expectedTab,
+ manifest
+) {
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let element = testWindow.document.querySelector(target.elementSelector);
+ let menu = element.querySelector("menupopup");
+
+ await leftClick(menu, element);
+ await checkShownEvent(
+ extension,
+ { menuIds: [target.context], contexts: [target.context, "all"] },
+ expectedTab
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(extension, expectedInfo, expectedTab);
+ menu.activateItem(
+ menu.querySelector(`#menus_mochi_test-menuitem-_${target.context}`)
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ await extension.unload();
+}
+
+add_task(async function test_browser_action_menu_popup_mv2() {
+ await subtest_action_popup_menu(
+ window,
+ {
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "browser_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "browser_action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ browser_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_browser_action_menu_popup_message_window_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-browserAction-toolbarbutton",
+ context: "browser_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "browser_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ browser_action: {
+ default_title: "This is a test",
+ default_windows: ["messageDisplay"],
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_message_display_action_menu_popup_pane_mv2() {
+ let tabmail = document.getElementById("tabmail");
+ let aboutMessage = tabmail.currentAboutMessage;
+ await SimpleTest.promiseFocus(aboutMessage);
+
+ await subtest_action_popup_menu(
+ aboutMessage,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_popup_tab_mv2() {
+ let tab = await openMessageInTab(gMessage);
+ await subtest_action_popup_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_popup_window_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_formattoolbar_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+
+add_task(async function test_browser_action_menu_popup_mv3() {
+ await subtest_action_popup_menu(
+ window,
+ {
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_browser_action_menu_popup_message_window_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-browserAction-toolbarbutton",
+ context: "action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ action: {
+ default_title: "This is a test",
+ default_windows: ["messageDisplay"],
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_message_display_action_menu_popup_pane_mv3() {
+ let tabmail = document.getElementById("tabmail");
+ let aboutMessage = tabmail.currentAboutMessage;
+ await SimpleTest.promiseFocus(aboutMessage);
+
+ await subtest_action_popup_menu(
+ aboutMessage,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_popup_tab_mv3() {
+ let tab = await openMessageInTab(gMessage);
+ await subtest_action_popup_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_popup_window_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ nonActionButtonSelector: "#button-attach",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_formattoolbar_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js
new file mode 100644
index 0000000000..bc773afa44
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js
@@ -0,0 +1,582 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+function getVisibleChildrenIds(menuElem) {
+ return Array.from(menuElem.children)
+ .filter(elem => !elem.hidden)
+ .map(elem => elem.id || elem.tagName);
+}
+
+function checkIsDefaultMenuItemVisible(visibleMenuItemIds) {
+ // In this whole test file, we open a menu on a link. Assume that all
+ // default menu items are shown if one link-specific menu item is shown.
+ ok(
+ visibleMenuItemIds.includes("browserContext-copylink"),
+ `The default 'Copy Link Location' menu item should be in ${visibleMenuItemIds}.`
+ );
+}
+
+// Tests the following:
+// - Calling overrideContext({}) during oncontextmenu forces the menu to only
+// show an extension's own items.
+// - These menu items all appear in the root menu.
+// - The usual extension filtering behavior (e.g. documentUrlPatterns and
+// targetUrlPatterns) is still applied; some menu items are therefore hidden.
+// - Calling overrideContext({showDefaults:true}) causes the default menu items
+// to be shown, but only after the extension's.
+// - overrideContext expires after the menu is opened once.
+// - overrideContext can be called from shadow DOM.
+add_task(async function overrideContext_in_extension_tab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.allow_eval_with_system_principal", true]],
+ });
+
+ function extensionTabScript() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_dom_part_1");
+ },
+ { once: true }
+ );
+
+ let shadowRoot = document
+ .getElementById("shadowHost")
+ .attachShadow({ mode: "open" });
+ shadowRoot.innerHTML = `<a href="http://example.com/">Link</a>`;
+ shadowRoot.firstChild.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_shadow_dom");
+ },
+ { once: true }
+ );
+
+ browser.menus.create({
+ id: "tab_1",
+ title: "tab_1",
+ documentUrlPatterns: [document.URL],
+ onclick() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ // Verifies that last call takes precedence.
+ browser.menus.overrideContext({ showDefaults: false });
+ browser.menus.overrideContext({ showDefaults: true });
+ browser.test.sendMessage("oncontextmenu_in_dom_part_2");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("onClicked_tab_1");
+ },
+ });
+ browser.menus.create(
+ {
+ id: "tab_2",
+ title: "tab_2",
+ onclick() {
+ browser.test.sendMessage("onClicked_tab_2");
+ },
+ },
+ () => {
+ browser.test.sendMessage("menu-registered");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["menus", "menus.overrideContext"],
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <a href="http://example.com/">Link</a>
+ <div id="shadowHost"></div>
+ <script src="tab.js"></script>
+ `,
+ "tab.js": extensionTabScript,
+ },
+ background() {
+ // Expected to match and thus be visible.
+ browser.menus.create({ id: "bg_1", title: "bg_1" });
+ browser.menus.create({
+ id: "bg_2",
+ title: "bg_2",
+ targetUrlPatterns: ["*://example.com/*"],
+ });
+
+ // Expected to not match and be hidden.
+ browser.menus.create({
+ id: "bg_3",
+ title: "bg_3",
+ targetUrlPatterns: ["*://nomatch/*"],
+ });
+ browser.menus.create({
+ id: "bg_4",
+ title: "bg_4",
+ documentUrlPatterns: [document.URL],
+ });
+
+ browser.menus.onShown.addListener(info => {
+ browser.test.assertEq("tab", info.viewType, "Expected viewType");
+ browser.test.assertEq(
+ "bg_1,bg_2,tab_1,tab_2",
+ info.menuIds.join(","),
+ "Expected menu items."
+ );
+ browser.test.assertEq(
+ "all,link",
+ info.contexts.sort().join(","),
+ "Expected menu contexts"
+ );
+ browser.test.sendMessage("onShown");
+ });
+
+ browser.tabs.create({ url: "tab.html" });
+ },
+ });
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["menus"],
+ },
+ background() {
+ browser.menus.create(
+ { id: "other_extension_item", title: "other_extension_item" },
+ () => {
+ browser.test.sendMessage("other_extension_item_created");
+ }
+ );
+ },
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("other_extension_item_created");
+
+ await extension.startup();
+ await extension.awaitMessage("menu-registered");
+
+ const EXPECTED_EXTENSION_MENU_IDS = [
+ `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_bg_2`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_2`,
+ ];
+ const OTHER_EXTENSION_MENU_ID = `${makeWidgetId(
+ otherExtension.id
+ )}-menuitem-_other_extension_item`;
+
+ {
+ // Tests overrideContext({})
+ info("Expecting the menu to be replaced by overrideContext.");
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom_part_1");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items"
+ );
+
+ let menuItems = menu.getElementsByAttribute("label", "tab_1");
+ await closeExtensionContextMenu(menuItems[0]);
+ await extension.awaitMessage("onClicked_tab_1");
+ }
+
+ {
+ // Tests overrideContext({showDefaults:true}))
+ info(
+ "Expecting the menu to be replaced by overrideContext, including default menu items."
+ );
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom_part_2");
+ await extension.awaitMessage("onShown");
+
+ let visibleMenuItemIds = getVisibleChildrenIds(menu);
+ Assert.deepEqual(
+ visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected extension menu items at the start."
+ );
+
+ checkIsDefaultMenuItemVisible(visibleMenuItemIds);
+
+ is(
+ visibleMenuItemIds[visibleMenuItemIds.length - 1],
+ OTHER_EXTENSION_MENU_ID,
+ "Other extension menu item should be at the end."
+ );
+
+ let menuItems = menu.getElementsByAttribute("label", "tab_2");
+ await closeExtensionContextMenu(menuItems[0]);
+ await extension.awaitMessage("onClicked_tab_2");
+ }
+
+ {
+ // Tests that previous overrideContext call has been forgotten,
+ // so the default behavior should occur (=move items into submenu).
+ info(
+ "Expecting the default menu to be used when overrideContext is not called."
+ );
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("onShown");
+
+ checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+
+ let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu");
+ is(menuItems.length, 1, "Expected top-level menu element for extension.");
+ let topLevelExtensionMenuItem = menuItems[0];
+ is(
+ topLevelExtensionMenuItem.nextSibling,
+ null,
+ "Extension menu should be the last element."
+ );
+
+ const submenu = await openSubmenu(topLevelExtensionMenuItem);
+ is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(submenu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Extension menu items should be in the submenu by default."
+ );
+
+ await closeContextMenu();
+ }
+
+ {
+ info(
+ "Expecting the menu to be replaced by overrideContext from a listener inside shadow DOM."
+ );
+ // Tests that overrideContext({}) can be used from a listener inside shadow DOM.
+ let menu = await openContextMenu(
+ () => this.document.getElementById("shadowHost").shadowRoot.firstChild
+ );
+ await extension.awaitMessage("oncontextmenu_in_shadow_dom");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items after overrideContext({}) in shadow DOM"
+ );
+
+ await closeContextMenu();
+ }
+
+ // Unloading the extension will automatically close the extension's tab.html
+ await extension.unload();
+ await otherExtension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+});
+
+async function run_overrideContext_test_in_popup(testWindow, buttonSelector) {
+ function extensionPopupScript() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_dom_part_1");
+ },
+ { once: true }
+ );
+
+ let shadowRoot = document
+ .getElementById("shadowHost")
+ .attachShadow({ mode: "open" });
+ shadowRoot.innerHTML = `<a href="http://example.com/">Link2</a>`;
+ shadowRoot.firstChild.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_shadow_dom");
+ },
+ { once: true }
+ );
+
+ browser.menus.create({
+ id: "popup_1",
+ title: "popup_1",
+ documentUrlPatterns: [document.URL],
+ onclick() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ // Verifies that last call takes precedence.
+ browser.menus.overrideContext({ showDefaults: false });
+ browser.menus.overrideContext({ showDefaults: true });
+ browser.test.sendMessage("oncontextmenu_in_dom_part_2");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("onClicked_popup_1");
+ },
+ });
+ browser.menus.create(
+ {
+ id: "popup_2",
+ title: "popup_2",
+ onclick() {
+ browser.test.sendMessage("onClicked_popup_2");
+ },
+ },
+ () => {
+ browser.test.sendMessage("menu-registered");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: `overrideContext@mochi.test`,
+ },
+ },
+ permissions: ["menus", "menus.overrideContext"],
+ browser_action: {
+ default_popup: "popup.html",
+ default_title: "Popup",
+ },
+ compose_action: {
+ default_popup: "popup.html",
+ default_title: "Popup",
+ },
+ message_display_action: {
+ default_popup: "popup.html",
+ default_title: "Popup",
+ },
+ },
+ files: {
+ "popup.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <a id="link1" href="http://example.com/">Link1</a>
+ <div id="shadowHost"></div>
+ <script src="popup.js"></script>
+ `,
+ "popup.js": extensionPopupScript,
+ },
+ background() {
+ // Expected to match and thus be visible.
+ browser.menus.create({
+ id: "bg_1",
+ title: "bg_1",
+ viewTypes: ["popup"],
+ });
+ // Expected to not match and be hidden.
+ browser.menus.create({
+ id: "bg_2",
+ title: "bg_2",
+ viewTypes: ["tab"],
+ });
+ browser.menus.onShown.addListener(info => {
+ browser.test.assertEq("popup", info.viewType, "Expected viewType");
+ browser.test.assertEq(
+ "bg_1,popup_1,popup_2",
+ info.menuIds.join(","),
+ "Expected menu items."
+ );
+ browser.test.sendMessage("onShown");
+ });
+
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ const EXPECTED_EXTENSION_MENU_IDS = [
+ `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_popup_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_popup_2`,
+ ];
+ const button = testWindow.document.querySelector(buttonSelector);
+ Assert.ok(button, "Button created");
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, testWindow);
+ await extension.awaitMessage("menu-registered");
+
+ {
+ // Tests overrideContext({})
+ info("Expecting the menu to be replaced by overrideContext.");
+
+ let menu = await openContextMenuInPopup(extension, "#link1", testWindow);
+ await extension.awaitMessage("oncontextmenu_in_dom_part_1");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items"
+ );
+
+ let menuItems = menu.getElementsByAttribute("label", "popup_1");
+
+ await closeExtensionContextMenu(menuItems[0], {}, testWindow);
+ await extension.awaitMessage("onClicked_popup_1");
+ }
+
+ {
+ // Tests overrideContext({showDefaults:true}))
+ info(
+ "Expecting the menu to be replaced by overrideContext, including default menu items."
+ );
+ let menu = await openContextMenuInPopup(extension, "#link1", testWindow);
+ await extension.awaitMessage("oncontextmenu_in_dom_part_2");
+ await extension.awaitMessage("onShown");
+ let visibleMenuItemIds = getVisibleChildrenIds(menu);
+ Assert.deepEqual(
+ visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected extension menu items at the start."
+ );
+ checkIsDefaultMenuItemVisible(visibleMenuItemIds);
+
+ let menuItems = menu.getElementsByAttribute("label", "popup_2");
+ await closeExtensionContextMenu(menuItems[0], {}, testWindow);
+ await extension.awaitMessage("onClicked_popup_2");
+ }
+
+ {
+ // Tests that previous overrideContext call has been forgotten,
+ // so the default behavior should occur (=move items into submenu).
+ info(
+ "Expecting the default menu to be used when overrideContext is not called."
+ );
+ let menu = await openContextMenuInPopup(extension, "#link1", testWindow);
+ await extension.awaitMessage("onShown");
+
+ checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+
+ let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu");
+ is(menuItems.length, 1, "Expected top-level menu element for extension.");
+ let topLevelExtensionMenuItem = menuItems[0];
+ is(
+ topLevelExtensionMenuItem.nextSibling,
+ null,
+ "Extension menu should be the last element."
+ );
+
+ const submenu = await openSubmenu(topLevelExtensionMenuItem);
+ is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(submenu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Extension menu items should be in the submenu by default."
+ );
+
+ await closeContextMenu(menu);
+ }
+
+ {
+ info("Testing overrideContext from a listener inside a shadow DOM.");
+ // Tests that overrideContext({}) can be used from a listener inside shadow DOM.
+ let menu = await openContextMenuInPopup(
+ extension,
+ () => this.document.getElementById("shadowHost").shadowRoot.firstChild,
+ testWindow
+ );
+ await extension.awaitMessage("oncontextmenu_in_shadow_dom");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items after overrideContext({}) in shadow DOM"
+ );
+
+ await closeContextMenu(menu);
+ }
+
+ await closeBrowserAction(extension, testWindow);
+ await extension.unload();
+}
+
+add_task(async function overrideContext_in_extension_browser_action_popup() {
+ await run_overrideContext_test_in_popup(
+ window,
+ `.unified-toolbar [extension="overrideContext@mochi.test"]`
+ );
+});
+
+add_task(async function overrideContext_in_extension_compose_action_popup() {
+ let account = createAccount();
+ addIdentity(account);
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+ await run_overrideContext_test_in_popup(
+ composeWindow,
+ "#overridecontext_mochi_test-composeAction-toolbarbutton"
+ );
+ composeWindow.close();
+});
+
+add_task(
+ async function overrideContext_in_extension_message_display_action_popup_of_mail3pane() {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders[0]);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await run_overrideContext_test_in_popup(
+ about3Pane.messageBrowser.contentWindow,
+ "#overridecontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ about3Pane.displayFolder(rootFolder);
+ }
+);
+
+add_task(
+ async function overrideContext_in_extension_message_display_action_popup_of_window() {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ let messages = subFolders[0].messages;
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ await focusWindow(messageWindow);
+ await run_overrideContext_test_in_popup(
+ messageWindow.messageBrowser.contentWindow,
+ "#overridecontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ messageWindow.close();
+ }
+);
+
+add_task(
+ async function overrideContext_in_extension_message_display_action_popup_of_tab() {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ let messages = subFolders[0].messages;
+
+ await openMessageInTab(messages.getNext());
+
+ let tabmail = document.getElementById("tabmail");
+ await run_overrideContext_test_in_popup(
+ tabmail.currentAboutMessage,
+ "#overridecontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ tabmail.closeOtherTabs(0);
+ }
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js
new file mode 100644
index 0000000000..2a7192fe70
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js
@@ -0,0 +1,375 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+function getVisibleChildrenIds(menuElem) {
+ return Array.from(menuElem.children)
+ .filter(elem => !elem.hidden)
+ .map(elem => elem.id || elem.tagName);
+}
+
+function checkIsDefaultMenuItemVisible(visibleMenuItemIds) {
+ // In this whole test file, we open a menu on a link. Assume that all
+ // default menu items are shown if one link-specific menu item is shown.
+ ok(
+ visibleMenuItemIds.includes("browserContext-copylink"),
+ `The default 'Copy Link Location' menu item should be in ${visibleMenuItemIds}.`
+ );
+}
+
+// Tests that the context of an extension menu can be changed to:
+// - tab
+add_task(async function overrideContext_with_context() {
+ // Background script of the main test extension and the auxiliary other extension.
+ function background() {
+ const HTTP_URL = "https://example.com/?SomeTab";
+ browser.test.onMessage.addListener(async (msg, tabId) => {
+ browser.test.assertEq(
+ "testTabAccess",
+ msg,
+ `Expected message in ${browser.runtime.id}`
+ );
+ let tab = await browser.tabs.get(tabId);
+ if (!tab.url) {
+ // tabs or activeTab not active.
+ browser.test.sendMessage("testTabAccessDone", "tab_no_url");
+ return;
+ }
+ try {
+ let [url] = await browser.tabs.executeScript(tabId, {
+ code: "document.URL",
+ });
+ browser.test.assertEq(
+ HTTP_URL,
+ url,
+ "Expected successful executeScript"
+ );
+ browser.test.sendMessage("testTabAccessDone", "executeScript_ok");
+ return;
+ } catch (e) {
+ browser.test.assertEq(
+ "Missing host permission for the tab",
+ e.message,
+ "Expected error message"
+ );
+ browser.test.sendMessage("testTabAccessDone", "executeScript_failed");
+ }
+ });
+ browser.menus.onShown.addListener((info, tab) => {
+ browser.test.assertEq(
+ "tab",
+ info.viewType,
+ "Expected viewType at onShown"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.linkUrl,
+ "Expected linkUrl at onShown"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.srckUrl,
+ "Expected srcUrl at onShown"
+ );
+ browser.test.sendMessage("onShown", {
+ menuIds: info.menuIds.sort(),
+ contexts: info.contexts,
+ bookmarkId: info.bookmarkId,
+ pageUrl: info.pageUrl,
+ frameUrl: info.frameUrl,
+ tabId: tab && tab.id,
+ });
+ });
+ browser.menus.onClicked.addListener((info, tab) => {
+ browser.test.assertEq(
+ "tab",
+ info.viewType,
+ "Expected viewType at onClicked"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.linkUrl,
+ "Expected linkUrl at onClicked"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.srckUrl,
+ "Expected srcUrl at onClicked"
+ );
+ browser.test.sendMessage("onClicked", {
+ menuItemId: info.menuItemId,
+ bookmarkId: info.bookmarkId,
+ pageUrl: info.pageUrl,
+ frameUrl: info.frameUrl,
+ tabId: tab && tab.id,
+ });
+ });
+
+ // Minimal properties to define menu items for a specific context.
+ browser.menus.create({
+ id: "tab_context",
+ title: "tab_context",
+ contexts: ["tab"],
+ });
+
+ // documentUrlPatterns in the tab context applies to the tab's URL.
+ browser.menus.create({
+ id: "tab_context_http",
+ title: "tab_context_http",
+ contexts: ["tab"],
+ documentUrlPatterns: [HTTP_URL],
+ });
+ browser.menus.create({
+ id: "tab_context_moz_unexpected",
+ title: "tab_context_moz",
+ contexts: ["tab"],
+ documentUrlPatterns: ["moz-extension://*/tab.html"],
+ });
+ // When viewTypes is present, the document's URL is matched instead.
+ browser.menus.create({
+ id: "tab_context_viewType_http_unexpected",
+ title: "tab_context_viewType_http",
+ contexts: ["tab"],
+ viewTypes: ["tab"],
+ documentUrlPatterns: [HTTP_URL],
+ });
+ browser.menus.create({
+ id: "tab_context_viewType_moz",
+ title: "tab_context_viewType_moz",
+ contexts: ["tab"],
+ viewTypes: ["tab"],
+ documentUrlPatterns: ["moz-extension://*/tab.html"],
+ });
+
+ browser.menus.create({ id: "link_context", title: "link_context" }, () => {
+ browser.test.sendMessage("menu_items_registered");
+ });
+
+ if (browser.runtime.id === "@menu-test-extension") {
+ browser.tabs.create({ url: "tab.html" });
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "@menu-test-extension" } },
+ permissions: ["menus", "menus.overrideContext", "tabs"],
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <a href="http://example.com/">Link</a>
+ <script src="tab.js"></script>
+ `,
+ "tab.js": async () => {
+ let [tab] = await browser.tabs.query({
+ url: "https://example.com/?SomeTab",
+ });
+ let testCases = [
+ {
+ context: "tab",
+ tabId: tab.id,
+ },
+ {
+ context: "tab",
+ tabId: tab.id,
+ },
+ {
+ context: "tab",
+ tabId: 123456789, // Some invalid tabId.
+ },
+ ];
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ document.addEventListener("contextmenu", () => {
+ browser.menus.overrideContext(testCases.shift());
+ browser.test.sendMessage("oncontextmenu_in_dom");
+ });
+
+ browser.test.sendMessage("setup_ready", {
+ tabId: tab.id,
+ httpUrl: tab.url,
+ extensionUrl: document.URL,
+ });
+ },
+ },
+ background,
+ });
+
+ let { browser } = window.openContentTab("https://example.com/?SomeTab");
+ await awaitBrowserLoaded(browser);
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "@other-test-extension" } },
+ permissions: ["menus", "activeTab"],
+ },
+ background,
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("menu_items_registered");
+
+ await extension.startup();
+ await extension.awaitMessage("menu_items_registered");
+
+ let { tabId, httpUrl, extensionUrl } = await extension.awaitMessage(
+ "setup_ready"
+ );
+ info(`Set up test with tabId=${tabId}.`);
+
+ {
+ // Test case 1: context=tab
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom");
+ for (let ext of [extension, otherExtension]) {
+ info(`Testing menu from ${ext.id} after changing context to tab`);
+ Assert.deepEqual(
+ await ext.awaitMessage("onShown"),
+ {
+ menuIds: [
+ "tab_context",
+ "tab_context_http",
+ "tab_context_viewType_moz",
+ ],
+ contexts: ["tab"],
+ bookmarkId: undefined,
+ pageUrl: undefined, // because extension has no host permissions.
+ frameUrl: extensionUrl,
+ tabId,
+ },
+ "Expected onShown details after changing context to tab"
+ );
+ }
+ let topLevels = menu.getElementsByAttribute("ext-type", "top-level-menu");
+ is(topLevels.length, 1, "Expected top-level menu for otherExtension");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ [
+ `${makeWidgetId(extension.id)}-menuitem-_tab_context`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_context_http`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_context_viewType_moz`,
+ `menuseparator`,
+ topLevels[0].id,
+ ],
+ "Expected menu items after changing context to tab"
+ );
+
+ let submenu = await openSubmenu(topLevels[0]);
+ is(submenu, topLevels[0].menupopup, "Correct submenu opened");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(submenu),
+ [
+ `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context`,
+ `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_http`,
+ `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_viewType_moz`,
+ ],
+ "Expected menu items in submenu after changing context to tab"
+ );
+
+ extension.sendMessage("testTabAccess", tabId);
+ is(
+ await extension.awaitMessage("testTabAccessDone"),
+ "executeScript_failed",
+ "executeScript should fail due to the lack of permissions."
+ );
+
+ otherExtension.sendMessage("testTabAccess", tabId);
+ is(
+ await otherExtension.awaitMessage("testTabAccessDone"),
+ "tab_no_url",
+ "Other extension should not have activeTab permissions yet."
+ );
+
+ // Click on the menu item of the other extension to unlock host permissions.
+ let menuItems = menu.getElementsByAttribute("label", "tab_context");
+ is(
+ menuItems.length,
+ 2,
+ "There are two menu items with label 'tab_context'"
+ );
+ await closeExtensionContextMenu(menuItems[1]);
+
+ Assert.deepEqual(
+ await otherExtension.awaitMessage("onClicked"),
+ {
+ menuItemId: "tab_context",
+ bookmarkId: undefined,
+ pageUrl: httpUrl,
+ frameUrl: extensionUrl,
+ tabId,
+ },
+ "Expected onClicked details after changing context to tab"
+ );
+
+ extension.sendMessage("testTabAccess", tabId);
+ is(
+ await extension.awaitMessage("testTabAccessDone"),
+ "executeScript_failed",
+ "executeScript of extension that created the menu should still fail."
+ );
+
+ otherExtension.sendMessage("testTabAccess", tabId);
+ is(
+ await otherExtension.awaitMessage("testTabAccessDone"),
+ "executeScript_ok",
+ "Other extension should have activeTab permissions."
+ );
+ }
+
+ {
+ // Test case 2: context=tab, click on menu item of extension..
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom");
+
+ // The previous test has already verified the visible menu items,
+ // so we skip checking the onShown result and only test clicking.
+ await extension.awaitMessage("onShown");
+ await otherExtension.awaitMessage("onShown");
+ let menuItems = menu.getElementsByAttribute("label", "tab_context");
+ is(
+ menuItems.length,
+ 2,
+ "There are two menu items with label 'tab_context'"
+ );
+ await closeExtensionContextMenu(menuItems[0]);
+
+ Assert.deepEqual(
+ await extension.awaitMessage("onClicked"),
+ {
+ menuItemId: "tab_context",
+ bookmarkId: undefined,
+ pageUrl: httpUrl,
+ frameUrl: extensionUrl,
+ tabId,
+ },
+ "Expected onClicked details after changing context to tab"
+ );
+
+ extension.sendMessage("testTabAccess", tabId);
+ is(
+ await extension.awaitMessage("testTabAccessDone"),
+ "executeScript_failed",
+ "activeTab permission should not be available to the extension that created the menu."
+ );
+ }
+
+ {
+ // Test case 4: context=tab, invalid tabId.
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom");
+ // When an invalid tabId is used, all extension menu logic is skipped and
+ // the default menu is shown.
+ checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+ await closeContextMenu(menu);
+ }
+
+ await extension.unload();
+ await otherExtension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+ tabmail.closeTab(tabmail.currentTabInfo);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js
new file mode 100644
index 0000000000..c99ea52440
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js
@@ -0,0 +1,1016 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+ rootFolder.createSubfolder("test1", null);
+ rootFolder.createSubfolder("test2", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+ createMessages(subFolders.test1, 5);
+ createMessages(subFolders.test2, 6);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+add_task(async function testGetDisplayedMessage() {
+ let files = {
+ "background.js": async () => {
+ let [{ id: firstTabId, displayedFolder }] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+
+ let { messages } = await browser.messages.list(displayedFolder);
+
+ async function checkResults(action, expectedMessages, sameTab) {
+ let msgListener = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+ let msgsListener = window.waitForEvent(
+ "messageDisplay.onMessagesDisplayed"
+ );
+
+ if (typeof action == "string") {
+ await window.sendMessage(action);
+ } else {
+ action();
+ }
+
+ let tab;
+ let message;
+ if (expectedMessages.length == 1) {
+ [tab, message] = await msgListener;
+ let [msgsTab, msgs] = await msgsListener;
+ // Check listener results.
+ if (sameTab) {
+ browser.test.assertEq(firstTabId, tab.id);
+ browser.test.assertEq(firstTabId, msgsTab.id);
+ } else {
+ browser.test.assertTrue(firstTabId != tab.id);
+ browser.test.assertTrue(firstTabId != msgsTab.id);
+ }
+ browser.test.assertEq(
+ messages[expectedMessages[0]].subject,
+ message.subject
+ );
+ browser.test.assertEq(
+ messages[expectedMessages[0]].subject,
+ msgs[0].subject
+ );
+
+ // Check displayed message result.
+ message = await browser.messageDisplay.getDisplayedMessage(tab.id);
+ browser.test.assertEq(
+ messages[expectedMessages[0]].subject,
+ message.subject
+ );
+ } else {
+ // onMessageDisplayed doesn't fire for the multi-message case.
+ let msgs;
+ [tab, msgs] = await msgsListener;
+
+ for (let [i, expected] of expectedMessages.entries()) {
+ browser.test.assertEq(messages[expected].subject, msgs[i].subject);
+ }
+
+ // More than one selected, so getDisplayMessage returns null.
+ message = await browser.messageDisplay.getDisplayedMessage(tab.id);
+ browser.test.assertEq(null, message);
+ }
+
+ let displayMsgs = await browser.messageDisplay.getDisplayedMessages(
+ tab.id
+ );
+ browser.test.assertEq(expectedMessages.length, displayMsgs.length);
+ for (let [i, expected] of expectedMessages.entries()) {
+ browser.test.assertEq(
+ messages[expected].subject,
+ displayMsgs[i].subject
+ );
+ }
+ return tab;
+ }
+
+ async function testGetDisplayedMessageFunctions(tabId, expected) {
+ let messages = await browser.messageDisplay.getDisplayedMessages(tabId);
+ if (expected) {
+ browser.test.assertEq(1, messages.length);
+ browser.test.assertEq(expected.subject, messages[0].subject);
+ } else {
+ browser.test.assertEq(0, messages.length);
+ }
+
+ let message = await browser.messageDisplay.getDisplayedMessage(tabId);
+ if (expected) {
+ browser.test.assertEq(expected.subject, message.subject);
+ } else {
+ browser.test.assertEq(null, message);
+ }
+ }
+
+ // Test that selecting a different message fires the event.
+ await checkResults("show message 1", [1], true);
+
+ // ... and again, for good measure.
+ await checkResults("show message 2", [2], true);
+
+ // Test that opening a message in a new tab fires the event.
+ let tab = await checkResults("open message 0 in tab", [0], false);
+
+ // The opened tab should return message #0.
+ await testGetDisplayedMessageFunctions(tab.id, messages[0]);
+
+ // The first tab should return message #2, even if it is currently not displayed.
+ await testGetDisplayedMessageFunctions(firstTabId, messages[2]);
+
+ // Closing the tab should return us to the first tab.
+ await browser.tabs.remove(tab.id);
+
+ // Test that opening a message in a new window fires the event.
+ tab = await checkResults("open message 1 in window", [1], false);
+
+ // Test the windows API being able to return the messageDisplay window as
+ // the current one.
+ let msgWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(msgWindow.type, "messageDisplay");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ // Close the window.
+ browser.tabs.remove(tab.id);
+
+ // Test that selecting a multiple messages fires the event.
+ await checkResults("show messages 1 and 2", [1, 2], true);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+
+ await extension.awaitMessage("show message 1");
+ about3Pane.threadTree.selectedIndex = 1;
+ extension.sendMessage();
+
+ await extension.awaitMessage("show message 2");
+ about3Pane.threadTree.selectedIndex = 2;
+ extension.sendMessage();
+
+ await extension.awaitMessage("open message 0 in tab");
+ await openMessageInTab(gMessages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage("open message 1 in window");
+ await openMessageInWindow(gMessages[1]);
+ extension.sendMessage();
+
+ await extension.awaitMessage("show messages 1 and 2");
+ about3Pane.threadTree.selectedIndices = [1, 2];
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testOpenMessagesInTabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Helper class to keep track of expected tab states and cycle though all
+ // tabs after each test to enure the returned values are as expected under
+ // different active/inactive scenarios.
+ class TabTest {
+ constructor() {
+ this.expectedTabs = new Map();
+ }
+
+ // Check the given tab to match the expected values, update the internal
+ // tracker Map, and cycle through all tabs to make sure they still match
+ // the expected values.
+ async check(description, tabId, expected) {
+ browser.test.log(`TabTest: ${description}`);
+ if (expected.active) {
+ // Mark all other tabs inactive.
+ this.expectedTabs.forEach((v, k) => {
+ v.active = k == tabId;
+ });
+ }
+ // When we call this.check() to cycle thru all tabs, we do not specify
+ // an expected value. Do not update the tracker map in this case.
+ if (!expected.skip) {
+ this.expectedTabs.set(tabId, expected);
+ }
+
+ // Wait till the loaded url is as expected. Only checking the last part,
+ // since running this test with --verify causes multiple accounts to
+ // be created, changing the expected first part of message urls.
+ await window.waitForCondition(async () => {
+ let tab = await browser.tabs.get(tabId);
+ let expected = this.expectedTabs.get(tabId);
+ return tab.status == "complete" && tab.url.endsWith(expected.url);
+ }, `Should have loaded the correct URL in tab ${tabId}`);
+
+ // Check if all existing tabs match their expected values.
+ await this._verify();
+
+ // Cycle though all tabs, if there is more than one and run the check
+ // for each active tab.
+ if (!expected.skip && this.expectedTabs.size > 1) {
+ // Loop over all tabs, activate each and verify all of them. Test the currently active
+ // tab last, so we end up with the original condition.
+ let currentActiveTab = this._toArray().find(tab => tab.active);
+ let tabsToVerify = this._toArray()
+ .filter(tab => tab.id != currentActiveTab.id)
+ .concat(currentActiveTab);
+ for (let tab of tabsToVerify) {
+ await browser.tabs.update(tab.id, { active: true });
+ await this.check("Activating tab " + tab.id, tab.id, {
+ active: true,
+ skip: true,
+ });
+ }
+ }
+ }
+
+ // Return the expectedTabs Map as an array.
+ _toArray() {
+ return Array.from(this.expectedTabs.entries(), tab => {
+ return { id: tab[0], ...tab[1] };
+ });
+ }
+
+ // Verify that all tabs match their currently expected values.
+ async _verify() {
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(
+ this.expectedTabs.size,
+ tabs.length,
+ `number of tabs should be correct`
+ );
+
+ for (let [tabId, expectedTab] of this.expectedTabs) {
+ let tab = await browser.tabs.get(tabId);
+ browser.test.assertEq(
+ expectedTab.active,
+ tab.active,
+ `${tab.type} tab (id:${tabId}) should have the correct active setting`
+ );
+
+ if (expectedTab.hasOwnProperty("message")) {
+ // Getthe currently displayed message.
+ let message = await browser.messageDisplay.getDisplayedMessage(
+ tabId
+ );
+
+ // Test message either being correct or not displayed if not
+ // expected.
+ if (expectedTab.message) {
+ browser.test.assertTrue(
+ !!message,
+ `${tab.type} tab (id:${tabId}) should have a message`
+ );
+ if (message) {
+ browser.test.assertEq(
+ expectedTab.message.id,
+ message.id,
+ `${tab.type} tab (id:${tabId}) should have the correct message`
+ );
+ }
+ } else {
+ browser.test.assertEq(
+ null,
+ message,
+ `${tab.type} tab (id:${tabId}) should not display a message`
+ );
+ }
+ }
+
+ // Testing url parameter.
+ if (expectedTab.url) {
+ browser.test.assertTrue(
+ tab.url.endsWith(expectedTab.url),
+ `${tab.type} tab (id:${tabId}) should display the correct url`
+ );
+ }
+ }
+ }
+ }
+
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let folder1 = accounts[0].folders.find(f => f.name == "test1");
+ browser.test.assertTrue(!!folder1, "folder should exist");
+ let { messages: messages1 } = await browser.messages.list(folder1);
+ browser.test.assertEq(
+ 5,
+ messages1.length,
+ `number of messages should be correct`
+ );
+
+ let folder2 = accounts[0].folders.find(f => f.name == "test2");
+ browser.test.assertTrue(!!folder2, "folder should exist");
+ let { messages: messages2 } = await browser.messages.list(folder2);
+ browser.test.assertEq(
+ 6,
+ messages2.length,
+ `number of messages should be correct`
+ );
+
+ // Test reject on invalid openProperties.
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ messageId: 578 }),
+ `Unknown or invalid messageId: 578.`,
+ "browser.messageDisplay.open() should reject, if invalid messageId is specified"
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ headerMessageId: "1" }),
+ `Unknown or invalid headerMessageId: 1.`,
+ "browser.messageDisplay.open() should reject, if invalid headerMessageId is specified"
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({}),
+ "Exactly one of messageId, headerMessageId or file must be specified.",
+ "browser.messageDisplay.open() should reject, if no messageId and no headerMessageId is specified"
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ messageId: 578, headerMessageId: "1" }),
+ "Exactly one of messageId, headerMessageId or file must be specified.",
+ "browser.messageDisplay.open() should reject, if messageId and headerMessageId are specified"
+ );
+
+ // Create a TabTest to cycle through all existing tabs after each test to
+ // verify returned values under different active/inactive scenarios.
+ let tabTest = new TabTest();
+
+ // Load a content tab into the primary mail tab, to have a known startup
+ // condition.
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(1, tabs.length);
+ let mailTab = tabs[0];
+ await browser.tabs.update(mailTab.id, {
+ url: "https://www.example.com/mailTab/1",
+ });
+ await tabTest.check(
+ "Load a url into the default mail tab.",
+ mailTab.id,
+ {
+ active: true,
+ url: "https://www.example.com/mailTab/1",
+ }
+ );
+
+ // Create an active content tab.
+ let tab1 = await browser.tabs.create({
+ url: "https://www.example.com/contentTab1/1",
+ });
+ await tabTest.check("Create a content tab #1.", tab1.id, {
+ active: true,
+ url: "https://www.example.com/contentTab1/1",
+ });
+
+ // Open an inactive message tab.
+ let tab2 = await browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "tab",
+ active: false,
+ });
+ await tabTest.check("messageDisplay.open with active: false", tab2.id, {
+ active: false,
+ message: messages1[0],
+ // To be able to run this test with --verify, specify only the last part
+ // of the expected message url, which is independent of the associated
+ // account.
+ url: "/localhost/test1?number=1",
+ });
+
+ // Open an active message tab.
+ let tab3 = await browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "tab",
+ active: true,
+ });
+ await tabTest.check(
+ "Opening the same message again should create a new tab.",
+ tab3.id,
+ {
+ active: true,
+ message: messages1[0],
+ url: "/localhost/test1?number=1",
+ }
+ );
+
+ // Open another content tab.
+ let tab4 = await browser.tabs.create({
+ url: "https://www.example.com/contentTab1/2",
+ });
+ await tabTest.check("Create a content tab #2.", tab4.id, {
+ active: true,
+ url: "https://www.example.com/contentTab1/2",
+ });
+
+ await browser.tabs.remove(tab1.id);
+ await browser.tabs.remove(tab2.id);
+ await browser.tabs.remove(tab3.id);
+ await browser.tabs.remove(tab4.id);
+
+ // Test opening multiple tabs.
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[1].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[2].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[3].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[4].id,
+ location: "tab",
+ })
+ );
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < 5; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for tab ${i}`
+ );
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages1[i].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function testOpenMessagesInWindows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let folder1 = accounts[0].folders.find(f => f.name == "test1");
+ browser.test.assertTrue(!!folder1, "folder should exist");
+ let { messages: messages1 } = await browser.messages.list(folder1);
+ browser.test.assertEq(
+ 5,
+ messages1.length,
+ `number of messages should be correct`
+ );
+
+ // Open multiple different windows.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[1].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[2].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[3].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[4].id,
+ location: "window",
+ })
+ );
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ let foundIds = new Set();
+ for (let i = 0; i < 5; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for window ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages1[i].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ // Open multiple identical windows.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ let foundIds = new Set();
+ for (let i = 0; i < 5; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for window ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages1[0].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_MV3_event_pages_onMessageDisplayed() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.messageDisplay.onMessageDisplayed.addListener((tab, message) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onMessageDisplayed received", {
+ tab,
+ message,
+ });
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "onMessageDisplayed@mochi.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["messageDisplay.onMessageDisplayed"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Select a message.
+
+ {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 2;
+
+ let displayInfo = await extension.awaitMessage(
+ "onMessageDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.message.subject,
+ "Huge Shindig Yesterday",
+ "The primed onMessageDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "mail",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessageDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a window.
+
+ {
+ let messageWindow = await openMessageInWindow(gMessages[0]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessageDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.message.subject,
+ "Big Meeting Today",
+ "The primed onMessageDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessageDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ messageWindow.close();
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a tab.
+
+ {
+ await openMessageInTab(gMessages[1]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessageDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.message.subject,
+ "Small Party Tomorrow",
+ "The primed onMessageDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessageDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ document.getElementById("tabmail").closeTab();
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_MV3_event_pages_onMessagesDisplayed() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.messageDisplay.onMessagesDisplayed.addListener(
+ (tab, messages) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onMessagesDisplayed received", {
+ tab,
+ messages,
+ });
+ }
+ }
+ );
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "onMessagesDisplayed@mochi.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["messageDisplay.onMessagesDisplayed"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Select multiple messages.
+
+ {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndices = [0, 1, 2, 3, 4];
+
+ let displayInfo = await extension.awaitMessage(
+ "onMessagesDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.messages.length,
+ 5,
+ "The primed onMessagesDisplayed event should return the correct number of messages."
+ );
+ Assert.deepEqual(
+ [
+ "Big Meeting Today",
+ "Small Party Tomorrow",
+ "Huge Shindig Yesterday",
+ "Tiny Wedding In a Fortnight",
+ "Red Document Needs Attention",
+ ],
+ displayInfo.messages.map(e => e.subject),
+ "The primed onMessagesDisplayed event should return the correct messages."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "mail",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessagesDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a window.
+
+ {
+ let messageWindow = await openMessageInWindow(gMessages[0]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessagesDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.messages.length,
+ 1,
+ "The primed onMessagesDisplayed event should return the correct number of messages."
+ );
+ Assert.equal(
+ displayInfo.messages[0].subject,
+ "Big Meeting Today",
+ "The primed onMessagesDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessagesDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ messageWindow.close();
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a tab.
+
+ {
+ await openMessageInTab(gMessages[1]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessagesDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.messages.length,
+ 1,
+ "The primed onMessagesDisplayed event should return the correct number of messages."
+ );
+ Assert.equal(
+ displayInfo.messages[0].subject,
+ "Small Party Tomorrow",
+ "The primed onMessagesDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessagesDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ document.getElementById("tabmail").closeTab();
+ }
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js
new file mode 100644
index 0000000000..4c48d835b4
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js
@@ -0,0 +1,337 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let messages;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+// This test uses a command from the menus API to open the popup.
+add_task(async function test_popup_open_with_menu_command() {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-menu-command",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ }
+
+ info("Message tab");
+ {
+ await openMessageInTab(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-menu-command",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ document.getElementById("tabmail").closeTab();
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-menu-command",
+ window: messageWindow.messageBrowser.contentWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ messageWindow.close();
+ }
+});
+
+add_task(async function test_theme_icons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let uuid = extension.uuid;
+ let button = aboutMessage.document.getElementById(
+ "message_display_action_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+
+ await extension.unload();
+}).skip(); // TODO (Bug 1828322)
+
+add_task(async function test_button_order() {
+ info("3-pane tab");
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "header-view-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "header-view-toolbar",
+ },
+ ],
+ tabmail.currentAboutMessage,
+ "message_display_action"
+ );
+
+ info("Message tab");
+ await openMessageInTab(messages.getNext());
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "header-view-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "header-view-toolbar",
+ },
+ ],
+ tabmail.currentAboutMessage,
+ "message_display_action"
+ );
+ tabmail.closeTab();
+
+ info("Message window");
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "header-view-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "header-view-toolbar",
+ },
+ ],
+ messageWindow.messageBrowser.contentWindow,
+ "message_display_action"
+ );
+ messageWindow.close();
+});
+
+add_task(async function test_upgrade() {
+ // Add a message_display_action, to make sure the currentSet has been initialized.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension1",
+ applications: { gecko: { id: "Extension1@mochi.test" } },
+ message_display_action: {
+ default_title: "Extension1",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension1 ready");
+ },
+ });
+ await extension1.startup();
+ await extension1.awaitMessage("Extension1 ready");
+
+ // Add extension without a message_display_action.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 ready");
+ },
+ });
+ await extension2.startup();
+ await extension2.awaitMessage("Extension2 ready");
+
+ // Update the extension, now including a message_display_action.
+ let updatedExtension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "2.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ message_display_action: {
+ default_title: "Extension2",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 updated");
+ },
+ });
+ await updatedExtension2.startup();
+ await updatedExtension2.awaitMessage("Extension2 updated");
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let button = aboutMessage.document.getElementById(
+ "extension2_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ Assert.ok(button, "Button should exist");
+ await extension1.unload();
+ await extension2.unload();
+ await updatedExtension2.unload();
+});
+
+add_task(async function test_iconPath() {
+ // String values for the default_icon manifest entry have been tested in the
+ // theme_icons test already. Here we test imagePath objects for the manifest key
+ // and string values as well as objects for the setIcons() function.
+ let files = {
+ "background.js": async () => {
+ await window.sendMessage("checkState", "icon1.png");
+
+ // TODO: Figure out why this isn't working properly.
+ // await browser.messageDisplayAction.setIcon({ path: "icon2.png" });
+ // await window.sendMessage("checkState", "icon2.png");
+
+ // await browser.messageDisplayAction.setIcon({ path: { 16: "icon3.png" } });
+ // await window.sendMessage("checkState", "icon3.png");
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_title: "default",
+ default_icon: { 16: "icon1.png" },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ extension.onMessage("checkState", async expected => {
+ let uuid = extension.uuid;
+ let button = aboutMessage.document.getElementById(
+ "message_display_action_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/${expected}")`,
+ `Icon path should be correct.`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js
new file mode 100644
index 0000000000..96493c475a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js
@@ -0,0 +1,294 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let messages;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+// This test clicks on the action button to open the popup.
+add_task(async function test_popup_open_with_click() {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ }
+
+ info("Message tab");
+ {
+ await openMessageInTab(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ document.getElementById("tabmail").closeTab();
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: messageWindow.messageBrowser.contentWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ messageWindow.close();
+ }
+});
+
+// This test uses openPopup() to open the popup in a message window.
+add_task(async function test_popup_open_with_openPopup_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ let windows = await browser.windows.getAll();
+ let mailWindow = windows.find(window => window.type == "normal");
+ let messageWindow = windows.find(
+ window => window.type == "messageDisplay"
+ );
+ browser.test.assertTrue(!!mailWindow, "should have found a mailWindow");
+ browser.test.assertTrue(
+ !!messageWindow,
+ "should have found a messageWindow"
+ );
+
+ let tabs = await browser.tabs.query({});
+ let mailTab = tabs.find(tab => tab.type == "mail");
+ browser.test.assertTrue(!!mailTab, "should have found a mailTab");
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(mailTab.id);
+ browser.test.assertTrue(!!msg, "should display a message");
+
+ // The test starts with an opened messageWindow, the message_display_action
+ // is allowed there and should be visible, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Specifically open the message_display_action of the mailWindow, since we
+ // loaded a message, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup({
+ windowId: mailWindow.id,
+ }),
+ "openPopup() should have succeeded when explicitly requesting the mailWindow"
+ );
+ await window.waitForMessage();
+ // Mail window should have focus now.
+ browser.test.assertTrue(
+ (await browser.windows.get(mailWindow.id)).focused,
+ "mailWindow should be focused"
+ );
+
+ // Disable the message_display_action, openPopup() should fail.
+ await browser.messageDisplayAction.disable();
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed after the action_button was disabled"
+ );
+
+ // Enable the message_display_action, openPopup() should succeed.
+ await browser.messageDisplayAction.enable();
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded after the action_button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create content tab, the message_display_action is not allowed there and
+ // should not be visible, openPopup() should fail.
+ let contentTab = await browser.tabs.create({
+ url: "https://www.example.com",
+ });
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed while the content tab is active"
+ );
+
+ // Close the content tab and return to the mail space, the message_display_action
+ // should be visible again, openPopup() should succeed.
+ await browser.tabs.remove(contentTab.id);
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded after the content tab was closed"
+ );
+ await window.waitForMessage();
+
+ // Load a webpage into the mailTab, the message_display_action should not
+ // be shown and openPopup() should fail
+ await browser.tabs.update(mailTab.id, { url: "https://www.example.com" });
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed while the mail tab shows a webpage"
+ );
+
+ // Open a message in a tab, the message_display_action should be shown and
+ // openPopup() should succeed.
+ let messageTab = await browser.messageDisplay.open({
+ active: true,
+ location: "tab",
+ messageId: msg.id,
+ windowId: mailWindow.id,
+ });
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded in a message tab"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a message_display_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ (await browser.windows.get(popupWindow.id)).focused,
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the message_display_action of the messageWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup({
+ windowId: messageWindow.id,
+ }),
+ "openPopup() should have succeeded when explicitly requesting the messageWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+
+ // The messageWindow is focused now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Close the popup window, the extra message tab and finish
+ await browser.windows.remove(popupWindow.id);
+ await browser.tabs.remove(messageTab.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js
new file mode 100644
index 0000000000..9f72bf4c99
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let messages;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+async function subtest_popup_open_with_click_MV3_event_pages(
+ terminateBackground
+) {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ }
+
+ info("Message tab");
+ {
+ await openMessageInTab(messages.getNext());
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ tabmail.closeTab();
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: messageWindow.messageBrowser.contentWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ messageWindow.close();
+ }
+}
+// This MV3 test clicks on the action button to open the popup.
+add_task(async function test_event_pages_without_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(false);
+});
+// This MV3 test clicks on the action button to open the popup (background termination).
+add_task(async function test_event_pages_with_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(true);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js
new file mode 100644
index 0000000000..694d352090
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js
@@ -0,0 +1,184 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 1);
+ let [message] = [...folder.messages];
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: folder.URI,
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+
+ await openMessageInTab(message);
+ await openMessageInWindow(message);
+ await new Promise(resolve => executeSoon(resolve));
+
+ let files = {
+ "background.js": async () => {
+ async function checkProperty(property, expectedDefault, ...expected) {
+ browser.test.log(
+ `${property}: ${expectedDefault}, ${expected.join(", ")}`
+ );
+
+ browser.test.assertEq(
+ expectedDefault,
+ await browser.messageDisplayAction[property]({})
+ );
+ for (let i = 0; i < 3; i++) {
+ browser.test.assertEq(
+ expected[i],
+ await browser.messageDisplayAction[property]({ tabId: tabIDs[i] })
+ );
+ }
+
+ await window.sendMessage("checkProperty", property, expected);
+ }
+
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(3, tabs.length);
+ let tabIDs = tabs.map(t => t.id);
+
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.messageDisplayAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.messageDisplayAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.messageDisplayAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.messageDisplayAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.messageDisplayAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.messageDisplayAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[2],
+ title: "tab2",
+ });
+ await checkProperty("getTitle", "default", "default", "default", "tab2");
+ await browser.messageDisplayAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "new", "new", "tab2");
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[1],
+ title: "tab1",
+ });
+ await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[2],
+ title: null,
+ });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await browser.messageDisplayAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[1],
+ title: null,
+ });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+
+ await browser.tabs.remove(tabIDs[0]);
+ await browser.tabs.remove(tabIDs[1]);
+ await browser.tabs.remove(tabIDs[2]);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action_properties@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ message_display_action: {
+ default_title: "default",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let mainWindowTabs = tabmail.tabInfo;
+ is(mainWindowTabs.length, 2);
+
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let messageWindowButton =
+ messageWindow.messageBrowser.contentDocument.getElementById(
+ "message_display_action_properties_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ extension.onMessage("checkProperty", async (property, expected) => {
+ function checkButton(button, expectedIndex) {
+ switch (property) {
+ case "isEnabled":
+ is(
+ button.disabled,
+ !expected[expectedIndex],
+ `button ${expectedIndex} enabled state`
+ );
+ break;
+ case "getTitle":
+ is(
+ button.getAttribute("label"),
+ expected[expectedIndex],
+ `button ${expectedIndex} label`
+ );
+ break;
+ }
+ }
+
+ for (let i = 0; i < 2; i++) {
+ tabmail.switchToTab(mainWindowTabs[i]);
+ let aboutMessage = mainWindowTabs[i].chromeBrowser.contentWindow;
+ if (aboutMessage.location.href == "about:3pane") {
+ aboutMessage = aboutMessage.messageBrowser.contentWindow;
+ }
+ await new Promise(resolve => aboutMessage.requestAnimationFrame(resolve));
+ checkButton(
+ aboutMessage.document.getElementById(
+ "message_display_action_properties_mochi_test-messageDisplayAction-toolbarbutton"
+ ),
+ i
+ );
+ }
+ checkButton(messageWindowButton, 2);
+
+ extension.sendMessage();
+ });
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+ tabmail.closeOtherTabs(0);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js
new file mode 100644
index 0000000000..3f75bcb61c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js
@@ -0,0 +1,636 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account, messages;
+let tabmail, about3Pane, messagePane;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("messageDisplayScripts", null);
+ let folder = rootFolder.getChildNamed("messageDisplayScripts");
+ createMessages(folder, 11);
+ messages = [...folder.messages];
+
+ tabmail = document.getElementById("tabmail");
+ about3Pane = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ about3Pane.displayFolder(folder.URI);
+ messagePane =
+ about3Pane.messageBrowser.contentDocument.getElementById("messagepane");
+});
+
+async function checkMessageBody(expected, message, browser) {
+ if (message && "textContent" in expected) {
+ let body = await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(message, null, (msgHdr, mimeMessage) => {
+ resolve(mimeMessage.parts[0].body);
+ });
+ });
+ // Ignore Windows line-endings, they're not important here.
+ body = body.replace(/\r/g, "");
+ expected.textContent = body + expected.textContent;
+ }
+ if (!browser) {
+ browser = messagePane;
+ }
+
+ await checkContent(browser, expected);
+}
+
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
+add_task(async function testInsertRemoveCSS() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgba(0, 0, 0, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgb(0, 255, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgba(0, 0, 0, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgb(0, 128, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody({ backgroundColor: "rgba(0, 0, 0, 0)" }, messages[0]);
+
+ await extension.unload();
+});
+
+/** Tests browser.tabs.insertCSS fails without the "messagesModify" permission. */
+add_task(async function testInsertRemoveCSSNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: darkred; }",
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ file: "test.css",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 1;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ messages[1]
+ );
+
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript. */
+add_task(async function testExecuteScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, { file: "test.js" });
+
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 2;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ textContent: "" }, messages[2]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ foo: "bar" }, messages[2]);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ {
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[2]
+ );
+
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript fails without the "messagesModify" permission. */
+add_task(async function testExecuteScriptNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { file: "test.js" }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ file: "test.js",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 3;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody({ foo: null, textContent: "" }, messages[3]);
+
+ await extension.unload();
+});
+
+/** Tests the messenger alias is available. */
+add_task(async function testExecuteScriptAlias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.querySelector(".moz-text-flowed").textContent +=
+ messenger.runtime.getManifest().applications.gecko.id;`,
+ });
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ applications: { gecko: { id: "message_display_scripts@mochitest" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 4;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ textContent: "" }, messages[4]);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ { textContent: "message_display_scripts@mochitest" },
+ messages[4]
+ );
+
+ await extension.unload();
+});
+
+/**
+ * Tests browser.messageDisplayScripts.register correctly adds CSS and
+ * JavaScript to message display windows. Also tests calling `unregister`
+ * on the returned object.
+ */
+add_task(async function testRegister() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Keep track of registered scrips being executed and ready.
+ browser.runtime.onMessage.addListener((message, sender) => {
+ if (message == "LOADED") {
+ window.sendMessage("ScriptLoaded", sender.tab.id);
+ }
+ });
+
+ let registeredScript = await browser.messageDisplayScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ });
+
+ browser.test.onMessage.addListener(async (message, data) => {
+ switch (message) {
+ case "Unregister":
+ await registeredScript.unregister();
+ browser.test.notifyPass("finished");
+ break;
+
+ case "RuntimeMessageTest":
+ try {
+ browser.test.assertEq(
+ `Received: ${data.tabId}`,
+ await browser.tabs.sendMessage(data.tabId, data.tabId)
+ );
+ } catch (ex) {
+ browser.test.fail(
+ `Failed to send message to messageDisplayScript: ${ex}`
+ );
+ }
+ browser.test.sendMessage("RuntimeMessageTestDone");
+ break;
+ }
+ });
+
+ window.sendMessage("Ready");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ browser.runtime.onMessage.addListener(async message => {
+ return `Received: ${message}`;
+ });
+ browser.runtime.sendMessage("LOADED");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify", "<all_urls>"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 5;
+ await awaitBrowserLoaded(messagePane);
+
+ extension.startup();
+ await extension.awaitMessage("Ready");
+
+ // Check a message that was already loaded. This tab has not loaded the
+ // registered scripts.
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ messages[5]
+ );
+
+ // Load a new message and check it is modified.
+ let loadPromise = extension.awaitMessage("ScriptLoaded");
+ about3Pane.threadTree.selectedIndex = 6;
+ let tabId = await loadPromise;
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6]
+ );
+ // Check runtime messaging.
+ let testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId });
+ await testDonePromise;
+
+ // Open the message in a new tab.
+ loadPromise = extension.awaitMessage("ScriptLoaded");
+ let messageTab = await openMessageInTab(messages[6]);
+ let messageTabId = await loadPromise;
+ Assert.equal(tabmail.tabInfo.length, 2);
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6],
+ messageTab.browser
+ );
+ // Check runtime messaging.
+ testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId: messageTabId });
+ await testDonePromise;
+
+ // Open a content tab. The CSS and script shouldn't apply.
+ let contentTab = window.openContentTab("http://mochi.test:8888/");
+ // Let's wait a while and see if anything happens:
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: null,
+ },
+ undefined,
+ contentTab.browser
+ );
+
+ // Closing this tab should bring us back to the message in a tab.
+ tabmail.closeTab(contentTab);
+ Assert.equal(tabmail.currentTabInfo, messageTab);
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6],
+ messageTab.browser
+ );
+ // Check runtime messaging.
+ testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId: messageTabId });
+ await testDonePromise;
+
+ // Open the message in a new window.
+ loadPromise = extension.awaitMessage("ScriptLoaded");
+ let newWindow = await openMessageInWindow(messages[7]);
+ let newWindowMessagePane = newWindow.getBrowser();
+ let windowTabId = await loadPromise;
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[7],
+ newWindowMessagePane
+ );
+ // Check runtime messaging.
+ testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId: windowTabId });
+ await testDonePromise;
+
+ // Unregister.
+ extension.sendMessage("Unregister");
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Check the CSS is unloaded from the message in a tab.
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6],
+ messageTab.browser
+ );
+
+ // Close the new tab.
+ tabmail.closeTab(messageTab);
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6]
+ );
+
+ // Check the CSS is unloaded from the message in a window.
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[7],
+ newWindowMessagePane
+ );
+
+ await BrowserTestUtils.closeWindow(newWindow);
+});
+
+/** Tests content_scripts in the manifest do not affect message display. */
+async function subtestContentScriptManifest(message, ...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent += "Hey look, the script ran!";
+ },
+ },
+ manifest: {
+ permissions,
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["test.css"],
+ js: ["test.js"],
+ match_about_blank: true,
+ match_origin_as_fallback: true,
+ },
+ ],
+ },
+ });
+
+ // match_origin_as_fallback is not implemented yet. Bug 1475831.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ message
+ );
+
+ await extension.unload();
+}
+
+add_task(async function testContentScriptManifestNoPermission() {
+ about3Pane.threadTree.selectedIndex = 7;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptManifest(messages[7]);
+});
+add_task(async function testContentScriptManifest() {
+ about3Pane.threadTree.selectedIndex = 8;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptManifest(messages[8], "messagesModify");
+});
+
+/** Tests registered content scripts do not affect message display. */
+async function subtestContentScriptRegister(message, ...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ await browser.contentScripts.register({
+ matches: ["<all_urls>"],
+ css: [{ file: "test.css" }],
+ js: [{ file: "test.js" }],
+ matchAboutBlank: true,
+ });
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ message
+ );
+
+ await extension.unload();
+}
+
+add_task(async function testContentScriptRegisterNoPermission() {
+ about3Pane.threadTree.selectedIndex = 9;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptRegister(messages[9], "<all_urls>");
+});
+add_task(async function testContentScriptRegister() {
+ about3Pane.threadTree.selectedIndex = 10;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptRegister(
+ messages[10],
+ "<all_urls>",
+ "messagesModify"
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js
new file mode 100644
index 0000000000..ebae544585
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test to make sure messageDisplay.getDisplayedMessage() returns null for
+ * non-message tabs.
+ */
+add_task(async function testGetDisplayedMessageInComposeTab() {
+ let files = {
+ "background.js": async () => {
+ let composeTab = await browser.compose.beginNew();
+ browser.test.assertEq(
+ composeTab.type,
+ "messageCompose",
+ "Should have found a compose tab"
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(composeTab.id);
+ browser.test.assertTrue(!msg, "Should not have found a message");
+
+ await browser.tabs.remove(composeTab.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js
new file mode 100644
index 0000000000..70b9670ac1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js
@@ -0,0 +1,212 @@
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+async function getTestExtension_open_msg() {
+ let files = {
+ "background.js": async () => {
+ let [location] = await window.waitForMessage();
+
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Get displayed message.
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+
+ // Open a message in the specified location and request the displayed
+ // message immediately.
+ let { message: message2, tab: messageTab } = await new Promise(
+ resolve => {
+ let createListener = async tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ let message = await browser.messageDisplay.getDisplayedMessage(
+ tab.id
+ );
+ resolve({ tab, message });
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ browser.messageDisplay.open({
+ location,
+ messageId: message1.id,
+ });
+ }
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2?.id,
+ "We should see the same message."
+ );
+ browser.tabs.remove(messageTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ return ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+}
+
+/**
+ * Open a message tab and request its message immediately.
+ */
+add_task(async function test_message_tab() {
+ let extension = await getTestExtension_open_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("tab");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Open a message window and request its message immediately.
+ */
+add_task(async function test_message_window() {
+ let extension = await getTestExtension_open_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("window");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+async function getTestExtension_select_msg() {
+ let files = {
+ "background.js": async () => {
+ let [expected] = await window.waitForMessage();
+
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Get displayed message.
+ let message = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(!!message, "We should have a displayed message.");
+
+ await window.sendMessage("select");
+ let messages = await browser.messageDisplay.getDisplayedMessages(
+ mailTab.id
+ );
+ browser.test.assertEq(
+ expected,
+ messages.length,
+ "The returned number of messages should be correct."
+ );
+ for (let msg of messages) {
+ browser.test.assertTrue(
+ message.id != msg.id,
+ "The returned message must not be the original selected message."
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ return ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+}
+
+/**
+ * Select a single message in a mail tab and request it immediately.
+ */
+add_task(async function test_single_message() {
+ let extension = await getTestExtension_select_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ extension.onMessage("select", () => {
+ about3Pane.threadTree.selectedIndex = 1;
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ extension.sendMessage(1);
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Select multiple messages in a mail tab and request them immediately.
+ */
+add_task(async function test_multiple_message() {
+ let extension = await getTestExtension_select_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ extension.onMessage("select", () => {
+ about3Pane.threadTree.selectedIndices = [2, 3];
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ extension.sendMessage(2);
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js
new file mode 100644
index 0000000000..a3b5c8cf0f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js
@@ -0,0 +1,221 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(4);
+
+let gRootFolder;
+add_setup(async () => {
+ let account = createAccount();
+ gRootFolder = account.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("testFolder", null);
+ gRootFolder.createSubfolder("otherFolder", null);
+ await createMessages(gRootFolder.getChildNamed("testFolder"), 5);
+});
+
+async function testOpenMessages(testConfig) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let testFolder = accounts[0].folders.find(f => f.name == "testFolder");
+ browser.test.assertTrue(!!testFolder, "folder should exist");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ `number of messages should be correct`
+ );
+
+ // Get test properties.
+ let [testConfig] = await window.sendMessage("getTestConfig");
+
+ async function open(message, testConfig) {
+ let properties = { ...testConfig };
+ if (properties.headerMessageId) {
+ properties.headerMessageId = message.headerMessageId;
+ } else if (properties.messageId) {
+ properties.messageId = message.id;
+ } else if (properties.file) {
+ properties.file = new File(
+ [await browser.messages.getRaw(message.id)],
+ "msgfile.eml"
+ );
+ }
+ return browser.messageDisplay.open(properties);
+ }
+
+ let expectedFail;
+ let additionalWindowIdToBeRemoved;
+ if (testConfig.windowType) {
+ switch (testConfig.windowType) {
+ case "normal":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ }
+ break;
+ case "popup":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ expectedFail = `Window with ID ${secondWindow.id} is not a normal window`;
+ }
+ break;
+ case "invalid":
+ testConfig.windowId = 1234;
+ expectedFail = `Invalid window ID: 1234`;
+ break;
+ }
+ delete testConfig.windowType;
+ }
+
+ if (expectedFail) {
+ await browser.test.assertRejects(
+ open(messages[0], testConfig),
+ `${expectedFail}`,
+ "browser.messageDisplay.open() should fail with invalid windowId"
+ );
+ } else {
+ // Open multiple messages.
+ let promisedTabs = [];
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < openedTabs.length; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for message ${i}`
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ if (testConfig.file) {
+ browser.test.assertTrue(
+ messages[Math.floor(i / 2)].id != msg.id,
+ `Opened file msg should have a new message id (${
+ msg.id
+ }) and should not equal the id of the source message (${
+ messages[Math.floor(i / 2)].id
+ }) in window ${i}`
+ );
+ } else {
+ browser.test.assertEq(
+ messages[Math.floor(i / 2)].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ }
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ if (additionalWindowIdToBeRemoved) {
+ await browser.windows.remove(additionalWindowIdToBeRemoved);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gRootFolder.getChildNamed("otherFolder"));
+
+ extension.onMessage("getTestConfig", async () => {
+ extension.sendMessage(testConfig);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testMessageFileActiveDefault() {
+ await testOpenMessages({ file: true, active: true });
+});
+add_task(async function testMessageFileInactiveDefault() {
+ await testOpenMessages({ file: true, active: false });
+});
+add_task(async function testMessageFileActiveWindow() {
+ await testOpenMessages({
+ file: true,
+ active: true,
+ location: "window",
+ });
+});
+add_task(async function testMessageFileInactiveWindow() {
+ await testOpenMessages({
+ file: true,
+ active: false,
+ location: "window",
+ });
+});
+add_task(async function testMessageFileActiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: true,
+ location: "tab",
+ });
+});
+add_task(async function testMessageFileInactiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: false,
+ location: "tab",
+ });
+});
+add_task(async function testMessageFileOtherNormalWindowActiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: true,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageFileOtherNormalWindowInactiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: false,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageFileOtherPopupWindowFail() {
+ await testOpenMessages({
+ file: true,
+ location: "tab",
+ windowType: "popup",
+ });
+});
+add_task(async function testMessageFileInvalidWindowFail() {
+ await testOpenMessages({
+ file: true,
+ location: "tab",
+ windowType: "invalid",
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js
new file mode 100644
index 0000000000..8be6aa4c2b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js
@@ -0,0 +1,221 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(4);
+
+let gRootFolder;
+add_setup(async () => {
+ let account = createAccount();
+ gRootFolder = account.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("testFolder", null);
+ gRootFolder.createSubfolder("otherFolder", null);
+ await createMessages(gRootFolder.getChildNamed("testFolder"), 5);
+});
+
+async function testOpenMessages(testConfig) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let testFolder = accounts[0].folders.find(f => f.name == "testFolder");
+ browser.test.assertTrue(!!testFolder, "folder should exist");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ `number of messages should be correct`
+ );
+
+ // Get test properties.
+ let [testConfig] = await window.sendMessage("getTestConfig");
+
+ async function open(message, testConfig) {
+ let properties = { ...testConfig };
+ if (properties.headerMessageId) {
+ properties.headerMessageId = message.headerMessageId;
+ } else if (properties.messageId) {
+ properties.messageId = message.id;
+ } else if (properties.file) {
+ properties.file = new File(
+ [await browser.messages.getRaw(message.id)],
+ "msgfile.eml"
+ );
+ }
+ return browser.messageDisplay.open(properties);
+ }
+
+ let expectedFail;
+ let additionalWindowIdToBeRemoved;
+ if (testConfig.windowType) {
+ switch (testConfig.windowType) {
+ case "normal":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ }
+ break;
+ case "popup":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ expectedFail = `Window with ID ${secondWindow.id} is not a normal window`;
+ }
+ break;
+ case "invalid":
+ testConfig.windowId = 1234;
+ expectedFail = `Invalid window ID: 1234`;
+ break;
+ }
+ delete testConfig.windowType;
+ }
+
+ if (expectedFail) {
+ await browser.test.assertRejects(
+ open(messages[0], testConfig),
+ `${expectedFail}`,
+ "browser.messageDisplay.open() should fail with invalid windowId"
+ );
+ } else {
+ // Open multiple messages.
+ let promisedTabs = [];
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < openedTabs.length; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for message ${i}`
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ if (testConfig.file) {
+ browser.test.assertTrue(
+ messages[Math.floor(i / 2)].id != msg.id,
+ `Opened file msg should have a new message id (${
+ msg.id
+ }) and should not equal the id of the source message (${
+ messages[Math.floor(i / 2)].id
+ }) in window ${i}`
+ );
+ } else {
+ browser.test.assertEq(
+ messages[Math.floor(i / 2)].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ }
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ if (additionalWindowIdToBeRemoved) {
+ await browser.windows.remove(additionalWindowIdToBeRemoved);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gRootFolder.getChildNamed("otherFolder"));
+
+ extension.onMessage("getTestConfig", async () => {
+ extension.sendMessage(testConfig);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testHeaderMessageIdActiveDefault() {
+ await testOpenMessages({ headerMessageId: true, active: true });
+});
+add_task(async function testHeaderMessageIdInactiveDefault() {
+ await testOpenMessages({ headerMessageId: true, active: false });
+});
+add_task(async function testHeaderMessageIdActiveWindow() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: true,
+ location: "window",
+ });
+});
+add_task(async function testHeaderMessageIdInactiveWindow() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: false,
+ location: "window",
+ });
+});
+add_task(async function testHeaderMessageIdActiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: true,
+ location: "tab",
+ });
+});
+add_task(async function testHeaderMessageIdInactiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: false,
+ location: "tab",
+ });
+});
+add_task(async function testHeaderMessageIdOtherNormalWindowActiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: true,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testHeaderMessageIdOtherNormalWindowInactiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: false,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testHeaderMessageIdOtherPopupWindowFail() {
+ await testOpenMessages({
+ headerMessageId: true,
+ location: "tab",
+ windowType: "popup",
+ });
+});
+add_task(async function testHeaderMessageIdInvalidWindowFail() {
+ await testOpenMessages({
+ headerMessageId: true,
+ location: "tab",
+ windowType: "invalid",
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js
new file mode 100644
index 0000000000..47995d9ecd
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js
@@ -0,0 +1,221 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(4);
+
+let gRootFolder;
+add_setup(async () => {
+ let account = createAccount();
+ gRootFolder = account.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("testFolder", null);
+ gRootFolder.createSubfolder("otherFolder", null);
+ await createMessages(gRootFolder.getChildNamed("testFolder"), 5);
+});
+
+async function testOpenMessages(testConfig) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let testFolder = accounts[0].folders.find(f => f.name == "testFolder");
+ browser.test.assertTrue(!!testFolder, "folder should exist");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ `number of messages should be correct`
+ );
+
+ // Get test properties.
+ let [testConfig] = await window.sendMessage("getTestConfig");
+
+ async function open(message, testConfig) {
+ let properties = { ...testConfig };
+ if (properties.headerMessageId) {
+ properties.headerMessageId = message.headerMessageId;
+ } else if (properties.messageId) {
+ properties.messageId = message.id;
+ } else if (properties.file) {
+ properties.file = new File(
+ [await browser.messages.getRaw(message.id)],
+ "msgfile.eml"
+ );
+ }
+ return browser.messageDisplay.open(properties);
+ }
+
+ let expectedFail;
+ let additionalWindowIdToBeRemoved;
+ if (testConfig.windowType) {
+ switch (testConfig.windowType) {
+ case "normal":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ }
+ break;
+ case "popup":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ expectedFail = `Window with ID ${secondWindow.id} is not a normal window`;
+ }
+ break;
+ case "invalid":
+ testConfig.windowId = 1234;
+ expectedFail = `Invalid window ID: 1234`;
+ break;
+ }
+ delete testConfig.windowType;
+ }
+
+ if (expectedFail) {
+ await browser.test.assertRejects(
+ open(messages[0], testConfig),
+ `${expectedFail}`,
+ "browser.messageDisplay.open() should fail with invalid windowId"
+ );
+ } else {
+ // Open multiple messages.
+ let promisedTabs = [];
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < openedTabs.length; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for message ${i}`
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ if (testConfig.file) {
+ browser.test.assertTrue(
+ messages[Math.floor(i / 2)].id != msg.id,
+ `Opened file msg should have a new message id (${
+ msg.id
+ }) and should not equal the id of the source message (${
+ messages[Math.floor(i / 2)].id
+ }) in window ${i}`
+ );
+ } else {
+ browser.test.assertEq(
+ messages[Math.floor(i / 2)].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ }
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ if (additionalWindowIdToBeRemoved) {
+ await browser.windows.remove(additionalWindowIdToBeRemoved);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gRootFolder.getChildNamed("otherFolder"));
+
+ extension.onMessage("getTestConfig", async () => {
+ extension.sendMessage(testConfig);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testMessageIdActiveDefault() {
+ await testOpenMessages({ messageId: true, active: true });
+});
+add_task(async function testMessageIdInactiveDefault() {
+ await testOpenMessages({ messageId: true, active: false });
+});
+add_task(async function testMessageIdActiveWindow() {
+ await testOpenMessages({
+ messageId: true,
+ active: true,
+ location: "window",
+ });
+});
+add_task(async function testMessageIdInactiveWindow() {
+ await testOpenMessages({
+ messageId: true,
+ active: false,
+ location: "window",
+ });
+});
+add_task(async function testMessageIdActiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: true,
+ location: "tab",
+ });
+});
+add_task(async function testMessageIdInActiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: false,
+ location: "tab",
+ });
+});
+add_task(async function testMessageIdOtherNormalWindowActiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: true,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageIdOtherNormalWindowInactiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: false,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageIdOtherPopupWindowFail() {
+ await testOpenMessages({
+ messageId: true,
+ location: "tab",
+ windowType: "popup",
+ });
+});
+add_task(async function testMessageIdInvalidWindowFail() {
+ await testOpenMessages({
+ messageId: true,
+ location: "tab",
+ windowType: "invalid",
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_message_external.js b/comm/mail/components/extensions/test/browser/browser_ext_message_external.js
new file mode 100644
index 0000000000..8a4cf7ea30
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_message_external.js
@@ -0,0 +1,427 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var gAccount;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+ gFolder = rootFolder.getChildNamed("test0");
+ createMessages(gFolder, 5);
+});
+
+add_task(async function testExternalMessage() {
+ // Copy eml file into the profile folder, where we can delete it during the test.
+ let profileDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ profileDir.initWithPath(PathUtils.profileDir);
+ let messageFile = new FileUtils.File(
+ getTestFilePath("messages/attachedMessageSample.eml")
+ );
+ messageFile.copyTo(profileDir, "attachedMessageSample.eml");
+
+ let files = {
+ "background.js": async () => {
+ let platformInfo = await browser.runtime.getPlatformInfo();
+
+ const emlData = {
+ openExternalFileMessage: {
+ headerMessageId: "sample.eml@mime.sample",
+ author: "Batman <bruce@wayne-enterprises.com>",
+ ccList: ["Robin <damian@wayne-enterprises.com>"],
+ subject: "Attached message with attachments",
+ attachments: 2,
+ size: 9754,
+ external: true,
+ read: null,
+ recipients: ["Heinz <mueller@example.com>"],
+ date: 958796995000,
+ body: "This message has one normal attachment and one email attachment",
+ },
+ openExternalAttachedMessage: {
+ headerMessageId: "sample-attached.eml@mime.sample",
+ author: "Superman <clark.kent@dailyplanet.com>",
+ ccList: ["Jimmy <jimmy.Olsen@dailyplanet.com>"],
+ subject: "Test message",
+ attachments: 3,
+ size: platformInfo.os == "win" ? 6947 : 6825, // Line endings.
+ external: true,
+ read: null,
+ recipients: ["Heinz Müller <mueller@examples.com>"],
+ date: 958606367000,
+ body: "Die Hasen und die Frösche",
+ },
+ };
+
+ let [{ displayedFolder, windowId: mainWindowId }] =
+ await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+
+ // Open an external file, either from file or via API.
+ async function openAndVerifyExternalMessage(
+ actionOrMessageId,
+ location,
+ expected
+ ) {
+ let tabPromise = window.waitForEvent("tabs.onCreated");
+ let messagePromise = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+
+ let returnedMsgTab;
+ if (Number.isInteger(actionOrMessageId)) {
+ returnedMsgTab = await browser.messageDisplay.open({
+ messageId: actionOrMessageId,
+ location,
+ });
+ } else {
+ await window.sendMessage(actionOrMessageId, location);
+ }
+ let [msgTab] = await tabPromise;
+ let [openedMsgTab, message] = await messagePromise;
+
+ if ("windowId" in expected) {
+ browser.test.assertEq(
+ expected.windowId,
+ msgTab.windowId,
+ "The opened tab should belong to the correct window"
+ );
+ } else {
+ browser.test.assertTrue(
+ msgTab.windowId != mainWindowId,
+ "The opened tab should not belong to the main window"
+ );
+ }
+ browser.test.assertEq(
+ msgTab.id,
+ openedMsgTab.id,
+ "The opened tab should match the onMessageDisplayed event tab"
+ );
+
+ if (Number.isInteger(actionOrMessageId)) {
+ browser.test.assertEq(
+ msgTab.id,
+ returnedMsgTab.id,
+ "The returned tab should match the onMessageDisplayed event tab"
+ );
+ }
+
+ if ("messageId" in expected) {
+ browser.test.assertEq(
+ expected.messageId,
+ message.id,
+ "The message should have the same ID as it did previously"
+ );
+ }
+
+ // Test the received message and the re-queried message.
+ for (let msg of [message, await browser.messages.get(message.id)]) {
+ browser.test.assertEq(
+ message.id,
+ msg.id,
+ "`The opened message should be correct."
+ );
+ browser.test.assertEq(
+ expected.author,
+ msg.author,
+ "The author should be correct"
+ );
+ browser.test.assertEq(
+ expected.headerMessageId,
+ msg.headerMessageId,
+ "The headerMessageId should be correct"
+ );
+ browser.test.assertEq(
+ expected.subject,
+ msg.subject,
+ "The subject should be correct"
+ );
+ browser.test.assertEq(
+ expected.size,
+ msg.size,
+ "The size should be correct"
+ );
+ browser.test.assertEq(
+ expected.external,
+ msg.external,
+ "The external flag should be correct"
+ );
+ browser.test.assertEq(
+ expected.date,
+ msg.date.getTime(),
+ "The date should be correct"
+ );
+ window.assertDeepEqual(
+ expected.recipients,
+ msg.recipients,
+ "The recipients should be correct"
+ );
+ window.assertDeepEqual(
+ expected.ccList,
+ msg.ccList,
+ "The carbon copy recipients should be correct"
+ );
+ }
+
+ let raw = await browser.messages.getRaw(message.id);
+ browser.test.assertTrue(
+ raw.startsWith(`Message-ID: <${expected.headerMessageId}>`),
+ "Raw msg should be correct"
+ );
+
+ let full = await browser.messages.getFull(message.id);
+ browser.test.assertTrue(
+ full.headers["message-id"].includes(`<${expected.headerMessageId}>`),
+ "Message-ID of full msg should be correct"
+ );
+ browser.test.assertTrue(
+ full.parts[0].parts[0].body.includes(expected.body),
+ "Body of full msg should be correct"
+ );
+
+ let attachments = await browser.messages.listAttachments(message.id);
+ browser.test.assertEq(
+ expected.attachments,
+ attachments.length,
+ "Should find the correct number of attachments"
+ );
+
+ await browser.tabs.remove(msgTab.id);
+ return message;
+ }
+
+ // Check API operations on the given message.
+ async function testMessageOperations(message) {
+ // Test copying a file message into Thunderbird.
+ let { messages: messagesBeforeCopy } = await browser.messages.list(
+ displayedFolder
+ );
+ await browser.messages.copy([message.id], displayedFolder);
+ let { messages: messagesAfterCopy } = await browser.messages.list(
+ displayedFolder
+ );
+ browser.test.assertEq(
+ messagesBeforeCopy.length + 1,
+ messagesAfterCopy.length,
+ "The file message should have been copied into the current folder"
+ );
+ let { messages } = await browser.messages.query({
+ folder: displayedFolder,
+ headerMessageId: message.headerMessageId,
+ });
+ browser.test.assertTrue(
+ messages.length == 1,
+ "A query should find the new copied file message in the current folder"
+ );
+
+ // All other operations should fail.
+ await browser.test.assertRejects(
+ browser.messages.update(message.id, {}),
+ `Error updating message: Operation not permitted for external messages`,
+ "Updating external messages should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.delete([message.id]),
+ `Error deleting message: Operation not permitted for external messages`,
+ "Deleting external messages should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.archive([message.id]),
+ `Error archiving message: Operation not permitted for external messages`,
+ "Archiving external messages should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.move([message.id], displayedFolder),
+ `Error moving message: Operation not permitted for external messages`,
+ "Moving external messages should throw."
+ );
+
+ return messages[0];
+ }
+
+ // Open an external message in a tab and check its details.
+ let externalMessage = await openAndVerifyExternalMessage(
+ "openExternalFileMessage",
+ "tab",
+ { ...emlData.openExternalFileMessage, windowId: mainWindowId }
+ );
+ // Open and check the same message in a window.
+ await openAndVerifyExternalMessage("openExternalFileMessage", "window", {
+ ...emlData.openExternalFileMessage,
+ messageId: externalMessage.id,
+ });
+ // Open and check the same message in a tab, using the API.
+ await openAndVerifyExternalMessage(externalMessage.id, "tab", {
+ ...emlData.openExternalFileMessage,
+ messageId: externalMessage.id,
+ windowId: mainWindowId,
+ });
+ // Open and check the same message in a window, using the API.
+ await openAndVerifyExternalMessage(externalMessage.id, "window", {
+ ...emlData.openExternalFileMessage,
+ messageId: externalMessage.id,
+ });
+
+ // Test operations on the external message. This will put a copy in a
+ // folder that we can use for the next step.
+ let copiedMessage = await testMessageOperations(externalMessage);
+ let messagePromise = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+ await browser.mailTabs.setSelectedMessages([copiedMessage.id]);
+ await messagePromise;
+
+ // Open an attached message in a tab and check its details.
+ let attachedMessage = await openAndVerifyExternalMessage(
+ "openExternalAttachedMessage",
+ "tab",
+ { ...emlData.openExternalAttachedMessage, windowId: mainWindowId }
+ );
+ // Open and check the same message in a window.
+ await openAndVerifyExternalMessage(
+ "openExternalAttachedMessage",
+ "window",
+ {
+ ...emlData.openExternalAttachedMessage,
+ messageId: attachedMessage.id,
+ }
+ );
+ // Open and check the same message in a tab, using the API.
+ await openAndVerifyExternalMessage(attachedMessage.id, "tab", {
+ ...emlData.openExternalAttachedMessage,
+ messageId: attachedMessage.id,
+ windowId: mainWindowId,
+ });
+ // Open and check the same message in a window, using the API.
+ await openAndVerifyExternalMessage(attachedMessage.id, "window", {
+ ...emlData.openExternalAttachedMessage,
+ messageId: attachedMessage.id,
+ });
+
+ // Test operations on the attached message.
+ await testMessageOperations(attachedMessage);
+
+ // Delete the local eml file to trigger access errors.
+ await window.sendMessage(`deleteExternalMessage`);
+
+ await browser.test.assertRejects(
+ browser.messages.update(externalMessage.id, {}),
+ `Error updating message: Message not found: ${externalMessage.id}.`,
+ "Updating a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.delete([externalMessage.id]),
+ `Error deleting message: Message not found: ${externalMessage.id}.`,
+ "Deleting a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.archive([externalMessage.id]),
+ `Error archiving message: Message not found: ${externalMessage.id}.`,
+ "Archiving a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.move([externalMessage.id], displayedFolder),
+ `Error moving message: Message not found: ${externalMessage.id}.`,
+ "Moving a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.copy([externalMessage.id], displayedFolder),
+ `Error copying message: Message not found: ${externalMessage.id}.`,
+ "Copying a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ messageId: externalMessage.id }),
+ `Unknown or invalid messageId: ${externalMessage.id}.`,
+ "Opening a missing message should throw."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [
+ "accountsRead",
+ "messagesRead",
+ "messagesMove",
+ "messagesDelete",
+ ],
+ },
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.displayFolder(gFolder.URI);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ extension.onMessage("openExternalFileMessage", async location => {
+ let messagePath = PathUtils.join(
+ PathUtils.profileDir,
+ "attachedMessageSample.eml"
+ );
+ let messageFile = new FileUtils.File(messagePath);
+ let url = Services.io
+ .newFileURI(messageFile)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior[
+ location == "window" ? "NEW_WINDOW" : "NEW_TAB"
+ ]
+ );
+
+ MailUtils.openEMLFile(window, messageFile, url);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("openExternalAttachedMessage", async location => {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior[
+ location == "window" ? "NEW_WINDOW" : "NEW_TAB"
+ ]
+ );
+
+ // The message with attachment should be loaded in the 3-pane tab.
+ let aboutMessage = tabmail.currentAboutMessage;
+ aboutMessage.toggleAttachmentList(true);
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.querySelector(".attachmentItem"),
+ { clickCount: 2 },
+ aboutMessage
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("deleteExternalMessage", async () => {
+ let messagePath = PathUtils.join(
+ PathUtils.profileDir,
+ "attachedMessageSample.eml"
+ );
+ let messageFile = new FileUtils.File(messagePath);
+ messageFile.remove(false);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js b/comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js
new file mode 100644
index 0000000000..c4e38465f7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_setup(async () => {
+ MailServices.accounts.createLocalMailAccount();
+ let localRoot =
+ MailServices.accounts.localFoldersServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ let folder = localRoot.createLocalSubfolder("AttachmentA");
+ await createMessageFromFile(
+ folder,
+ getTestFilePath("messages/attachedMessageSample.eml")
+ );
+});
+
+add_task(async function testOpenAttachment() {
+ let files = {
+ "background.js": async () => {
+ let { messages } = await browser.messages.query({
+ headerMessageId: "sample.eml@mime.sample",
+ });
+
+ async function testTab(tab) {
+ let tabPromise = window.waitForEvent("tabs.onCreated");
+ let messagePromise = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+ await browser.messages.openAttachment(
+ messages[0].id,
+ // Open the eml attachment.
+ "1.2",
+ tab.id
+ );
+
+ let [msgTab] = await tabPromise;
+ let [openedMsgTab, message] = await messagePromise;
+
+ browser.test.assertEq(
+ msgTab.id,
+ openedMsgTab.id,
+ "The opened tab should match the onMessageDisplayed event tab"
+ );
+ browser.test.assertEq(
+ message.headerMessageId,
+ "sample-attached.eml@mime.sample",
+ "Should have opened the correct message"
+ );
+
+ await browser.tabs.remove(msgTab.id);
+ }
+
+ // Test using a mail tab.
+ let mailTab = await browser.mailTabs.getCurrent();
+ await testTab(mailTab);
+
+ // Test using a content tab.
+ let contentTab = await browser.tabs.create({ url: "test.html" });
+ await testTab(contentTab);
+ await browser.tabs.remove(contentTab.id);
+
+ // Test using a content window.
+ let contentWindow = await browser.windows.create({
+ type: "popup",
+ url: "test.html",
+ });
+ await testTab(contentWindow.tabs[0]);
+ await browser.windows.remove(contentWindow.id);
+
+ // Test using a message tab.
+ let messageTab = await browser.messageDisplay.open({
+ messageId: messages[0].id,
+ location: "tab",
+ });
+ await testTab(messageTab);
+ await browser.tabs.remove(messageTab.id);
+
+ // Test using a message window.
+ let messageWindowTab = await browser.messageDisplay.open({
+ messageId: messages[0].id,
+ location: "window",
+ });
+ await testTab(messageWindowTab);
+ await browser.tabs.remove(messageWindowTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [
+ "accountsRead",
+ "messagesRead",
+ "messagesMove",
+ "messagesDelete",
+ ],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js b/comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js
new file mode 100644
index 0000000000..302486e31f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let messages;
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+
+ // Modify the messages so the filters can be checked against them.
+
+ messages = [...subFolders[0].messages];
+ messages[0].markRead(true);
+ messages[2].markRead(true);
+ messages[4].markRead(true);
+ messages[6].markRead(true);
+ messages[8].markRead(true);
+ messages[1].markFlagged(true);
+ messages[6].markFlagged(true);
+ messages[0].setStringProperty("keywords", "$label1");
+ messages[1].setStringProperty("keywords", "$label2");
+ messages[3].setStringProperty("keywords", "$label1 $label2");
+ messages[5].setStringProperty("keywords", "$label2");
+ messages[6].setStringProperty("keywords", "$label1");
+ messages[7].setStringProperty("keywords", "$label2 $label3");
+ messages[8].setStringProperty("keywords", "$label3");
+ messages[9].setStringProperty("keywords", "$label1 $label2 $label3");
+ messages[9].markHasAttachments(true);
+
+ // Add an author to the address book.
+
+ let author = messages[7].author.replace(/["<>]/g, "").split(" ");
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.setProperty("FirstName", author[0]);
+ card.setProperty("LastName", author[1]);
+ card.setProperty("DisplayName", `${author[0]} ${author[1]}`);
+ card.setProperty("PrimaryEmail", author[2]);
+ let ab = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ let addedCard = ab.addCard(card);
+
+ about3Pane.displayFolder(subFolders[0]);
+
+ registerCleanupFunction(() => {
+ ab.deleteCards([addedCard]);
+ });
+});
+
+add_task(async () => {
+ async function background() {
+ browser.mailTabs.setQuickFilter({ unread: true });
+ await window.sendMessage("checkVisible", 1, 3, 5, 7, 9);
+
+ browser.mailTabs.setQuickFilter({ flagged: true });
+ await window.sendMessage("checkVisible", 1, 6);
+
+ browser.mailTabs.setQuickFilter({ flagged: true, unread: true });
+ await window.sendMessage("checkVisible", 1);
+
+ browser.mailTabs.setQuickFilter({ tags: true });
+ await window.sendMessage("checkVisible", 0, 1, 3, 5, 6, 7, 8, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "any", tags: { $label1: true } },
+ });
+ await window.sendMessage("checkVisible", 0, 3, 6, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "any", tags: { $label2: true } },
+ });
+ await window.sendMessage("checkVisible", 1, 3, 5, 7, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "any", tags: { $label1: true, $label2: true } },
+ });
+ await window.sendMessage("checkVisible", 0, 1, 3, 5, 6, 7, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "all", tags: { $label1: true, $label2: true } },
+ });
+ await window.sendMessage("checkVisible", 3, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "all", tags: { $label1: true, $label2: false } },
+ });
+ await window.sendMessage("checkVisible", 0, 6);
+
+ browser.mailTabs.setQuickFilter({ attachment: true });
+ await window.sendMessage("checkVisible", 9);
+
+ browser.mailTabs.setQuickFilter({ attachment: false });
+ await window.sendMessage("checkVisible", 0, 1, 2, 3, 4, 5, 6, 7, 8);
+
+ browser.mailTabs.setQuickFilter({ contact: true });
+ await window.sendMessage("checkVisible", 7);
+
+ browser.mailTabs.setQuickFilter({ contact: false });
+ await window.sendMessage("checkVisible", 0, 1, 2, 3, 4, 5, 6, 8, 9);
+
+ browser.test.notifyPass("quickFilter");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkVisible", async (...expected) => {
+ let actual = [];
+ let dbView = about3Pane.gDBView;
+ for (let i = 0; i < dbView.numMsgsInView; i++) {
+ actual.push(messages.indexOf(dbView.getMsgHdrAt(i)));
+ }
+
+ Assert.deepEqual(actual, expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("quickFilter");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_sessions.js b/comm/mail/components/extensions/test/browser/browser_ext_sessions.js
new file mode 100644
index 0000000000..a77739c145
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_sessions.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function test_sessions_data() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let [mailTab] = await browser.tabs.query({ mailTab: true });
+ let contentTab = await browser.tabs.create({
+ url: "https://www.example.com",
+ });
+
+ // Check that there is no data at the beginning.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+
+ // Set some data.
+ await browser.sessions.setTabValue(mailTab.id, "aKey", "1234");
+ await browser.sessions.setTabValue(contentTab.id, "aKey", "4321");
+
+ // Check the data is correct.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ "1234",
+ "Value for aKey should exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ "4321",
+ "Value for aKey should exist"
+ );
+
+ // Update data.
+ await browser.sessions.setTabValue(mailTab.id, "aKey", "12345");
+ await browser.sessions.setTabValue(contentTab.id, "aKey", "54321");
+
+ // Check the data is correct.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ "12345",
+ "Value for aKey should exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ "54321",
+ "Value for aKey should exist"
+ );
+
+ // Clear data.
+ await browser.sessions.removeTabValue(mailTab.id, "aKey");
+ await browser.sessions.removeTabValue(contentTab.id, "aKey");
+
+ // Check the data is removed.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+
+ await browser.tabs.remove(contentTab.id);
+ browser.test.notifyPass();
+ },
+ manifest: {
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "sessions@mochi.test",
+ },
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_spaces.js b/comm/mail/components/extensions/test/browser/browser_ext_spaces.js
new file mode 100644
index 0000000000..16f6f4770e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_spaces.js
@@ -0,0 +1,1047 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helper Function, creates a test extension to verify expected button states.
+ *
+ * @param {Function} background - The background script executed by the test.
+ * @param {object} config - Additional config data for the test. Tests can
+ * include arbitrary data, but the following have a dedicated purpose:
+ * @param {string} selectedTheme - The selected theme (default, light or dark),
+ * used to select the expected button/menuitem icon.
+ * @param {?object} manifestIcons - The icons entry of the extension manifest.
+ * @param {?object} permissions - Permissions assigned to the extension.
+ */
+async function test_space(background, config = {}) {
+ let manifest = {
+ manifest_version: 3,
+ browser_specific_settings: {
+ gecko: {
+ id: "spaces_toolbar@mochi.test",
+ },
+ },
+ permissions: ["tabs"],
+ background: { scripts: ["utils.js", "background.js"] },
+ };
+
+ if (config.manifestIcons) {
+ manifest.icons = config.manifestIcons;
+ }
+
+ if (config.permissions) {
+ manifest.permissions = config.permissions;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest,
+ });
+
+ extension.onMessage("checkTabs", async test => {
+ let tabmail = document.getElementById("tabmail");
+
+ if (test.action && test.spaceName && test.url) {
+ let tabPromise =
+ test.action == "switch"
+ ? BrowserTestUtils.waitForEvent(tabmail.tabContainer, "TabSelect")
+ : contentTabOpenPromise(tabmail, test.url);
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${test.spaceName}`
+ );
+ button.click();
+ await tabPromise;
+ }
+
+ let tabs = tabmail.tabInfo.filter(tabInfo => !!tabInfo.spaceButtonId);
+ Assert.equal(
+ test.openSpacesUrls.length,
+ tabs.length,
+ `Should have found the correct number of open add-on spaces tabs.`
+ );
+ for (let expectedUrl of test.openSpacesUrls) {
+ Assert.ok(
+ tabmail.tabInfo.find(
+ tabInfo =>
+ !!tabInfo.spaceButtonId &&
+ tabInfo.browser.currentURI.spec == expectedUrl
+ ),
+ `Should have found a spaces tab with the expected url.`
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkUI", async expected => {
+ let addonButtons = document.querySelectorAll(".spaces-addon-button");
+ Assert.equal(
+ expected.length,
+ addonButtons.length,
+ `Should have found the correct number of buttons.`
+ );
+
+ for (let {
+ name,
+ url,
+ title,
+ icons,
+ badgeText,
+ badgeBackgroundColor,
+ } of expected) {
+ // Check button.
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${name}`
+ );
+ Assert.ok(button, `Button for space ${name} should exist.`);
+ Assert.equal(
+ title,
+ button.title,
+ `Title of button for space ${name} should be correct.`
+ );
+
+ // Check button icon.
+ let imgStyles = window.getComputedStyle(button.querySelector("img"));
+ Assert.equal(
+ icons[config.selectedTheme],
+ imgStyles.content,
+ `Icon for button of space ${name} with theme ${config.selectedTheme} should be correct.`
+ );
+
+ // Check badge.
+ let badge = button.querySelector(".spaces-badge-container");
+ let badgeStyles = window.getComputedStyle(badge);
+ if (badgeText) {
+ Assert.equal(
+ "block",
+ badgeStyles.display,
+ `Button of space ${name} should have a badge.`
+ );
+ Assert.equal(
+ badgeText,
+ badge.textContent,
+ `Badge of button of space ${name} should have the correct content.`
+ );
+ if (badgeBackgroundColor) {
+ Assert.equal(
+ badgeBackgroundColor,
+ badgeStyles.backgroundColor,
+ `Badge of button of space ${name} should have the correct backgroundColor.`
+ );
+ }
+ } else {
+ Assert.equal(
+ "none",
+ badgeStyles.display,
+ `Button of space ${name} should not have a badge.`
+ );
+ }
+
+ let collapseButton = document.getElementById("collapseButton");
+ let revealButton = document.getElementById("spacesToolbarReveal");
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ let pinnedPopup = document.getElementById("spacesButtonMenuPopup");
+
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+ collapseButton.click();
+ Assert.ok(
+ !revealButton.hidden,
+ "The status bar toggle button is not hidden"
+ );
+ Assert.ok(
+ !pinnedButton.hidden,
+ "The pinned titlebar button is not hidden"
+ );
+ pinnedPopup.openPopup();
+
+ // Check menuitem.
+ let menuitem = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${name}-menuitem`
+ );
+ Assert.ok(menuitem, `Menuitem for id ${name} should exist.`);
+ Assert.equal(
+ title,
+ menuitem.label,
+ `Label of menuitem of space ${name} should be correct.`
+ );
+
+ // Check menuitem icon.
+ let menuitemStyles = window.getComputedStyle(menuitem);
+ Assert.equal(
+ icons[config.selectedTheme],
+ menuitemStyles.listStyleImage,
+ `Icon of menuitem for space ${name} with theme ${config.selectedTheme} should be correct.`
+ );
+
+ pinnedPopup.hidePopup();
+ revealButton.click();
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+
+ //Check space and url.
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `spaces_toolbar_mochi_test-spacesButton-${name}`
+ );
+ Assert.ok(space, "The space of this button should exists");
+ Assert.equal(
+ url,
+ space.url,
+ "The stored url of the space should be correct"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getConfig", async () => {
+ extension.sendMessage(config);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function test_add_update_remove() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ await window.sendMessage("checkUI", []);
+
+ // Test create().
+ browser.test.log("create(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spaces.create(),
+ /Incorrect argument types for spaces.create./,
+ "create() without name should throw."
+ );
+
+ browser.test.log("create(): Without default url.");
+ await browser.test.assertThrows(
+ () => browser.spaces.create("space_1"),
+ /Incorrect argument types for spaces.create./,
+ "create() without default url should throw."
+ );
+
+ browser.test.log("create(): With invalid default url.");
+ await browser.test.assertRejects(
+ browser.spaces.create("space_1", "invalid://url"),
+ /Failed to create space with name space_1: Invalid default url./,
+ "create() with an invalid default url should throw."
+ );
+
+ browser.test.log("create(): With default url only.");
+ let space_1 = await browser.spaces.create(
+ "space_1",
+ "https://test.invalid"
+ );
+ let expected_space_1 = {
+ name: "space_1",
+ title: "Generated extension",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ browser.test.log("create(): With default url only, but existing id.");
+ await browser.test.assertRejects(
+ browser.spaces.create("space_1", "https://test.invalid"),
+ /Failed to create space with name space_1: Space already exists for this extension./,
+ "create() with existing id should throw."
+ );
+
+ browser.test.log("create(): With most properties.");
+ let space_2 = await browser.spaces.create("space_2", "/local/file.html", {
+ title: "Google",
+ defaultIcons: "default.png",
+ badgeText: "12",
+ badgeBackgroundColor: [50, 100, 150, 255],
+ });
+ let expected_space_2 = {
+ name: "space_2",
+ title: "Google",
+ url: browser.runtime.getURL("/local/file.html"),
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ badgeText: "12",
+ badgeBackgroundColor: "rgb(50, 100, 150)",
+ };
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Test update().
+ browser.test.log("update(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spaces.update(),
+ /Incorrect argument types for spaces.update./,
+ "update() without id should throw."
+ );
+
+ browser.test.log("update(): With invalid id.");
+ await browser.test.assertRejects(
+ browser.spaces.update(1234),
+ /Failed to update space with id 1234: Unknown id./,
+ "update() with invalid id should throw."
+ );
+
+ browser.test.log("update(): Without properties.");
+ await browser.spaces.update(space_1.id);
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Updating the badge.");
+ await browser.spaces.update(space_2.id, {
+ badgeText: "ok",
+ badgeBackgroundColor: "green",
+ });
+ expected_space_2.badgeText = "ok";
+ expected_space_2.badgeBackgroundColor = "rgb(0, 128, 0)";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Removing the badge.");
+ await browser.spaces.update(space_2.id, {
+ badgeText: "",
+ });
+ delete expected_space_2.badgeText;
+ delete expected_space_2.badgeBackgroundColor;
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Changing the title.");
+ await browser.spaces.update(space_2.id, {
+ title: "Some other title",
+ });
+ expected_space_2.title = "Some other title";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Removing the title.");
+ await browser.spaces.update(space_2.id, {
+ title: "",
+ });
+ expected_space_2.title = "Generated extension";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Setting invalid default url.");
+ await browser.test.assertRejects(
+ browser.spaces.update(space_2.id, "invalid://url"),
+ `Failed to update space with id ${space_2.id}: Invalid default url.`,
+ "update() with invalid default url should throw."
+ );
+
+ await browser.spaces.update(space_2.id, "https://test.more.invalid", {
+ title: "Bing",
+ });
+ expected_space_2.title = "Bing";
+ expected_space_2.url = "https://test.more.invalid";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Test remove().
+ browser.test.log("remove(): Removing without id.");
+ await browser.test.assertThrows(
+ () => browser.spaces.remove(),
+ /Incorrect argument types for spaces.remove./,
+ "remove() without id should throw."
+ );
+
+ browser.test.log("remove(): Removing with invalid id.");
+ await browser.test.assertRejects(
+ browser.spaces.remove(1234),
+ /Failed to remove space with id 1234: Unknown id./,
+ "remove() with invalid id should throw."
+ );
+
+ browser.test.log("remove(): Removing space_1.");
+ await browser.spaces.remove(space_1.id);
+ await window.sendMessage("checkUI", [expected_space_2]);
+
+ browser.test.notifyPass();
+ }
+ await test_space(background, { selectedTheme: "default" });
+ await test_space(background, {
+ selectedTheme: "default",
+ manifestIcons: { 16: "manifest.png" },
+ });
+});
+
+add_task(async function test_open_reload_close() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add spaces.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let space_1 = await browser.spaces.create("space_1", url1);
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ let space_2 = await browser.spaces.create("space_2", url2);
+
+ // Open spaces.
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url1,
+ spaceName: "space_1",
+ openSpacesUrls: [url1],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url2,
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open spaces tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ spaceName: "space_1",
+ openSpacesUrls: [url1, url2],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // TODO: Add test for tab reloading, once this has been implemented.
+
+ // Remove spaces and check that related spaces tab are closed.
+ await browser.spaces.remove(space_1.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spaces.remove(space_2.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_space(background, { selectedTheme: "default" });
+});
+
+add_task(async function test_icons() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ // Test 1: Setting defaultIcons and themeIcons.
+ browser.test.log("create(): Setting defaultIcons and themeIcons.");
+ let space_1 = await browser.spaces.create(
+ "space_1",
+ "https://test.invalid",
+ {
+ title: "Google",
+ defaultIcons: "default.png",
+ themeIcons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ }
+ );
+ let expected_space_1 = {
+ name: "space_1",
+ title: "Google",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Clearing defaultIcons.
+ await browser.spaces.update(space_1.id, {
+ defaultIcons: "",
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("dark.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Setting other defaultIcons.
+ await browser.spaces.update(space_1.id, {
+ defaultIcons: "other.png",
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Clearing themeIcons.
+ await browser.spaces.update(space_1.id, {
+ themeIcons: [],
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("other.png")}")`,
+ light: `url("${browser.runtime.getURL("other.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Setting other themeIcons.
+ await browser.spaces.update(space_1.id, {
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Test 2: Setting themeIcons only.
+ browser.test.log("create(): Setting themeIcons only.");
+ let space_2 = await browser.spaces.create(
+ "space_2",
+ "https://test.other.invalid",
+ {
+ title: "Wikipedia",
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ }
+ );
+ // Not specifying defaultIcons but only themeIcons should always use the
+ // theme icons, even for the default theme (and not the extension icon).
+ let expected_space_2 = {
+ name: "space_2",
+ title: "Wikipedia",
+ url: "https://test.other.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("dark2.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Clearing themeIcons.
+ await browser.spaces.update(space_2.id, {
+ themeIcons: [],
+ });
+ expected_space_2.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Test 3: Setting defaultIcons only.
+ browser.test.log("create(): Setting defaultIcons only.");
+ let space_3 = await browser.spaces.create(
+ "space_3",
+ "https://test.more.invalid",
+ {
+ title: "Bing",
+ defaultIcons: "default.png",
+ }
+ );
+ let expected_space_3 = {
+ name: "space_3",
+ title: "Bing",
+ url: "https://test.more.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ ]);
+
+ // Clearing defaultIcons and setting themeIcons.
+ await browser.spaces.update(space_3.id, {
+ defaultIcons: "",
+ themeIcons: [
+ {
+ dark: "dark3.png",
+ light: "light3.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_space_3.icons = {
+ default: `url("${browser.runtime.getURL("dark3.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark3.png")}")`,
+ light: `url("${browser.runtime.getURL("light3.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ ]);
+
+ // Test 4: Setting no icons.
+ browser.test.log("create(): Setting no icons.");
+ let space_4 = await browser.spaces.create(
+ "space_4",
+ "https://duckduckgo.com",
+ {
+ title: "DuckDuckGo",
+ }
+ );
+ let expected_space_4 = {
+ name: "space_4",
+ title: "DuckDuckGo",
+ url: "https://duckduckgo.com",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ expected_space_4,
+ ]);
+
+ // Setting and clearing default icons.
+ await browser.spaces.update(space_4.id, {
+ defaultIcons: "default.png",
+ });
+ expected_space_4.icons = {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ expected_space_4,
+ ]);
+ await browser.spaces.update(space_4.id, {
+ defaultIcons: "",
+ });
+ expected_space_4.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ expected_space_4,
+ ]);
+
+ browser.test.notifyPass();
+ }
+
+ // Test with and without icons defined in the manifest.
+ for (let manifestIcons of [null, { 16: "manifest16.png" }]) {
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await test_space(background, { selectedTheme: "light", manifestIcons });
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ await test_space(background, { selectedTheme: "dark", manifestIcons });
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ await test_space(background, { selectedTheme: "default", manifestIcons });
+ }
+});
+
+add_task(async function test_open_programmatically() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add spaces.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let space_1 = await browser.spaces.create("space_1", url1);
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ let space_2 = await browser.spaces.create("space_2", url2);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ async function openSpace(space, url) {
+ let loadPromise = new Promise(resolve => {
+ let urlSeen = false;
+ let listener = (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+ let tab = await browser.spaces.open(space.id);
+ await loadPromise;
+
+ browser.test.assertEq(
+ space.id,
+ tab.spaceId,
+ "The opened tab should belong to the correct space"
+ );
+
+ let queriedTabs = await browser.tabs.query({ spaceId: space.id });
+ browser.test.assertEq(
+ 1,
+ queriedTabs.length,
+ "browser.tabs.query() should find exactly one tab belonging to the opened space"
+ );
+ browser.test.assertEq(
+ tab.id,
+ queriedTabs[0].id,
+ "browser.tabs.query() should find the correct tab belonging to the opened space"
+ );
+ }
+
+ // Open space #1.
+ await openSpace(space_1, url1);
+ await window.sendMessage("checkTabs", {
+ spaceName: "space_1",
+ openSpacesUrls: [url1],
+ });
+
+ // Open space #2.
+ await openSpace(space_2, url2);
+ await window.sendMessage("checkTabs", {
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open space tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ spaceName: "space_1",
+ openSpacesUrls: [url1, url2],
+ });
+
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Remove spaces and check that related spaces tab are closed.
+ await browser.spaces.remove(space_1.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spaces.remove(space_2.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_space(background, { selectedTheme: "default" });
+});
+
+// Load a second extension parallel to the standard space test, which creates
+// two additional spaces.
+async function test_query({ permissions }) {
+ async function query_background() {
+ function verify(description, expected, spaces) {
+ browser.test.assertEq(
+ expected.length,
+ spaces.length,
+ `${description}: Should find the correct number of spaces`
+ );
+ window.assertDeepEqual(
+ spaces,
+ expected,
+ `${description}: Should find the correct spaces`
+ );
+ }
+
+ async function query(queryInfo, expected) {
+ let spaces =
+ queryInfo === null
+ ? await browser.spaces.query()
+ : await browser.spaces.query(queryInfo);
+ verify(`Query ${JSON.stringify(queryInfo)}`, expected, spaces);
+ }
+
+ let builtIn = [
+ {
+ id: 1,
+ name: "mail",
+ isBuiltIn: true,
+ isSelfOwned: false,
+ },
+ {
+ id: 2,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "addressbook",
+ },
+ {
+ id: 3,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "calendar",
+ },
+ {
+ id: 4,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "tasks",
+ },
+ {
+ id: 5,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "chat",
+ },
+ {
+ id: 6,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "settings",
+ },
+ ];
+
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+ let [{ other_1, other_11, permissions }] = await window.sendMessage(
+ "getConfig"
+ );
+ let hasManagement = permissions && permissions.includes("management");
+
+ // Verify space_1 from other extension.
+ let expected_other_1 = {
+ name: "space_1",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_other_1.extensionId = "spaces_toolbar_other@mochi.test";
+ }
+ verify("Check space_1 from other extension", other_1, expected_other_1);
+
+ // Verify space_11 from other extension.
+ let expected_other_11 = {
+ name: "space_11",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_other_11.extensionId = "spaces_toolbar_other@mochi.test";
+ }
+ verify("Check space_11 from other extension", other_11, expected_other_11);
+
+ // Manipulate isSelfOwned, because we got those from the other extension.
+ other_1.isSelfOwned = false;
+ other_11.isSelfOwned = false;
+
+ await query(null, [...builtIn, other_1, other_11]);
+ await query({}, [...builtIn, other_1, other_11]);
+ await query({ isSelfOwned: false }, [...builtIn, other_1, other_11]);
+ await query({ isBuiltIn: true }, [...builtIn]);
+ await query({ isBuiltIn: false }, [other_1, other_11]);
+ await query({ isSelfOwned: true }, []);
+ await query(
+ { extensionId: "spaces_toolbar_other@mochi.test" },
+ hasManagement ? [other_1, other_11] : []
+ );
+
+ // Add spaces.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let space_1 = await browser.spaces.create("space_1", url1);
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ let space_2 = await browser.spaces.create("space_2", url2);
+
+ // Verify returned space_1
+ let expected_space_1 = {
+ name: "space_1",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_space_1.extensionId = "spaces_toolbar@mochi.test";
+ }
+ verify("Check space_1", space_1, expected_space_1);
+
+ // Verify returned space_2
+ let expected_space_2 = {
+ name: "space_2",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_space_2.extensionId = "spaces_toolbar@mochi.test";
+ }
+ verify("Check space_2", space_2, expected_space_2);
+
+ await query(null, [...builtIn, other_1, other_11, space_1, space_2]);
+ await query({ isSelfOwned: false }, [...builtIn, other_1, other_11]);
+ await query({ isBuiltIn: true }, [...builtIn]);
+ await query({ isBuiltIn: false }, [other_1, other_11, space_1, space_2]);
+ await query({ isSelfOwned: true }, [space_1, space_2]);
+ await query(
+ { extensionId: "spaces_toolbar_other@mochi.test" },
+ hasManagement ? [other_1, other_11] : []
+ );
+ await query(
+ { extensionId: "spaces_toolbar@mochi.test" },
+ hasManagement ? [space_1, space_2] : []
+ );
+
+ await query({ id: space_1.id }, [space_1]);
+ await query({ id: other_1.id }, [other_1]);
+ await query({ id: space_2.id }, [space_2]);
+ await query({ id: other_11.id }, [other_11]);
+ await query({ name: "space_1" }, [other_1, space_1]);
+ await query({ name: "space_2" }, [space_2]);
+ await query({ name: "space_11" }, [other_11]);
+
+ browser.test.notifyPass();
+ }
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let url = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let other_1 = await browser.spaces.create("space_1", url);
+ let other_11 = await browser.spaces.create("space_11", url);
+ browser.test.sendMessage("Done", { other_1, other_11 });
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ manifest_version: 3,
+ browser_specific_settings: {
+ gecko: {
+ id: "spaces_toolbar_other@mochi.test",
+ },
+ },
+ permissions,
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ await otherExtension.startup();
+ let { other_1, other_11 } = await otherExtension.awaitMessage("Done");
+
+ await test_space(query_background, {
+ selectedTheme: "default",
+ other_1,
+ other_11,
+ permissions,
+ });
+
+ await otherExtension.awaitFinish();
+ await otherExtension.unload();
+}
+
+add_task(async function test_query_no_management_permission() {
+ await test_query({ permissions: [] });
+});
+
+add_task(async function test_query_management_permission() {
+ await test_query({ permissions: ["management"] });
+});
+
+// Test built-in spaces to make sure the space definition of the spaceTracker in
+// ext-mails.js is matching the actual space definition in spacesToolbar.js
+add_task(async function test_builtIn_spaces() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const checkSpace = async (spaceId, spaceName) => {
+ let spaces = await browser.spaces.query({ id: spaceId });
+ browser.test.assertEq(spaces.length, 1, "Should find a single space");
+ browser.test.assertEq(
+ spaces[0].isBuiltIn,
+ true,
+ "Should find a built-in space"
+ );
+ browser.test.assertEq(
+ spaces[0].name,
+ spaceName,
+ "Should find the correct space"
+ );
+ };
+
+ // Test the already open mail space.
+
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ mailTabs.length,
+ 1,
+ "Should find a single mail tab"
+ );
+ await checkSpace(mailTabs[0].spaceId, "mail");
+
+ // Test all other spaces.
+
+ let builtInSpaces = [
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ ];
+
+ for (let spaceName of builtInSpaces) {
+ await new Promise(resolve => {
+ const listener = async tab => {
+ await checkSpace(tab.spaceId, spaceName);
+ browser.tabs.remove(tab.id);
+ browser.tabs.onCreated.removeListener(listener);
+ resolve();
+ };
+ browser.tabs.onCreated.addListener(listener);
+ browser.test.sendMessage("openSpace", spaceName);
+ });
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "built-in-spaces@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("openSpace", async spaceName => {
+ window.gSpacesToolbar.openSpace(
+ window.document.getElementById("tabmail"),
+ window.gSpacesToolbar.spaces.find(space => space.name == spaceName)
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js b/comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js
new file mode 100644
index 0000000000..15a2b2b999
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js
@@ -0,0 +1,755 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helper Function, creates a test extension to verify expected button states.
+ *
+ * @param {Function} background - The background script executed by the test.
+ * @param {string} selectedTheme - The selected theme (default, light or dark),
+ * used to select the expected button/menuitem icon.
+ * @param {?object} manifestIcons - The icons entry of the extension manifest.
+ */
+async function test_spaceToolbar(background, selectedTheme, manifestIcons) {
+ let manifest = {
+ manifest_version: 2,
+ applications: {
+ gecko: {
+ id: "spaces_toolbar@mochi.test",
+ },
+ },
+ permissions: ["tabs"],
+ background: { scripts: ["utils.js", "background.js"] },
+ };
+
+ if (manifestIcons) {
+ manifest.icons = manifestIcons;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest,
+ });
+
+ extension.onMessage("checkTabs", async test => {
+ let tabmail = document.getElementById("tabmail");
+
+ if (test.action && test.buttonId && test.url) {
+ let tabPromise =
+ test.action == "switch"
+ ? BrowserTestUtils.waitForEvent(tabmail.tabContainer, "TabSelect")
+ : contentTabOpenPromise(tabmail, test.url);
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${test.buttonId}`
+ );
+ button.click();
+ await tabPromise;
+ }
+
+ let tabs = tabmail.tabInfo.filter(tabInfo => !!tabInfo.spaceButtonId);
+ Assert.equal(
+ test.openSpacesUrls.length,
+ tabs.length,
+ `Should have found the correct number of open add-on spaces tabs.`
+ );
+ for (let expectedUrl of test.openSpacesUrls) {
+ Assert.ok(
+ tabmail.tabInfo.find(
+ tabInfo =>
+ !!tabInfo.spaceButtonId &&
+ tabInfo.browser.currentURI.spec == expectedUrl
+ ),
+ `Should have found a spaces tab with the expected url.`
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkUI", async expected => {
+ let addonButtons = document.querySelectorAll(".spaces-addon-button");
+ Assert.equal(
+ expected.length,
+ addonButtons.length,
+ `Should have found the correct number of buttons.`
+ );
+
+ for (let {
+ id,
+ url,
+ title,
+ icons,
+ badgeText,
+ badgeBackgroundColor,
+ } of expected) {
+ // Check button.
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${id}`
+ );
+ Assert.ok(button, `Button for id ${id} should exist.`);
+ Assert.equal(
+ title,
+ button.title,
+ `Title of button ${id} should be correct.`
+ );
+
+ // Check button icon.
+ let imgStyles = window.getComputedStyle(button.querySelector("img"));
+ Assert.equal(
+ icons[selectedTheme],
+ imgStyles.content,
+ `Icon of button ${id} with theme ${selectedTheme} should be correct.`
+ );
+
+ // Check badge.
+ let badge = button.querySelector(".spaces-badge-container");
+ let badgeStyles = window.getComputedStyle(badge);
+ if (badgeText) {
+ Assert.equal(
+ "block",
+ badgeStyles.display,
+ `Button ${id} should have a badge.`
+ );
+ Assert.equal(
+ badgeText,
+ badge.textContent,
+ `Badge of button ${id} should have the correct content.`
+ );
+ if (badgeBackgroundColor) {
+ Assert.equal(
+ badgeBackgroundColor,
+ badgeStyles.backgroundColor,
+ `Badge of button ${id} should have the correct backgroundColor.`
+ );
+ }
+ } else {
+ Assert.equal(
+ "none",
+ badgeStyles.display,
+ `Button ${id} should not have a badge.`
+ );
+ }
+
+ let collapseButton = document.getElementById("collapseButton");
+ let revealButton = document.getElementById("spacesToolbarReveal");
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ let pinnedPopup = document.getElementById("spacesButtonMenuPopup");
+
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+ collapseButton.click();
+ Assert.ok(
+ !revealButton.hidden,
+ "The status bar toggle button is not hidden"
+ );
+ Assert.ok(
+ !pinnedButton.hidden,
+ "The pinned titlebar button is not hidden"
+ );
+ pinnedPopup.openPopup();
+
+ // Check menuitem.
+ let menuitem = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${id}-menuitem`
+ );
+ Assert.ok(menuitem, `Menuitem for id ${id} should exist.`);
+ Assert.equal(
+ title,
+ menuitem.label,
+ `Label of menuitem ${id} should be correct.`
+ );
+
+ // Check menuitem icon.
+ let menuitemStyles = window.getComputedStyle(menuitem);
+ Assert.equal(
+ icons[selectedTheme],
+ menuitemStyles.listStyleImage,
+ `Icon of menuitem ${id} with theme ${selectedTheme} should be correct.`
+ );
+
+ pinnedPopup.hidePopup();
+ revealButton.click();
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+
+ //Check space and url.
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `spaces_toolbar_mochi_test-spacesButton-${id}`
+ );
+ Assert.ok(space, "The space of this button should exists");
+ Assert.equal(
+ url,
+ space.url,
+ "The stored url of the space should be correct"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function test_add_update_remove() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ // Test addButton().
+ browser.test.log("addButton(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.addButton(),
+ /Incorrect argument types for spacesToolbar.addButton./,
+ "addButton() without id should throw."
+ );
+
+ browser.test.log("addButton(): Without properties.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.addButton("button_1"),
+ /Incorrect argument types for spacesToolbar.addButton./,
+ "addButton() without properties should throw."
+ );
+
+ browser.test.log("addButton(): With empty properties.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.addButton("button_1", {}),
+ /Failed to add button to the spaces toolbar: Invalid url./,
+ "addButton() without a url should throw."
+ );
+
+ browser.test.log("addButton(): With invalid url.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.addButton("button_1", {
+ url: "invalid://url",
+ }),
+ /Failed to add button to the spaces toolbar: Invalid url./,
+ "addButton() with an invalid url should throw."
+ );
+
+ browser.test.log("addButton(): With url only.");
+ await browser.spacesToolbar.addButton("button_1", {
+ url: "https://test.invalid",
+ });
+ let expected_button_1 = {
+ id: "button_1",
+ title: "Generated extension",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ browser.test.log("addButton(): With url only, but existing id.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.addButton("button_1", {
+ url: "https://test.invalid",
+ }),
+ /Failed to add button to the spaces toolbar: The id button_1 is already used by this extension./,
+ "addButton() with existing id should throw."
+ );
+
+ browser.test.log("addButton(): With most properties.");
+ await browser.spacesToolbar.addButton("button_2", {
+ title: "Google",
+ url: "/local/file.html",
+ defaultIcons: "default.png",
+ badgeText: "12",
+ badgeBackgroundColor: [50, 100, 150, 255],
+ });
+ let expected_button_2 = {
+ id: "button_2",
+ title: "Google",
+ url: browser.runtime.getURL("/local/file.html"),
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ badgeText: "12",
+ badgeBackgroundColor: "rgb(50, 100, 150)",
+ };
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Test updateButton().
+ browser.test.log("updateButton(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.updateButton(),
+ /Incorrect argument types for spacesToolbar.updateButton./,
+ "updateButton() without id should throw."
+ );
+
+ browser.test.log("updateButton(): Without properties.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.updateButton("InvalidId"),
+ /Incorrect argument types for spacesToolbar.updateButton./,
+ "updateButton() without properties should throw."
+ );
+
+ browser.test.log("updateButton(): With empty properties but invalid id.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.updateButton("InvalidId", {}),
+ /Failed to update button in the spaces toolbar: A button with id InvalidId does not exist for this extension./,
+ "updateButton() with invalid id should throw."
+ );
+
+ browser.test.log("updateButton(): With empty properties.");
+ await browser.spacesToolbar.updateButton("button_1", {});
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Updating the badge.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ badgeText: "ok",
+ badgeBackgroundColor: "green",
+ });
+ expected_button_2.badgeText = "ok";
+ expected_button_2.badgeBackgroundColor = "rgb(0, 128, 0)";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Removing the badge.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ badgeText: "",
+ });
+ delete expected_button_2.badgeText;
+ delete expected_button_2.badgeBackgroundColor;
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Changing the title.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ title: "Some other title",
+ });
+ expected_button_2.title = "Some other title";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Removing the title.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ title: "",
+ });
+ expected_button_2.title = "Generated extension";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Settings an invalid url.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.updateButton("button_2", {
+ url: "invalid://url",
+ }),
+ /Failed to update button in the spaces toolbar: Invalid url./,
+ "updateButton() with invalid url should throw."
+ );
+
+ await browser.spacesToolbar.updateButton("button_2", {
+ title: "Bing",
+ url: "https://test.more.invalid",
+ });
+ expected_button_2.title = "Bing";
+ expected_button_2.url = "https://test.more.invalid";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Test removeButton().
+ browser.test.log("removeButton(): Removing without id.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.removeButton(),
+ /Incorrect argument types for spacesToolbar.removeButton./,
+ "removeButton() without id should throw."
+ );
+
+ browser.test.log("removeButton(): Removing with invalid id.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.removeButton("InvalidId"),
+ /Failed to remove button from the spaces toolbar: A button with id InvalidId does not exist for this extension./,
+ "removeButton() with invalid id should throw."
+ );
+
+ browser.test.log("removeButton(): Removing button_1.");
+ await browser.spacesToolbar.removeButton("button_1");
+ await window.sendMessage("checkUI", [expected_button_2]);
+
+ browser.test.notifyPass();
+ }
+ await test_spaceToolbar(background, "default");
+ await test_spaceToolbar(background, "default", { 16: "manifest.png" });
+});
+
+add_task(async function test_open_reload_close() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add buttons.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ await browser.spacesToolbar.addButton("button_1", {
+ url: url1,
+ });
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ await browser.spacesToolbar.addButton("button_2", {
+ url: url2,
+ });
+
+ // Open spaces.
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url1,
+ buttonId: "button_1",
+ openSpacesUrls: [url1],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url2,
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open spaces tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ buttonId: "button_1",
+ openSpacesUrls: [url1, url2],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // TODO: Add test for tab reloading, once this has been implemented.
+
+ // Remove buttons and check that related spaces tab are closed.
+ await browser.spacesToolbar.removeButton("button_1");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spacesToolbar.removeButton("button_2");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_spaceToolbar(background, "default");
+});
+
+add_task(async function test_icons() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ // Test 1: Setting defaultIcons and themeIcons.
+ browser.test.log("addButton(): Setting defaultIcons and themeIcons.");
+ await browser.spacesToolbar.addButton("button_1", {
+ title: "Google",
+ url: "https://test.invalid",
+ defaultIcons: "default.png",
+ themeIcons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ });
+ let expected_button_1 = {
+ id: "button_1",
+ title: "Google",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Clearing defaultIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ defaultIcons: "",
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("dark.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Setting other defaultIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ defaultIcons: "other.png",
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Clearing themeIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ themeIcons: [],
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("other.png")}")`,
+ light: `url("${browser.runtime.getURL("other.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Setting other themeIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Test 2: Setting themeIcons only.
+ browser.test.log("addButton(): Setting themeIcons only.");
+ await browser.spacesToolbar.addButton("button_2", {
+ title: "Wikipedia",
+ url: "https://test.other.invalid",
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ });
+ // Not specifying defaultIcons but only themeIcons should always use the
+ // theme icons, even for the default theme (and not the extension icon).
+ let expected_button_2 = {
+ id: "button_2",
+ title: "Wikipedia",
+ url: "https://test.other.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("dark2.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Clearing themeIcons.
+ await browser.spacesToolbar.updateButton("button_2", {
+ themeIcons: [],
+ });
+ expected_button_2.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Test 3: Setting defaultIcons only.
+ browser.test.log("addButton(): Setting defaultIcons only.");
+ await browser.spacesToolbar.addButton("button_3", {
+ title: "Bing",
+ url: "https://test.more.invalid",
+ defaultIcons: "default.png",
+ });
+ let expected_button_3 = {
+ id: "button_3",
+ title: "Bing",
+ url: "https://test.more.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ ]);
+
+ // Clearing defaultIcons and setting themeIcons.
+ await browser.spacesToolbar.updateButton("button_3", {
+ defaultIcons: "",
+ themeIcons: [
+ {
+ dark: "dark3.png",
+ light: "light3.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_button_3.icons = {
+ default: `url("${browser.runtime.getURL("dark3.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark3.png")}")`,
+ light: `url("${browser.runtime.getURL("light3.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ ]);
+
+ // Test 4: Setting no icons.
+ browser.test.log("addButton(): Setting no icons.");
+ await browser.spacesToolbar.addButton("button_4", {
+ title: "DuckDuckGo",
+ url: "https://duckduckgo.com",
+ });
+ let expected_button_4 = {
+ id: "button_4",
+ title: "DuckDuckGo",
+ url: "https://duckduckgo.com",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ expected_button_4,
+ ]);
+
+ // Setting and clearing default icons.
+ await browser.spacesToolbar.updateButton("button_4", {
+ defaultIcons: "default.png",
+ });
+ expected_button_4.icons = {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ expected_button_4,
+ ]);
+ await browser.spacesToolbar.updateButton("button_4", {
+ defaultIcons: "",
+ });
+ expected_button_4.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ expected_button_4,
+ ]);
+
+ browser.test.notifyPass();
+ }
+
+ // Test with and without icons defined in the manifest.
+ for (let manifestIcons of [null, { 16: "manifest16.png" }]) {
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await test_spaceToolbar(background, "light", manifestIcons);
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ await test_spaceToolbar(background, "dark", manifestIcons);
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ await test_spaceToolbar(background, "default", manifestIcons);
+ }
+});
+
+add_task(async function test_open_programmatically() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add buttons.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ await browser.spacesToolbar.addButton("button_1", {
+ url: url1,
+ });
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ await browser.spacesToolbar.addButton("button_2", {
+ url: url2,
+ });
+
+ async function clickSpaceButton(buttonId, url) {
+ let loadPromise = new Promise(resolve => {
+ let urlSeen = false;
+ let listener = (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+ let tab = await browser.spacesToolbar.clickButton(buttonId);
+ await loadPromise;
+
+ let queriedTabs = await browser.tabs.query({ spaceId: tab.spaceId });
+ browser.test.assertEq(
+ 1,
+ queriedTabs.length,
+ "browser.tabs.query() should find exactly one tab belonging to the opened space"
+ );
+ browser.test.assertEq(
+ tab.id,
+ queriedTabs[0].id,
+ "browser.tabs.query() should find the correct tab belonging to the opened space"
+ );
+ }
+
+ // Open space #1.
+ await clickSpaceButton("button_1", url1);
+ await window.sendMessage("checkTabs", {
+ buttonId: "button_1",
+ openSpacesUrls: [url1],
+ });
+
+ // Open space #2.
+ await clickSpaceButton("button_2", url2);
+ await window.sendMessage("checkTabs", {
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open space tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ buttonId: "button_1",
+ openSpacesUrls: [url1, url2],
+ });
+
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Remove spaces and check that related spaces tab are closed.
+ await browser.spacesToolbar.removeButton("button_1");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spacesToolbar.removeButton("button_2");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_spaceToolbar(background, "default");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js
new file mode 100644
index 0000000000..fbc98ff09e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js
@@ -0,0 +1,336 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Common core of the test. This is complicated by how WebExtensions tests work.
+ *
+ * @param {Function} createTab - The code of this function is copied into the
+ * extension. It should assign a function to `window.createTab` that opens
+ * the tab to be tested and return the id of the tab.
+ * @param {Function} getBrowser - A function to get the <browser> associated
+ * with the tab.
+ */
+async function subTest(createTab, getBrowser, shouldRemove = true) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "createTab.js": createTab,
+ "background.js": async () => {
+ // Open the tab to be tested.
+
+ let tabId = await window.createTab();
+
+ // Test insertCSS, removeCSS, and executeScript.
+
+ await window.sendMessage();
+ await browser.tabs.insertCSS(tabId, {
+ code: "body { background: lime }",
+ });
+ await window.sendMessage();
+ await browser.tabs.removeCSS(tabId, {
+ code: "body { background: lime }",
+ });
+ await window.sendMessage();
+ await browser.tabs.executeScript(tabId, {
+ code: `
+ document.body.textContent = "Hey look, the script ran!";
+ browser.runtime.onConnect.addListener(port =>
+ port.onMessage.addListener(message => {
+ browser.test.assertEq(message, "Sending a message.");
+ port.postMessage("Got your message.");
+ })
+ );
+ browser.runtime.onMessage.addListener(
+ (message, sender, sendResponse) => {
+ browser.test.assertEq(message, "Sending a message.");
+ sendResponse("Got your message.");
+ }
+ );
+ `,
+ });
+ await window.sendMessage();
+
+ // Test connect and sendMessage. The receivers were set up above.
+
+ let port = await browser.tabs.connect(tabId);
+ port.onMessage.addListener(message =>
+ browser.test.assertEq(message, "Got your message.")
+ );
+ port.postMessage("Sending a message.");
+
+ let response = await browser.tabs.sendMessage(
+ tabId,
+ "Sending a message."
+ );
+ browser.test.assertEq(response, "Got your message.");
+
+ // Remove the tab if required.
+
+ let [shouldRemove] = await window.sendMessage();
+ if (shouldRemove) {
+ await browser.tabs.remove(tabId);
+ }
+ browser.test.notifyPass();
+ },
+ "test.html": "<html><body>I'm a real page!</body></html>",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "createTab.js", "background.js"] },
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ let browser = getBrowser();
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ await checkContent(browser, {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "I'm a real page!",
+ });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkContent(browser, { backgroundColor: "rgb(0, 255, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkContent(browser, { backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkContent(browser, { textContent: "Hey look, the script ran!" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ extension.sendMessage(shouldRemove);
+
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testFirstTab() {
+ let createTab = async () => {
+ window.createTab = async function () {
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(1, tabs.length);
+ await browser.tabs.update(tabs[0].id, { url: "test.html" });
+ return tabs[0].id;
+ };
+ };
+
+ let tabmail = document.getElementById("tabmail");
+ function getBrowser(expected) {
+ return tabmail.currentTabInfo.browser;
+ }
+
+ let gAccount = createAccount();
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.subFolders[0].URI,
+ });
+
+ return subTest(createTab, getBrowser, false);
+});
+
+add_task(async function testContentTab() {
+ let createTab = async () => {
+ window.createTab = async function () {
+ let tab = await browser.tabs.create({ url: "test.html" });
+ return tab.id;
+ };
+ };
+
+ function getBrowser(expected) {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail.currentTabInfo.browser;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs before the test."
+ );
+ // Run the subtest without removing the created tab, to check if extension tabs
+ // are removed automatically, when the extension is removed.
+ let rv = await subTest(createTab, getBrowser, false);
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs after the test."
+ );
+ return rv;
+});
+
+add_task(async function testPopupWindow() {
+ let createTab = async () => {
+ window.createTab = async function () {
+ let popup = await browser.windows.create({
+ url: "test.html",
+ type: "popup",
+ });
+ browser.test.assertEq(1, popup.tabs.length);
+ return popup.tabs[0].id;
+ };
+ };
+
+ function getBrowser(expected) {
+ let popups = [...Services.wm.getEnumerator("mail:extensionPopup")];
+ Assert.equal(popups.length, 1);
+
+ let popup = popups[0];
+
+ let popupBrowser = popup.getBrowser();
+ Assert.ok(popupBrowser);
+
+ return popupBrowser;
+ }
+ let popups = [...Services.wm.getEnumerator("mail:extensionPopup")];
+ Assert.equal(
+ popups.length,
+ 0,
+ "Should find the no extension windows before the test."
+ );
+ // Run the subtest without removing the created window, to check if extension
+ // windows are removed automatically, when the extension is removed.
+ let rv = await subTest(createTab, getBrowser, false);
+ Assert.equal(
+ popups.length,
+ 0,
+ "Should find the no extension windows after the test."
+ );
+ return rv;
+});
+
+add_task(async function testMultipleContentTabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tabs = [];
+ let tests = [
+ {
+ url: "test.html",
+ expectedUrl: browser.runtime.getURL("test.html"),
+ },
+ {
+ url: "test.html",
+ expectedUrl: browser.runtime.getURL("test.html"),
+ },
+ {
+ url: "https://www.example.com",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com/",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com/",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com/",
+ expectedUrl: "https://www.example.com/",
+ },
+ ];
+
+ async function create(url, expectedUrl) {
+ let tabDonePromise = new Promise(resolve => {
+ let changeInfoStatus = false;
+ let changeInfoUrl = false;
+
+ let listener = (tabId, changeInfo) => {
+ if (!tab || tab.id != tabId) {
+ return;
+ }
+ // Looks like "complete" is reached sometimes before the url is done,
+ // so check for both.
+ if (changeInfo.status == "complete") {
+ changeInfoStatus = true;
+ }
+ if (changeInfo.url) {
+ changeInfoUrl = changeInfo.url;
+ }
+
+ if (changeInfoStatus && changeInfoUrl) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve(changeInfoUrl);
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+
+ let tab = await browser.tabs.create({ url });
+ for (let otherTab of tabs) {
+ browser.test.assertTrue(
+ tab.id != otherTab.id,
+ "Id of created tab should be unique."
+ );
+ }
+ tabs.push(tab);
+
+ let changeInfoUrl = await tabDonePromise;
+ browser.test.assertEq(
+ expectedUrl,
+ changeInfoUrl,
+ "Should have seen the correct url."
+ );
+ }
+
+ for (let { url, expectedUrl } of tests) {
+ await create(url, expectedUrl);
+ }
+
+ browser.test.notifyPass();
+ },
+ "test.html": "<html><body>I'm a real page!</body></html>",
+ },
+ manifest: {
+ background: { scripts: ["background.js"] },
+ permissions: ["tabs"],
+ },
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs before the test."
+ );
+
+ await extension.startup();
+ await extension.awaitFinish();
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 8,
+ "Should find the correct number of tabs after the test."
+ );
+
+ await extension.unload();
+ // After unload, the two extension tabs should be closed.
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 6,
+ "Should find the correct number of tabs after extension unload."
+ );
+
+ for (let i = tabmail.tabInfo.length; i > 0; i--) {
+ let nativeTabInfo = tabmail.tabInfo[i - 1];
+ let uri = nativeTabInfo.browser?.browsingContext.currentURI;
+ if (uri && ["https", "http"].includes(uri.scheme)) {
+ tabmail.closeTab(nativeTabInfo);
+ }
+ }
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs after test has finished."
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js
new file mode 100644
index 0000000000..48afe44ad7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js
@@ -0,0 +1,275 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_setup(async function () {
+ // make sure userContext is enabled.
+ return SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+});
+
+add_task(async function () {
+ info("Start testing tabs.create with cookieStoreId");
+
+ let testCases = [
+ {
+ cookieStoreId: null,
+ expectedCookieStoreId: "firefox-default",
+ },
+ {
+ cookieStoreId: "firefox-default",
+ expectedCookieStoreId: "firefox-default",
+ },
+ {
+ cookieStoreId: "firefox-container-1",
+ expectedCookieStoreId: "firefox-container-1",
+ },
+ {
+ cookieStoreId: "firefox-container-2",
+ expectedCookieStoreId: "firefox-container-2",
+ },
+ { cookieStoreId: "firefox-container-42", failure: "exist" },
+ { cookieStoreId: "firefox-private", failure: "defaultToPrivate" },
+ { cookieStoreId: "wow", failure: "illegal" },
+ ];
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+
+ background() {
+ function testTab(data, tab) {
+ browser.test.assertTrue(!data.failure, "we want a success");
+ browser.test.assertTrue(!!tab, "we have a tab");
+ browser.test.assertEq(
+ data.expectedCookieStoreId,
+ tab.cookieStoreId,
+ "tab should have the correct cookieStoreId"
+ );
+ }
+
+ async function runTest(data) {
+ try {
+ // Tab Creation
+ let tab;
+ try {
+ tab = await browser.tabs.create({
+ windowId: this.defaultWindowId,
+ cookieStoreId: data.cookieStoreId,
+ });
+
+ browser.test.assertTrue(!data.failure, "we want a success");
+ } catch (error) {
+ browser.test.assertTrue(!!data.failure, "we want a failure");
+ if (data.failure == "illegal") {
+ browser.test.assertEq(
+ `Illegal cookieStoreId: ${data.cookieStoreId}`,
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else if (data.failure == "defaultToPrivate") {
+ browser.test.assertEq(
+ "Illegal to set private cookieStoreId in a non-private window",
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else if (data.failure == "privateToDefault") {
+ browser.test.assertEq(
+ "Illegal to set non-private cookieStoreId in a private window",
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else if (data.failure == "exist") {
+ browser.test.assertEq(
+ `No cookie store exists with ID ${data.cookieStoreId}`,
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else {
+ browser.test.fail("The test is broken");
+ }
+
+ browser.test.sendMessage("test-done");
+ return;
+ }
+
+ // Tests for tab creation
+ testTab(data, tab);
+
+ {
+ // Tests for tab querying
+ let [tab] = await browser.tabs.query({
+ windowId: this.defaultWindowId,
+ cookieStoreId: data.cookieStoreId,
+ });
+
+ browser.test.assertTrue(tab != undefined, "Tab found!");
+ testTab(data, tab);
+ }
+
+ let stores = await browser.cookies.getAllCookieStores();
+
+ let store = stores.find(store => store.id === tab.cookieStoreId);
+ browser.test.assertTrue(!!store, "We have a store for this tab.");
+ browser.test.assertTrue(
+ store.tabIds.includes(tab.id),
+ "tabIds includes this tab."
+ );
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.sendMessage("test-done");
+ } catch (e) {
+ browser.test.fail("An exception has been thrown");
+ }
+ }
+
+ async function initialize() {
+ let win = await browser.windows.getCurrent();
+ this.defaultWindowId = win.id;
+
+ browser.test.sendMessage("ready");
+ }
+
+ async function shutdown() {
+ browser.test.sendMessage("gone");
+ }
+
+ // Waiting for messages
+ browser.test.onMessage.addListener((msg, data) => {
+ if (msg == "be-ready") {
+ initialize();
+ } else if (msg == "test") {
+ runTest(data);
+ } else {
+ browser.test.assertTrue("finish", msg, "Shutting down");
+ shutdown();
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Tests must be ready...");
+ extension.sendMessage("be-ready");
+ await extension.awaitMessage("ready");
+ info("Tests are ready to run!");
+
+ for (let test of testCases) {
+ info(`test tab.create with cookieStoreId: "${test.cookieStoreId}"`);
+ extension.sendMessage("test", test);
+ await extension.awaitMessage("test-done");
+ }
+
+ info("Waiting for shutting down...");
+ extension.sendMessage("finish");
+ await extension.awaitMessage("gone");
+
+ await extension.unload();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "firefox-container-1" }),
+ /Contextual identities are currently disabled/,
+ "should refuse to open container tab when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function tabs_query_cookiestoreid_nocookiepermission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let tab = await browser.tabs.create({});
+ browser.test.assertEq(
+ "firefox-default",
+ tab.cookieStoreId,
+ "Expecting cookieStoreId for new tab"
+ );
+ let query = await browser.tabs.query({
+ index: tab.index,
+ cookieStoreId: tab.cookieStoreId,
+ });
+ browser.test.assertEq(
+ "firefox-default",
+ query[0].cookieStoreId,
+ "Expecting cookieStoreId for new tab through browser.tabs.query"
+ );
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function tabs_query_multiple_cookiestoreId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+
+ async background() {
+ let tab1 = await browser.tabs.create({
+ cookieStoreId: "firefox-container-1",
+ });
+ browser.test.log(`Tab created for cookieStoreId:${tab1.cookieStoreId}`);
+
+ let tab2 = await browser.tabs.create({
+ cookieStoreId: "firefox-container-2",
+ });
+ browser.test.log(`Tab created for cookieStoreId:${tab2.cookieStoreId}`);
+
+ let tab3 = await browser.tabs.create({
+ cookieStoreId: "firefox-container-3",
+ });
+ browser.test.log(`Tab created for cookieStoreId:${tab3.cookieStoreId}`);
+
+ let tabs = await browser.tabs.query({
+ cookieStoreId: ["firefox-container-1", "firefox-container-2"],
+ });
+
+ browser.test.assertEq(
+ 2,
+ tabs.length,
+ "Expecting tabs for firefox-container-1 and firefox-container-2"
+ );
+
+ browser.test.assertEq(
+ "firefox-container-1",
+ tabs[0].cookieStoreId,
+ "Expecting tab for firefox-container-1 cookieStoreId"
+ );
+
+ browser.test.assertEq(
+ "firefox-container-2",
+ tabs[1].cookieStoreId,
+ "Expecting tab for firefox-container-2 cookieStoreId"
+ );
+
+ await browser.tabs.remove([tab1.id, tab2.id, tab3.id]);
+ browser.test.sendMessage("test-done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test-done");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js
new file mode 100644
index 0000000000..fa23482a3a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js
@@ -0,0 +1,591 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let tabmail = document.getElementById("tabmail");
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("tabsEvents", null);
+ let testFolder = rootFolder.findSubFolder("tabsEvents");
+ createMessages(testFolder, 5);
+ let messages = [...testFolder.messages];
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "page1.html": "<html><body>Page 1</body></html>",
+ "page2.html": "<html><body>Page 2</body></html>",
+ "background.js": async () => {
+ // Executes a command, but first loads a second extension with terminated
+ // background and waits for it to be restarted due to the executed command.
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve(args);
+ }
+ },
+ onCreated(...args) {
+ this.pushEvent("onCreated", ...args);
+ },
+ onUpdated(...args) {
+ this.pushEvent("onUpdated", ...args);
+ },
+ onActivated(...args) {
+ this.pushEvent("onActivated", ...args);
+ },
+ onRemoved(...args) {
+ this.pushEvent("onRemoved", ...args);
+ },
+ async nextEvent() {
+ if (this.events.length == 0) {
+ return new Promise(
+ resolve => (this.currentPromise = { resolve })
+ );
+ }
+ return Promise.resolve(this.events[0]);
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ await this.nextEvent();
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(
+ typeof expectedArgs[i],
+ typeof actualArgs[i]
+ );
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(
+ expectedArgs[i][key],
+ actualArgs[i][key]
+ );
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+ return actualArgs;
+ },
+ async pageLoad(tab, active = true) {
+ while (true) {
+ // Read the first event without consuming it.
+ let [actualEvent, actualTabId, actualInfo, actualTab] =
+ await this.nextEvent();
+ browser.test.assertEq("onUpdated", actualEvent);
+ browser.test.assertEq(tab, actualTabId);
+
+ if (
+ actualInfo.status == "loading" ||
+ actualTab.url == "about:blank"
+ ) {
+ // We're not interested in these events. Take them off the list.
+ browser.test.log("Skipping this event.");
+ this.events.shift();
+ } else {
+ break;
+ }
+ }
+ await this.checkEvent(
+ "onUpdated",
+ tab,
+ { status: "complete" },
+ {
+ id: tab,
+ windowId: initialWindow,
+ active,
+ mailTab: false,
+ }
+ );
+ },
+ };
+
+ browser.tabs.onCreated.addListener(listener.onCreated.bind(listener));
+ browser.tabs.onUpdated.addListener(listener.onUpdated.bind(listener), {
+ properties: ["status"],
+ });
+ browser.tabs.onActivated.addListener(
+ listener.onActivated.bind(listener)
+ );
+ browser.tabs.onRemoved.addListener(listener.onRemoved.bind(listener));
+
+ browser.test.log(
+ "Collect the ID of the initial tab (there must be only one) and window."
+ );
+
+ let initialTabs = await browser.tabs.query({});
+ browser.test.assertEq(1, initialTabs.length);
+ browser.test.assertEq(0, initialTabs[0].index);
+ browser.test.assertTrue(initialTabs[0].mailTab);
+ browser.test.assertEq("mail", initialTabs[0].type);
+ let [{ id: initialTab, windowId: initialWindow }] = initialTabs;
+
+ browser.test.log("Add a first content tab and wait for it to load.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 1,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "content",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.tabs.create({
+ url: browser.runtime.getURL("page1.html"),
+ })
+ )
+ );
+ let [{ id: contentTab1 }] = await listener.checkEvent("onCreated", {
+ index: 1,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "content",
+ });
+ browser.test.assertTrue(contentTab1 != initialTab);
+ await listener.pageLoad(contentTab1);
+ browser.test.assertEq(
+ "content",
+ (await browser.tabs.get(contentTab1)).type
+ );
+
+ browser.test.log("Add a second content tab and wait for it to load.");
+
+ // The external extension is looking for the onUpdated event, it either be
+ // a loading or completed event. Compare with whatever the local extension
+ // is getting.
+ let locContentTabUpdateInfoPromise = new Promise(resolve => {
+ let listener = (...args) => {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve(args);
+ };
+ browser.tabs.onUpdated.addListener(listener, {
+ properties: ["status"],
+ });
+ });
+ let primedContentTabUpdateInfo = await capturePrimedEvent(
+ "onUpdated",
+ () =>
+ browser.tabs.create({
+ url: browser.runtime.getURL("page2.html"),
+ })
+ );
+ let [{ id: contentTab2 }] = await listener.checkEvent("onCreated", {
+ index: 2,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "content",
+ });
+ let locContentTabUpdateInfo = await locContentTabUpdateInfoPromise;
+ window.assertDeepEqual(
+ locContentTabUpdateInfo,
+ primedContentTabUpdateInfo,
+ "primed onUpdated event and non-primed onUpdeated event should receive the same values",
+ { strict: true }
+ );
+
+ browser.test.assertTrue(
+ ![initialTab, contentTab1].includes(contentTab2)
+ );
+ await listener.pageLoad(contentTab2);
+ browser.test.assertEq(
+ "content",
+ (await browser.tabs.get(contentTab2)).type
+ );
+
+ browser.test.log("Add the calendar tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 3,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "calendar",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openCalendarTab")
+ )
+ );
+ let [{ id: calendarTab }] = await listener.checkEvent("onCreated", {
+ index: 3,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "calendar",
+ });
+ browser.test.assertTrue(
+ ![initialTab, contentTab1, contentTab2].includes(calendarTab)
+ );
+
+ browser.test.log("Add the task tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 4,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "tasks",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openTaskTab")
+ )
+ );
+ let [{ id: taskTab }] = await listener.checkEvent("onCreated", {
+ index: 4,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "tasks",
+ });
+ browser.test.assertTrue(
+ ![initialTab, contentTab1, contentTab2, calendarTab].includes(taskTab)
+ );
+
+ browser.test.log("Open a folder in a tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 5,
+ windowId: initialWindow,
+ active: true,
+ mailTab: true,
+ type: "mail",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openFolderTab")
+ )
+ );
+ let [{ id: folderTab }] = await listener.checkEvent("onCreated", {
+ index: 5,
+ windowId: initialWindow,
+ active: true,
+ mailTab: true,
+ type: "mail",
+ });
+ browser.test.assertTrue(
+ ![
+ initialTab,
+ contentTab1,
+ contentTab2,
+ calendarTab,
+ taskTab,
+ ].includes(folderTab)
+ );
+
+ browser.test.log("Open a first message in a tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 6,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "messageDisplay",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openMessageTab", false)
+ )
+ );
+
+ let [{ id: messageTab1 }] = await listener.checkEvent("onCreated", {
+ index: 6,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "messageDisplay",
+ });
+ browser.test.assertTrue(
+ ![
+ initialTab,
+ contentTab1,
+ contentTab2,
+ calendarTab,
+ taskTab,
+ folderTab,
+ ].includes(messageTab1)
+ );
+ await listener.pageLoad(messageTab1);
+
+ browser.test.log(
+ "Open a second message in a tab. In the background, just because."
+ );
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 7,
+ windowId: initialWindow,
+ active: false,
+ mailTab: false,
+ type: "messageDisplay",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openMessageTab", true)
+ )
+ );
+ let [{ id: messageTab2 }] = await listener.checkEvent("onCreated", {
+ index: 7,
+ windowId: initialWindow,
+ active: false,
+ mailTab: false,
+ type: "messageDisplay",
+ });
+ browser.test.assertTrue(
+ ![
+ initialTab,
+ contentTab1,
+ contentTab2,
+ calendarTab,
+ taskTab,
+ folderTab,
+ messageTab1,
+ ].includes(messageTab2)
+ );
+ await listener.pageLoad(messageTab2, false);
+
+ browser.test.log(
+ "Activate each of the tabs in a somewhat random order to test the onActivated event."
+ );
+
+ let previousTabId = messageTab1;
+ for (let tab of [
+ initialTab,
+ calendarTab,
+ messageTab1,
+ taskTab,
+ contentTab1,
+ messageTab2,
+ folderTab,
+ contentTab2,
+ ]) {
+ window.assertDeepEqual(
+ [{ tabId: tab, windowId: initialWindow }],
+ await capturePrimedEvent("onActivated", () =>
+ browser.tabs.update(tab, { active: true })
+ )
+ );
+ await listener.checkEvent("onActivated", {
+ tabId: tab,
+ previousTabId,
+ windowId: initialWindow,
+ });
+ previousTabId = tab;
+ }
+
+ browser.test.log(
+ "Remove the first content tab. This was not active so no new tab should be activated."
+ );
+
+ window.assertDeepEqual(
+ [contentTab1, { windowId: initialWindow, isWindowClosing: false }],
+ await capturePrimedEvent("onRemoved", () =>
+ browser.tabs.remove(contentTab1)
+ )
+ );
+ await listener.checkEvent("onRemoved", contentTab1, {
+ windowId: initialWindow,
+ isWindowClosing: false,
+ });
+
+ browser.test.log(
+ "Remove the second content tab. This was active, and the calendar tab is after it, so that should be activated."
+ );
+
+ window.assertDeepEqual(
+ [contentTab2, { windowId: initialWindow, isWindowClosing: false }],
+ await capturePrimedEvent("onRemoved", () =>
+ browser.tabs.remove(contentTab2)
+ )
+ );
+ await listener.checkEvent("onRemoved", contentTab2, {
+ windowId: initialWindow,
+ isWindowClosing: false,
+ });
+ await listener.checkEvent("onActivated", {
+ tabId: calendarTab,
+ windowId: initialWindow,
+ });
+
+ browser.test.log("Remove the remaining tabs.");
+
+ for (let tab of [
+ taskTab,
+ messageTab1,
+ messageTab2,
+ folderTab,
+ calendarTab,
+ ]) {
+ window.assertDeepEqual(
+ [tab, { windowId: initialWindow, isWindowClosing: false }],
+ await capturePrimedEvent("onRemoved", () =>
+ browser.tabs.remove(tab)
+ )
+ );
+ await listener.checkEvent("onRemoved", tab, {
+ windowId: initialWindow,
+ isWindowClosing: false,
+ });
+ }
+
+ // Since the last tab was activated because all other tabs have been
+ // removed, previousTabId should be undefined.
+ await listener.checkEvent("onActivated", {
+ tabId: initialTab,
+ windowId: initialWindow,
+ previousTabId: undefined,
+ });
+
+ browser.test.assertEq(0, listener.events.length);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["tabs"],
+ },
+ });
+
+ // Function to start an event page extension (MV3), which can be called whenever
+ // the main test is about to trigger an event. The extension terminates its
+ // background and listens for that single event, verifying it is waking up correctly.
+ async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let eventName = browser.runtime.getManifest().description;
+
+ if (["onCreated", "onActivated", "onRemoved"].includes(eventName)) {
+ browser.tabs[eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ if (eventName == "onUpdated") {
+ browser.tabs.onUpdated.addListener(
+ (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onUpdated received", args);
+ }
+ },
+ {
+ properties: ["status"],
+ }
+ );
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ permissions: ["tabs"],
+ browser_specific_settings: {
+ gecko: { id: `tabs.eventpage.${eventName}@mochi.test` },
+ },
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "tabs", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "tabs", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "tabs", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+ }
+
+ extension.onMessage("openCalendarTab", () => {
+ let calendarTabButton = document.getElementById("calendarButton");
+ EventUtils.synthesizeMouseAtCenter(calendarTabButton, {
+ clickCount: 1,
+ });
+ });
+
+ extension.onMessage("openTaskTab", () => {
+ let taskTabButton = document.getElementById("tasksButton");
+ EventUtils.synthesizeMouseAtCenter(taskTabButton, { clickCount: 1 });
+ });
+
+ extension.onMessage("openFolderTab", () => {
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: false,
+ });
+ });
+
+ extension.onMessage("openMessageTab", background => {
+ let msgHdr = messages.shift();
+ tabmail.openTab("mailMessageTab", {
+ messageURI: testFolder.getUriForMsg(msgHdr),
+ background,
+ });
+ });
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution in the main test, after the event page extension is
+ // ready to capture the event with deactivated background.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js
new file mode 100644
index 0000000000..9a249c62cb
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js
@@ -0,0 +1,306 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("testFolder", null);
+ await createMessages(rootFolder.getChildNamed("testFolder"), 5);
+});
+
+add_task(async function test_tabs_move() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Works as intended only if tabs are created one after the other.
+ async function createTab(url) {
+ let createdTab;
+ let loadPromise = new Promise(resolve => {
+ let urlSeen = false;
+ let listener = (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+ createdTab = await browser.tabs.create({ url });
+ await loadPromise;
+ return createdTab;
+ }
+
+ // Works as intended only if windows are created one after the other.
+ async function createWindow({ url, type }) {
+ let createdWindow;
+ let loadPromise = new Promise(resolve => {
+ if (!url) {
+ resolve();
+ } else {
+ let urlSeen = false;
+ let listener = async (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ }
+ });
+ createdWindow = await browser.windows.create({ type, url });
+ await loadPromise;
+ return createdWindow;
+ }
+
+ let mailWindow = await browser.windows.getCurrent();
+
+ let tab1 = await createTab(browser.runtime.getURL("test1.html"));
+ let tab2 = await createTab(browser.runtime.getURL("test2.html"));
+ let tab3 = await createTab(browser.runtime.getURL("test3.html"));
+ let tab4 = await createTab(browser.runtime.getURL("test4.html"));
+
+ let tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(5, tabs.length, "Number of tabs is correct");
+ browser.test.assertEq(
+ tab1.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab1"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab2"
+ );
+ browser.test.assertEq(
+ tab3.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab3"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[4].id,
+ "Id of tab at index 4 should be that of tab4"
+ );
+ browser.test.assertEq(1, tabs[1].index, "Index of tab1 is correct");
+ browser.test.assertEq(2, tabs[2].index, "Index of tab2 is correct");
+ browser.test.assertEq(3, tabs[3].index, "Index of tab3 is correct");
+ browser.test.assertEq(4, tabs[4].index, "Index of tab4 is correct");
+
+ // Move two tabs to the end of the current window.
+ await browser.tabs.move([tab2.id, tab1.id], { index: -1 });
+
+ tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(
+ 5,
+ tabs.length,
+ "Number of tabs after move #1 is correct"
+ );
+ browser.test.assertEq(
+ tab3.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab3 after move #1"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab4 after move #1"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab2 after move #1"
+ );
+ browser.test.assertEq(
+ tab1.id,
+ tabs[4].id,
+ "Id of tab at index 4 should be that of tab1 after move #1"
+ );
+ browser.test.assertEq(
+ 1,
+ tabs[1].index,
+ "Index of tab3 after move #1 is correct"
+ );
+ browser.test.assertEq(
+ 2,
+ tabs[2].index,
+ "Index of tab4 after move #1 is correct"
+ );
+ browser.test.assertEq(
+ 3,
+ tabs[3].index,
+ "Index of tab2 after move #1 is correct"
+ );
+ browser.test.assertEq(
+ 4,
+ tabs[4].index,
+ "Index of tab1 after move #1 is correct"
+ );
+
+ // Move a single tab to a specific location in current window.
+ await browser.tabs.move(tab3.id, { index: 3 });
+
+ tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(
+ 5,
+ tabs.length,
+ "Number of tabs after move #2 is correct"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab4 after move #2"
+ );
+ browser.test.assertEq(
+ tab3.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab3 after move #2"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab2 after move #2"
+ );
+ browser.test.assertEq(
+ tab1.id,
+ tabs[4].id,
+ "Id of tab at index 4 should be that of tab1 after move #2"
+ );
+ browser.test.assertEq(
+ 1,
+ tabs[1].index,
+ "Index of tab4 after move #2 is correct"
+ );
+ browser.test.assertEq(
+ 2,
+ tabs[2].index,
+ "Index of tab3 after move #2 is correct"
+ );
+ browser.test.assertEq(
+ 3,
+ tabs[3].index,
+ "Index of tab2 after move #2 is correct"
+ );
+ browser.test.assertEq(
+ 4,
+ tabs[4].index,
+ "Index of tab1 after move #2 is correct"
+ );
+
+ // Moving tabs to a popup should fail.
+ let popupWindow = await createWindow({
+ url: browser.runtime.getURL("test1.html"),
+ type: "popup",
+ });
+ await browser.test.assertRejects(
+ browser.tabs.move([tab3.id, tabs[4].id], {
+ windowId: popupWindow.id,
+ index: -1,
+ }),
+ `Window with ID ${popupWindow.id} is not a normal window`,
+ "Moving tabs to a popup window should fail."
+ );
+
+ // Moving a tab from a popup should fail.
+ let [popupTab] = await browser.tabs.query({ windowId: popupWindow.id });
+ await browser.test.assertRejects(
+ browser.tabs.move(popupTab.id, {
+ windowId: mailWindow.id,
+ index: -1,
+ }),
+ `Tab with ID ${popupTab.id} does not belong to a normal window`,
+ "Moving tabs from a popup window should fail."
+ );
+
+ // Moving a tab to an invalid window should fail.
+ await browser.test.assertRejects(
+ browser.tabs.move(popupTab.id, { windowId: 1234, index: -1 }),
+ `Invalid window ID: 1234`,
+ "Moving tabs to an invalid window should fail."
+ );
+
+ // Move tab between windows.
+ let secondMailWindow = await createWindow({ type: "normal" });
+ let [movedTab] = await browser.tabs.move(tab3.id, {
+ windowId: secondMailWindow.id,
+ index: -1,
+ });
+
+ tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(
+ 4,
+ tabs.length,
+ "Number of tabs after move #3 is correct"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab4 after move #3"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab2 after move #3"
+ );
+ browser.test.assertEq(
+ tab1.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab1 after move #3"
+ );
+ browser.test.assertEq(
+ 1,
+ tabs[1].index,
+ "Index of tab4 after move #3 is correct"
+ );
+ browser.test.assertEq(
+ 2,
+ tabs[2].index,
+ "Index of tab2 after move #3 is correct"
+ );
+ browser.test.assertEq(
+ 3,
+ tabs[3].index,
+ "Index of tab1 after move #3 is correct"
+ );
+
+ tabs = await browser.tabs.query({ windowId: secondMailWindow.id });
+ browser.test.assertEq(
+ 2,
+ tabs.length,
+ "Number of tabs in the second normal window after move #3 is correct"
+ );
+ browser.test.assertEq(
+ movedTab.id,
+ tabs[1].id,
+ "Id of tab at index 1 of the second normal window should be that of the moved tab"
+ );
+
+ await browser.tabs.remove(tab1.id);
+ await browser.tabs.remove(tab2.id);
+ await browser.tabs.remove(tab4.id);
+ await browser.windows.remove(popupWindow.id);
+ await browser.windows.remove(secondMailWindow.id);
+
+ browser.test.notifyPass();
+ },
+ "test1.html": "<html><body>I'm page #1!</body></html>",
+ "test2.html": "<html><body>I'm page #2!</body></html>",
+ "test3.html": "<html><body>I'm page #3!</body></html>",
+ "test4.html": "<html><body>I'm page #4!</body></html>",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js
new file mode 100644
index 0000000000..515c7695bf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js
@@ -0,0 +1,226 @@
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+async function getTestExtension() {
+ let files = {
+ "background.js": async () => {
+ let [location] = await window.waitForMessage();
+
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Get displayed message.
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+
+ // Open message in a new tab, wait for onCreated and for onUpdated.
+ let messageTab = await new Promise(resolve => {
+ let createListener = tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ browser.test.assertEq(
+ "loading",
+ tab.status,
+ "The tab is expected to be still loading."
+ );
+ browser.tabs.onUpdated.addListener(updateListener, {
+ tabId: tab.id,
+ });
+ };
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (changeInfo.status) {
+ browser.test.assertEq(
+ tab.status,
+ changeInfo.status,
+ "We should see the same status in tab and in changeInfo."
+ );
+ if (changeInfo.status == "complete") {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(tab);
+ }
+ }
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ browser.messageDisplay.open({
+ location,
+ messageId: message1.id,
+ });
+ });
+
+ // We should now be able to get the message.
+ let message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2?.id,
+ "We should see the same message."
+ );
+
+ // We should be able to get the message later as well.
+ await new Promise(resolve => window.setTimeout(resolve));
+ let message3 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message3,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message3?.id,
+ "We should see the same message."
+ );
+
+ browser.tabs.remove(messageTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ return ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+}
+
+/**
+ * Open a message tab and check its status, wait till loaded and get the message.
+ */
+add_task(async function test_onCreated_message_tab() {
+ let extension = await getTestExtension();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("tab");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Open a message window and check its status, wait till loaded and get the message.
+ */
+add_task(async function test_onCreated_message_window() {
+ let extension = await getTestExtension();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("window");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Open an address book tab and check its status.
+ */
+add_task(async function test_onCreated_addressBook_tab() {
+ let files = {
+ "background.js": async () => {
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Open ab tab, wait for onCreated and for onUpdated.
+ let abTab = await new Promise(resolve => {
+ let createListener = tab => {
+ browser.test.assertEq(
+ "loading",
+ tab.status,
+ "The tab is expected to be still loading."
+ );
+ browser.tabs.onUpdated.addListener(updateListener, {
+ tabId: tab.id,
+ });
+ };
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (changeInfo.status) {
+ browser.test.assertEq(
+ tab.status,
+ changeInfo.status,
+ "We should see the same status in tab and in changeInfo."
+ );
+ if (changeInfo.status == "complete") {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(tab);
+ }
+ }
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ browser.addressBooks.openUI();
+ });
+ browser.test.assertEq(
+ "addressBook",
+ abTab.type,
+ "We should find an addressBook tab."
+ );
+ browser.tabs.remove(abTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("window");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js
new file mode 100644
index 0000000000..6cecde63c7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testQuery() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // There should be a single mailtab at startup.
+ let tabs = await browser.tabs.query({});
+
+ browser.test.assertEq(1, tabs.length, "Found one tab at startup");
+ browser.test.assertEq("mail", tabs[0].type, "Tab is mail tab");
+ let mailTab = tabs[0];
+
+ // Create a content tab.
+ let contentTab = await browser.tabs.create({ url: "test.html" });
+ browser.test.assertTrue(
+ contentTab.id != mailTab.id,
+ "Id of content tab is different from mail tab"
+ );
+
+ // Query spaces.
+ let spaces = await browser.spaces.query({ id: mailTab.spaceId });
+ browser.test.assertEq(1, spaces.length, "Found one matching space");
+ browser.test.assertEq(
+ "mail",
+ spaces[0].name,
+ "Space is the mail space"
+ );
+
+ // Query for all tabs.
+ tabs = await browser.tabs.query({});
+ browser.test.assertEq(2, tabs.length, "Found two tabs");
+
+ // Query for the content tab.
+ tabs = await browser.tabs.query({ type: "content" });
+ browser.test.assertEq(1, tabs.length, "Found one content tab");
+ browser.test.assertEq(
+ contentTab.id,
+ tabs[0].id,
+ "Id of content tab is correct"
+ );
+
+ // Query for the mail tab using spaceId.
+ tabs = await browser.tabs.query({ spaceId: mailTab.spaceId });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for the mail tab using type.
+ tabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for the mail tab using mailTab.
+ tabs = await browser.tabs.query({ mailTab: true });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for the content tab but also using mailTab.
+ tabs = await browser.tabs.query({ mailTab: true, type: "content" });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for active tab.
+ tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ contentTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for highlighted tab.
+ tabs = await browser.tabs.query({ highlighted: true });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ contentTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ await browser.tabs.remove(contentTab.id);
+ browser.test.notifyPass();
+ },
+ "test.html": "<html><body>I'm a real page!</body></html>",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js
new file mode 100644
index 0000000000..acd3bce0a7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js
@@ -0,0 +1,578 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {
+ return {
+ possibleApplicationHandlers: Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ ),
+ };
+ },
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+/**
+ * Update registered WebExtension protocol handler pages.
+ */
+add_task(async function testUpdateTabs_WebExtProtocolHandler() {
+ let files = {
+ "background.js": async () => {
+ // Test a mail tab.
+
+ let [mailTab] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertTrue(!!mailTab, "Should have found a mail tab.");
+
+ // Load a message.
+ let { messages } = await browser.messages.list(mailTab.displayedFolder);
+ await browser.mailTabs.setSelectedMessages(mailTab.id, [messages[0].id]);
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+ mailTab = await browser.tabs.get(mailTab.id);
+ browser.test.assertTrue(
+ mailTab.url.startsWith("mailbox:"),
+ "A message should be loaded"
+ );
+
+ // Update to a registered WebExtension protocol handler.
+ await new Promise(resolve => {
+ let urlSeen = false;
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (
+ changeInfo.url &&
+ changeInfo.url.endsWith("handler.html#ext%2Btest%3A1234-1")
+ ) {
+ urlSeen = true;
+ }
+ if (urlSeen && changeInfo.status == "complete") {
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ browser.tabs.update(mailTab.id, { url: "ext+test:1234-1" });
+ });
+
+ mailTab = await browser.tabs.get(mailTab.id);
+ browser.test.assertTrue(
+ mailTab.url.endsWith("handler.html#ext%2Btest%3A1234-1"),
+ "Should have found the correct protocol handler url loaded"
+ );
+
+ // Test a message tab.
+
+ let messageTab = await browser.messageDisplay.open({
+ location: "tab",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertTrue(
+ mailTab.windowId == messageTab.windowId,
+ "Tab should be in the main window."
+ );
+
+ // Updating a message tab to a registered WebExtension protocol handler
+ // should throw.
+ browser.test.assertRejects(
+ browser.tabs.update(messageTab.id, { url: "ext+test:1234-1" }),
+ /Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs./,
+ "Updating a message tab to a registered WebExtension protocol handler should throw"
+ );
+ browser.tabs.remove(messageTab.id);
+
+ // Test a message window.
+
+ let messageWindowTab = await browser.messageDisplay.open({
+ location: "window",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageWindowTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == messageWindowTab.windowId,
+ "Tab should not be in the main window."
+ );
+
+ // Updating a message window to a registered WebExtension protocol handler
+ // should throw.
+ browser.test.assertRejects(
+ browser.tabs.update(messageWindowTab.id, { url: "ext+test:1234-1" }),
+ /Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs./,
+ "Updating a message tab to a registered WebExtension protocol handler should throw"
+ );
+
+ browser.tabs.remove(messageWindowTab.id);
+
+ // Test a compose window.
+
+ let details1 = { to: ["Mr. Holmes <holmes@bakerstreet.invalid>"] };
+ let composeTab = await browser.compose.beginNew(details1);
+ browser.test.assertEq(
+ "messageCompose",
+ composeTab.type,
+ "Should have found a compose tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == composeTab.windowId,
+ "Tab should not be in the main window."
+ );
+ let details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ // Updating a message window to a registered WebExtension protocol handler
+ // should throw.
+ browser.test.assertRejects(
+ browser.tabs.update(composeTab.id, { url: "ext+test:1234-1" }),
+ /Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs./,
+ "Updating a message tab to a registered WebExtension protocol handler should throw"
+ );
+
+ browser.tabs.remove(composeTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "handler.html": "<html><body><p>Test Protocol Handler</p></body></html>",
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs", "compose"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Reload and update tabs and check if it fails for forbidden cases, keep track
+ * of urls opened externally.
+ */
+add_task(async function testUpdateReloadTabs() {
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+
+ let files = {
+ "background.js": async () => {
+ // Test a mail tab.
+
+ let [mailTab] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertTrue(!!mailTab, "Should have found a mail tab.");
+
+ // Load a URL.
+ await new Promise(resolve => {
+ let urlSeen = false;
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (changeInfo.url == "https://www.example.com/") {
+ urlSeen = true;
+ }
+ if (urlSeen && changeInfo.status == "complete") {
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ browser.tabs.update(mailTab.id, { url: "https://www.example.com/" });
+ });
+
+ browser.test.assertEq(
+ "https://www.example.com/",
+ (await browser.tabs.get(mailTab.id)).url,
+ "Should have found the correct url loaded"
+ );
+
+ // This should not throw.
+ await browser.tabs.reload(mailTab.id);
+
+ // Update a tel:// url.
+ await browser.tabs.update(mailTab.id, { url: "tel:1234-1" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-1");
+
+ // We should still have the same url displayed.
+ browser.test.assertEq(
+ "https://www.example.com/",
+ (await browser.tabs.get(mailTab.id)).url,
+ "Should have found the correct url loaded"
+ );
+
+ // Load a message.
+ let { messages } = await browser.messages.list(mailTab.displayedFolder);
+ await browser.mailTabs.setSelectedMessages(mailTab.id, [messages[1].id]);
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+ mailTab = await browser.tabs.get(mailTab.id);
+ browser.test.assertFalse(
+ "https://www.example.com/" == mailTab.url,
+ "Webpage should no longer be loaded"
+ );
+
+ // Reload should now fail.
+ browser.test.assertRejects(
+ browser.tabs.reload(mailTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a mail tab not displaying a content page should throw"
+ );
+
+ // We should still see the same message.
+ let message2 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(mailTab.id, { url: "tel:1234-2" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-2");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(mailTab.id);
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-1" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-1");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(mailTab.id);
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Test a message tab.
+
+ let messageTab = await browser.messageDisplay.open({
+ location: "tab",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertTrue(
+ mailTab.windowId == messageTab.windowId,
+ "Tab should be in the main window."
+ );
+
+ browser.test.assertRejects(
+ browser.tabs.reload(messageTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a message tab should throw"
+ );
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(messageTab.id, { url: "tel:1234-3" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-3");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-2" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-2");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ browser.tabs.remove(messageTab.id);
+
+ // Test a message window.
+
+ let messageWindowTab = await browser.messageDisplay.open({
+ location: "window",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageWindowTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == messageWindowTab.windowId,
+ "Tab should not be in the main window."
+ );
+
+ browser.test.assertRejects(
+ browser.tabs.reload(messageWindowTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a message window should throw"
+ );
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageWindowTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(messageWindowTab.id, { url: "tel:1234-4" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-4");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageWindowTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-3" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-3");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageWindowTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ browser.tabs.remove(messageWindowTab.id);
+
+ // Test a compose window.
+
+ let details1 = { to: ["Mr. Holmes <holmes@bakerstreet.invalid>"] };
+ let composeTab = await browser.compose.beginNew(details1);
+ browser.test.assertEq(
+ "messageCompose",
+ composeTab.type,
+ "Should have found a compose tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == composeTab.windowId,
+ "Tab should not be in the main window."
+ );
+ let details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ browser.test.assertRejects(
+ browser.tabs.reload(composeTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a compose window should throw"
+ );
+
+ // We should still see the same composer.
+ details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(composeTab.id, { url: "tel:1234-5" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-5");
+
+ // We should still see the same composer.
+ details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-4" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-4");
+
+ // We should still see the same composer.
+ details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ browser.tabs.remove(composeTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs", "compose"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+
+ extension.onMessage("check_external_loaded_url", async expected => {
+ Assert.equal(
+ 1,
+ mockExternalProtocolService._loadedURLs.length,
+ "Should have found a single loaded url"
+ );
+ Assert.equal(
+ mockExternalProtocolService._loadedURLs[0],
+ expected,
+ "Should have found the expected url"
+ );
+ mockExternalProtocolService._loadedURLs = [];
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ Assert.equal(
+ 0,
+ mockExternalProtocolService._loadedURLs.length,
+ "Should not have any unexpected urls loaded externally"
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js b/comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js
new file mode 100644
index 0000000000..b2e6a40fb1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js
@@ -0,0 +1,150 @@
+"use strict";
+
+// This test checks whether browser.theme.onUpdated works
+// when a static theme is applied
+
+const ACCENT_COLOR = "#a14040";
+const TEXT_COLOR = "#fac96e";
+const BACKGROUND =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" +
+ "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+
+add_task(async function test_on_updated() {
+ const theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.theme.onUpdated.addListener(updateInfo => {
+ browser.test.sendMessage("theme-updated", updateInfo);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Testing update event on static theme startup");
+ let updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.startup();
+ const { theme: receivedTheme, windowId } = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event");
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "Theme theme_frame image should be applied"
+ );
+ Assert.equal(
+ receivedTheme.colors.frame,
+ ACCENT_COLOR,
+ "Theme frame color should be applied"
+ );
+ Assert.equal(
+ receivedTheme.colors.tab_background_text,
+ TEXT_COLOR,
+ "Theme tab_background_text color should be applied"
+ );
+
+ info("Testing update event on static theme unload");
+ updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.unload();
+ const updateInfo = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event on unload");
+ Assert.equal(
+ Object.keys(updateInfo.theme),
+ 0,
+ "unloading theme sends empty theme in update event"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_on_updated_eventpage() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.eventPages.enabled", true]],
+ });
+ const theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset
+ // and allows to observe the order of events fired. In case of a wake-up,
+ // the first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.theme.onUpdated.addListener(async updateInfo => {
+ browser.test.sendMessage("theme-updated", {
+ eventCount: ++eventCounter,
+ ...updateInfo,
+ });
+ });
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: { gecko: { id: "themes@mochi.test" } },
+ },
+ });
+
+ await extension.startup();
+ assertPersistentListeners(extension, "theme", "onUpdated", {
+ primed: false,
+ });
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ assertPersistentListeners(extension, "theme", "onUpdated", {
+ primed: true,
+ });
+
+ info("Testing update event on static theme startup");
+
+ await theme.startup();
+
+ const {
+ eventCount,
+ theme: receivedTheme,
+ windowId,
+ } = await extension.awaitMessage("theme-updated");
+ Assert.equal(eventCount, 1, "Event counter should be correct");
+ Assert.ok(!windowId, "No window id in static theme update event");
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "Theme theme_frame image should be applied"
+ );
+
+ await theme.unload();
+ await extension.awaitMessage("theme-updated");
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js
new file mode 100644
index 0000000000..483482981e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js
@@ -0,0 +1,685 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let subFolders;
+let messages;
+
+async function showTooltip(elementSelector, tooltip, browser, description) {
+ Assert.ok(!!tooltip, "tooltip element should exist");
+ tooltip.ownerGlobal.windowUtils.disableNonTestMouseEvents(true);
+ try {
+ while (tooltip.state != "open") {
+ // We first have to click on the element, otherwise a mousemove event will not
+ // trigger the tooltip.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ await synthesizeMouseAtCenterAndRetry(
+ elementSelector,
+ { button: 1 },
+ browser
+ );
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ await synthesizeMouseAtCenterAndRetry(
+ elementSelector,
+ { type: "mousemove" },
+ browser
+ );
+
+ try {
+ await TestUtils.waitForCondition(
+ () => tooltip.state == "open",
+ `Tooltip should have been shown for ${description}`
+ );
+ } catch (e) {
+ console.log(`Tooltip was not shown for ${description}, trying again.`);
+ }
+ }
+ } finally {
+ tooltip.ownerGlobal.windowUtils.disableNonTestMouseEvents(false);
+ }
+}
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ await TestUtils.waitForCondition(
+ () => subFolders[0].messages.hasMoreElements(),
+ "Messages should be added to folder"
+ );
+ messages = subFolders[0].messages;
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+add_task(async function test_browserAction_in_about3pane() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.browserAction.openPopup();
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let popupBrowser = document.querySelector(".webextension-popup-browser");
+ let tooltip = document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "browserAction in about3pane"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_browserAction_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the message window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open the popup after a message has been displayed.
+ browser.messageDisplay.onMessageDisplayed.addListener(
+ async (tab, message) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.browserAction.openPopup({ windowId: tab.windowId });
+ }
+ );
+
+ // Open a message in a window.
+ let { messages } = await browser.messages.query({});
+ browser.messageDisplay.open({
+ location: "window",
+ messageId: messages[0].id,
+ });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ browser_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ default_windows: ["messageDisplay"],
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let popupBrowser = messageWindow.document.querySelector(
+ ".webextension-popup-browser"
+ );
+ let tooltip = messageWindow.document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "browserAction in message window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_composeAction() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the compose window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ let composeTab = await browser.compose.beginNew();
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.composeAction.openPopup({ windowId: composeTab.windowId });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ compose_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let popupBrowser = composeWindow.document.querySelector(
+ ".webextension-popup-browser"
+ );
+ let tooltip = composeWindow.document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "composeAction in compose window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_messageDisplayAction_in_about3pane() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.messageDisplayAction.openPopup();
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ // The tooltip and the popup panel are defined in the top level messenger
+ // window, not in about:message.
+ let popupBrowser = document.querySelector(".webextension-popup-browser");
+ let tooltip = document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "messageDisplayAction in about3pane"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_messageDisplayAction_in_message_tab() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the message tab.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open the popup after a message has been displayed.
+ browser.messageDisplay.onMessageDisplayed.addListener(
+ async (tab, message) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.messageDisplayAction.openPopup({ windowId: tab.windowId });
+ }
+ );
+
+ // Open a message in a tab.
+ let { messages } = await browser.messages.query({});
+ browser.messageDisplay.open({
+ location: "tab",
+ messageId: messages[0].id,
+ });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ // The tooltip and the popup panel are defined in the top level messenger
+ // window, not in about:message.
+ let popupBrowser = document.querySelector(".webextension-popup-browser");
+ let tooltip = document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "messageDisplayAction in message tab"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_messageDisplayAction_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the message window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open the popup after a message has been displayed.
+ browser.messageDisplay.onMessageDisplayed.addListener(
+ async (tab, message) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.messageDisplayAction.openPopup({ windowId: tab.windowId });
+ }
+ );
+
+ // Open a message in a window.
+ let { messages } = await browser.messages.query({});
+ browser.messageDisplay.open({
+ location: "window",
+ messageId: messages[0].id,
+ });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let popupBrowser = messageWindow.document.querySelector(
+ ".webextension-popup-browser"
+ );
+ let tooltip = messageWindow.document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "messageDisplayAction in message window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_extension_window() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the extension window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open an extension window.
+ browser.windows.create({ type: "popup", url: "page.html" });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let extensionWindow = Services.wm.getMostRecentWindow(
+ "mail:extensionPopup"
+ );
+ let tooltip = extensionWindow.document.getElementById(
+ "remoteBrowserTooltip"
+ );
+ await showTooltip(
+ "p",
+ tooltip,
+ extensionWindow.browser,
+ "extension window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_extension_tab() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the extension tab.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open an extension tab.
+ browser.tabs.create({ url: "page.html" });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let tooltip = window.document.getElementById("remoteBrowserTooltip");
+ let browser = window.gTabmail.currentTabInfo.browser;
+ await showTooltip("p", tooltip, browser, "extension tab");
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows.js b/comm/mail/components/extensions/test/browser/browser_ext_windows.js
new file mode 100644
index 0000000000..6c996a8ca5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows.js
@@ -0,0 +1,439 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {},
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ urlLoaded(url) {
+ let found = this._loadedURLs.includes(url);
+ this._loadedURLs = this._loadedURLs.filter(e => e != url);
+ return found;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+add_task(async function test_openDefaultBrowser() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ const urls = {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://www.google.de/": true,
+ "https://www.google.de/": true,
+ "ftp://www.google.de/": false,
+ };
+
+ for (let [url, expected] of Object.entries(urls)) {
+ let rv = null;
+ try {
+ await browser.windows.openDefaultBrowser(url);
+ rv = true;
+ } catch (e) {
+ rv = false;
+ }
+ browser.test.assertEq(
+ rv,
+ expected,
+ `Checking result for browser.windows.openDefaultBrowser(${url})`
+ );
+ }
+ browser.test.sendMessage("ready", urls);
+ },
+ });
+
+ await extension.startup();
+ let urls = await extension.awaitMessage("ready");
+ for (let [url, expected] of Object.entries(urls)) {
+ Assert.equal(
+ mockExternalProtocolService.urlLoaded(url),
+ expected,
+ `Double check result for browser.windows.openDefaultBrowser(${url})`
+ );
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_focusWindows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let listener = {
+ waitingPromises: [],
+ waitForEvent() {
+ return new Promise(resolve => {
+ listener.waitingPromises.push(resolve);
+ });
+ },
+ checkWaiting() {
+ if (listener.waitingPromises.length < 1) {
+ browser.test.fail("Unexpected event fired");
+ }
+ },
+ created(win) {
+ listener.checkWaiting();
+ listener.waitingPromises.shift()(["onCreated", win]);
+ },
+ focusChanged(windowId) {
+ listener.checkWaiting();
+ listener.waitingPromises.shift()(["onFocusChanged", windowId]);
+ },
+ removed(windowId) {
+ listener.checkWaiting();
+ listener.waitingPromises.shift()(["onRemoved", windowId]);
+ },
+ };
+ browser.windows.onCreated.addListener(listener.created);
+ browser.windows.onFocusChanged.addListener(listener.focusChanged);
+ browser.windows.onRemoved.addListener(listener.removed);
+
+ let firstWindow = await browser.windows.getCurrent();
+ browser.test.assertEq("normal", firstWindow.type);
+
+ let currentWindows = await browser.windows.getAll();
+ browser.test.assertEq(1, currentWindows.length);
+ browser.test.assertEq(firstWindow.id, currentWindows[0].id);
+
+ // Open a new mail window.
+
+ let createdWindowPromise = listener.waitForEvent();
+ let focusChangedPromise1 = listener.waitForEvent();
+ let focusChangedPromise2 = listener.waitForEvent();
+ let eventName, createdWindow, windowId;
+
+ browser.test.sendMessage("openWindow");
+ [eventName, createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("onCreated", eventName);
+ browser.test.assertEq("normal", createdWindow.type);
+
+ [eventName, windowId] = await focusChangedPromise1;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(browser.windows.WINDOW_ID_NONE, windowId);
+
+ [eventName, windowId] = await focusChangedPromise2;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(createdWindow.id, windowId);
+
+ currentWindows = await browser.windows.getAll();
+ browser.test.assertEq(2, currentWindows.length);
+ browser.test.assertEq(firstWindow.id, currentWindows[0].id);
+ browser.test.assertEq(createdWindow.id, currentWindows[1].id);
+
+ // Focus the first window.
+
+ let platformInfo = await browser.runtime.getPlatformInfo();
+
+ let focusChangedPromise3;
+ if (["mac", "win"].includes(platformInfo.os)) {
+ // Mac and Windows don't fire this event. Pretend they do.
+ focusChangedPromise3 = Promise.resolve([
+ "onFocusChanged",
+ browser.windows.WINDOW_ID_NONE,
+ ]);
+ } else {
+ focusChangedPromise3 = listener.waitForEvent();
+ }
+ let focusChangedPromise4 = listener.waitForEvent();
+
+ browser.test.sendMessage("switchWindows");
+ [eventName, windowId] = await focusChangedPromise3;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(browser.windows.WINDOW_ID_NONE, windowId);
+
+ [eventName, windowId] = await focusChangedPromise4;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(firstWindow.id, windowId);
+
+ // Close the first window.
+
+ let removedWindowPromise = listener.waitForEvent();
+
+ browser.test.sendMessage("closeWindow");
+ [eventName, windowId] = await removedWindowPromise;
+ browser.test.assertEq("onRemoved", eventName);
+ browser.test.assertEq(createdWindow.id, windowId);
+
+ currentWindows = await browser.windows.getAll();
+ browser.test.assertEq(1, currentWindows.length);
+ browser.test.assertEq(firstWindow.id, currentWindows[0].id);
+
+ browser.windows.onCreated.removeListener(listener.created);
+ browser.windows.onFocusChanged.removeListener(listener.focusChanged);
+ browser.windows.onRemoved.removeListener(listener.removed);
+
+ browser.test.notifyPass();
+ },
+ });
+
+ let account = createAccount();
+
+ await extension.startup();
+
+ await extension.awaitMessage("openWindow");
+ let newWindowPromise = BrowserTestUtils.domWindowOpened();
+ window.MsgOpenNewWindowForFolder(account.incomingServer.rootFolder.URI);
+ let newWindow = await newWindowPromise;
+
+ await extension.awaitMessage("switchWindows");
+ window.focus();
+
+ await extension.awaitMessage("closeWindow");
+ newWindow.close();
+
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function checkTitlePreface() {
+ let l10n = new Localization([
+ "branding/brand.ftl",
+ "messenger/extensions/popup.ftl",
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "content.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>A test document</title>
+ <script type="text/javascript" src="content.js"></script>
+ </head>
+ <body>
+ <p>This is text.</p>
+ </body>
+ </html>
+ `,
+ "content.js": `
+ browser.runtime.onMessage.addListener(
+ (data, sender) => {
+ if (data.command == "close") {
+ window.close();
+ }
+ }
+ );`,
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ let popup;
+
+ // Test titlePreface during window creation.
+ {
+ let titlePreface = "PREFACE1";
+ let windowCreatePromise = window.waitForEvent("windows.onCreated");
+ // Do not await the create statement, but instead check if the onCreated
+ // event is delayed correctly to get the correct values.
+ browser.windows.create({
+ titlePreface,
+ url: "content.html",
+ type: "popup",
+ allowScriptsToClose: true,
+ });
+ popup = (await windowCreatePromise)[0];
+ let [expectedTitle] = await window.sendMessage(
+ "checkTitle",
+ titlePreface
+ );
+ browser.test.assertEq(
+ expectedTitle,
+ popup.title,
+ `Should find the correct title`
+ );
+ browser.test.assertEq(
+ true,
+ popup.focused,
+ `Should find the correct focus state`
+ );
+ }
+
+ // Test titlePreface during window update.
+ {
+ let titlePreface = "PREFACE2";
+ let updated = await browser.windows.update(popup.id, {
+ titlePreface,
+ });
+ let [expectedTitle] = await window.sendMessage(
+ "checkTitle",
+ titlePreface
+ );
+ browser.test.assertEq(
+ expectedTitle,
+ updated.title,
+ `Should find the correct title`
+ );
+ browser.test.assertEq(
+ true,
+ updated.focused,
+ `Should find the correct focus state`
+ );
+ }
+
+ // Finish
+ {
+ let windowRemovePromise = window.waitForEvent("windows.onRemoved");
+ browser.test.log(
+ "Testing allowScriptsToClose, waiting for window to close."
+ );
+ await browser.runtime.sendMessage({ command: "close" });
+ await windowRemovePromise;
+ }
+
+ // Test title after create without a preface.
+ {
+ let popup = await browser.windows.create({
+ url: "content.html",
+ type: "popup",
+ allowScriptsToClose: true,
+ });
+ let [expectedTitle] = await window.sendMessage("checkTitle", "");
+ browser.test.assertEq(
+ expectedTitle,
+ popup.title,
+ `Should find the correct title`
+ );
+ browser.test.assertEq(
+ true,
+ popup.focused,
+ `Should find the correct focus state`
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkTitle", async titlePreface => {
+ let win = Services.wm.getMostRecentWindow("mail:extensionPopup");
+
+ let defaultTitle = await l10n.formatValue("extension-popup-default-title");
+
+ let expectedTitle = titlePreface + "A test document";
+ // If we're on Mac, we don't display the separator and the app name (which
+ // is also used as default title).
+ if (AppConstants.platform != "macosx") {
+ expectedTitle += ` - ${defaultTitle}`;
+ }
+
+ Assert.equal(
+ win.document.title,
+ expectedTitle,
+ `Check if title is as expected.`
+ );
+ extension.sendMessage(expectedTitle);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_popupLayoutProperties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>Test body</p>
+ </body>
+ </html>`,
+ "background.js": async () => {
+ async function checkWindow(windowId, expected, retries = 0) {
+ let win = await browser.windows.get(windowId);
+
+ if (
+ retries &&
+ Object.keys(expected).some(key => expected[key] != win[key])
+ ) {
+ browser.test.log(
+ `Got mismatched size (${JSON.stringify(
+ expected
+ )} != ${JSON.stringify(win)}). Retrying after a short delay.`
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 200));
+ return checkWindow(windowId, expected, retries - 1);
+ }
+
+ for (let [key, value] of Object.entries(expected)) {
+ browser.test.assertEq(
+ value,
+ win[key],
+ `Should find the correct updated value for ${key}`
+ );
+ }
+
+ return true;
+ }
+
+ let tests = [
+ { retries: 0, properties: { state: "minimized" } },
+ { retries: 0, properties: { state: "maximized" } },
+ { retries: 0, properties: { state: "fullscreen" } },
+ {
+ retries: 5,
+ properties: { width: 210, height: 220, left: 90, top: 80 },
+ },
+ ];
+
+ // Test create.
+ for (let test of tests) {
+ let win = await browser.windows.create({
+ type: "popup",
+ url: "test.html",
+ ...test.properties,
+ });
+ await checkWindow(win.id, test.properties, test.retries);
+ await browser.windows.remove(win.id);
+ }
+
+ // Test update.
+ for (let test of tests) {
+ let win = await browser.windows.create({
+ type: "popup",
+ url: "test.html",
+ });
+ await browser.windows.update(win.id, test.properties);
+ await checkWindow(win.id, test.properties, test.retries);
+ await browser.windows.remove(win.id);
+ }
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: { scripts: ["background.js"] },
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js
new file mode 100644
index 0000000000..ee5acf743f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function check_focus() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Create a promise which waits until the script in the window is loaded
+ // and the email field has focus, so we can send our fake keystrokes.
+ let loadPromise = new Promise(resolve => {
+ let listener = async (msg, sender) => {
+ if (msg == "loaded") {
+ browser.runtime.onMessage.removeListener(listener);
+ resolve(sender.tab.windowId);
+ }
+ };
+ browser.runtime.onMessage.addListener(listener);
+ });
+
+ let openedWin = await browser.windows.create({
+ url: "focus.html",
+ type: "popup",
+ allowScriptsToClose: true,
+ });
+ let loadedWinId = await loadPromise;
+
+ browser.test.assertEq(
+ openedWin.id,
+ loadedWinId,
+ "The correct window should have been loaded"
+ );
+
+ let removePromise = new Promise(resolve => {
+ browser.windows.onRemoved.addListener(id => {
+ if (id == openedWin.id) {
+ resolve();
+ }
+ });
+ });
+
+ window.sendMessage("sendKeyStrokes", openedWin.id);
+
+ await removePromise;
+ browser.test.notifyPass("finished");
+ },
+ "focus.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="utils.js"></script>
+ <script src="focus.js"></script>
+ <title>Focus Test</title>
+ </head>
+ <body>
+ <input id="email" type="text"/>
+ <input id="delay" type="number" min="0" max="10" size="2"/>
+ </body>
+ </html>`,
+ "focus.js": () => {
+ async function load() {
+ let email = document.getElementById("email");
+ email.focus();
+
+ await new Promise(r => window.setTimeout(r));
+ await browser.runtime.sendMessage("loaded");
+
+ // Fails as expected if focus is not set in
+ // https://searchfox.org/comm-central/rev/be2751632bd695d17732ff590a71acb9b1ef920c/mail/components/extensions/extensionPopup.js#126-130
+ await window.waitForCondition(
+ () => email.value == "happy typing",
+ `Input field should have the correct value. Expected: "happy typing", actual: "${email.value}"`
+ );
+
+ window.close();
+ }
+ document.addEventListener("DOMContentLoaded", load, { once: true });
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("sendKeyStrokes", id => {
+ let window = Services.wm.getOuterWindowWithId(id);
+ EventUtils.sendString("happy typing", window);
+ extension.sendMessage("happy typing");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js
new file mode 100644
index 0000000000..19d34c26c5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js
@@ -0,0 +1,116 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Supported for creating normal windows is very limited in Thunderbird, a url
+// in the createData is ignored for example. This test only verifies that all the
+// things that are officially not supported, fail.
+
+add_task(async function no_cookies_permission() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "firefox-container-1" }),
+ /No permission for cookieStoreId/,
+ "cookieStoreId requires cookies permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function invalid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "not-firefox-container-1" }),
+ /Illegal cookieStoreId/,
+ "cookieStoreId must be valid"
+ );
+
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "firefox-private" }),
+ /Illegal to set private cookieStoreId in a non-private window/,
+ "cookieStoreId cannot be private in a non-private window"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "firefox-container-1" }),
+ /Contextual identities are currently disabled/,
+ "cookieStoreId cannot be a container tab ID when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function cookieStoreId_and_tabId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ for (let cookieStoreId of ["firefox-default", "firefox-container-1"]) {
+ let { id: normalTabId } = await browser.tabs.create({ cookieStoreId });
+
+ await browser.test.assertRejects(
+ browser.windows.create({
+ cookieStoreId: "firefox-container-2",
+ tabId: normalTabId,
+ }),
+ /`tabId` may not be used in conjunction with `cookieStoreId`/,
+ "Cannot use cookieStoreId for pre-existing tabs"
+ );
+
+ await browser.tabs.remove(normalTabId);
+ }
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js
new file mode 100644
index 0000000000..e547fb6b9b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js
@@ -0,0 +1,255 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function no_cookies_permission() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-container-1",
+ }),
+ /No permission for cookieStoreId/,
+ "cookieStoreId requires cookies permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function invalid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "not-firefox-container-1",
+ }),
+ /Illegal cookieStoreId/,
+ "cookieStoreId must be valid"
+ );
+
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-private",
+ }),
+ /Illegal to set private cookieStoreId in a non-private window/,
+ "cookieStoreId cannot be private in a non-private window"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-container-1",
+ }),
+ /Contextual identities are currently disabled/,
+ "cookieStoreId cannot be a container tab ID when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function valid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ const testCases = [
+ {
+ description: "one URL",
+ createParams: {
+ type: "popup",
+ url: "about:blank",
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreIds: ["firefox-container-1"],
+ expectedExecuteScriptResult: ["about:blank - null"],
+ },
+ {
+ description: "one URL in an array",
+ createParams: {
+ type: "popup",
+ url: ["about:blank"],
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreIds: ["firefox-container-1"],
+ expectedExecuteScriptResult: ["about:blank - null"],
+ },
+ ];
+
+ async function background(testCases) {
+ let readyTabs = new Map();
+ let tabReadyCheckers = new Set();
+ browser.webNavigation.onCompleted.addListener(({ url, tabId, frameId }) => {
+ if (frameId === 0) {
+ readyTabs.set(tabId, url);
+ browser.test.log(`Detected navigation in tab ${tabId} to ${url}.`);
+
+ for (let check of tabReadyCheckers) {
+ check(tabId, url);
+ }
+ }
+ });
+ async function awaitTabReady(tabId, expectedUrl) {
+ if (readyTabs.get(tabId) === expectedUrl) {
+ browser.test.log(`Tab ${tabId} was ready with URL ${expectedUrl}.`);
+ return;
+ }
+ await new Promise(resolve => {
+ browser.test.log(
+ `Waiting for tab ${tabId} to load URL ${expectedUrl}...`
+ );
+ tabReadyCheckers.add(function check(completedTabId, completedUrl) {
+ if (completedTabId === tabId && completedUrl === expectedUrl) {
+ tabReadyCheckers.delete(check);
+ resolve();
+ }
+ });
+ });
+ browser.test.log(`Tab ${tabId} is ready with URL ${expectedUrl}.`);
+ }
+
+ async function executeScriptAndGetResult(tabId) {
+ try {
+ return (
+ await browser.tabs.executeScript(tabId, {
+ matchAboutBlank: true,
+ code: "`${document.URL} - ${origin}`",
+ })
+ )[0];
+ } catch (e) {
+ return e.message;
+ }
+ }
+ for (let {
+ description,
+ createParams,
+ expectedCookieStoreIds,
+ expectedExecuteScriptResult,
+ } of testCases) {
+ let win = await browser.windows.create(createParams);
+
+ browser.test.assertEq(
+ expectedCookieStoreIds.length,
+ win.tabs.length,
+ "Expected number of tabs"
+ );
+
+ for (let [i, expectedCookieStoreId] of Object.entries(
+ expectedCookieStoreIds
+ )) {
+ browser.test.assertEq(
+ expectedCookieStoreId,
+ win.tabs[i].cookieStoreId,
+ `expected cookieStoreId for tab ${i} (${description})`
+ );
+ }
+
+ for (let [i, expectedResult] of Object.entries(
+ expectedExecuteScriptResult
+ )) {
+ // Wait until the the tab can process the tabs.executeScript calls.
+ // TODO: Remove this when bug 1418655 and bug 1397667 are fixed.
+ let expectedUrl = Array.isArray(createParams.url)
+ ? createParams.url[i]
+ : createParams.url || "about:home";
+ await awaitTabReady(win.tabs[i].id, expectedUrl);
+
+ let result = await executeScriptAndGetResult(win.tabs[i].id);
+ browser.test.assertEq(
+ expectedResult,
+ result,
+ `expected executeScript result for tab ${i} (${description})`
+ );
+ }
+
+ await browser.windows.remove(win.id);
+ }
+ browser.test.sendMessage("done");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies", "webNavigation"],
+ },
+ background: `(${background})(${JSON.stringify(testCases)})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function cookieStoreId_and_tabId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ for (let cookieStoreId of ["firefox-default", "firefox-container-1"]) {
+ let { id: normalTabId } = await browser.tabs.create({ cookieStoreId });
+
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-container-2",
+ tabId: normalTabId,
+ }),
+ /`tabId` may not be used in conjunction with `cookieStoreId`/,
+ "Cannot use cookieStoreId for pre-existing tabs"
+ );
+
+ await browser.tabs.remove(normalTabId);
+ }
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_events.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_events.js
new file mode 100644
index 0000000000..89cbd77e55
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_events.js
@@ -0,0 +1,405 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("windowsEvents", null);
+ let testFolder = rootFolder.findSubFolder("windowsEvents");
+ createMessages(testFolder, 5);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Executes a command, but first loads a second extension with terminated
+ // background and waits for it to be restarted due to the executed command.
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ let listener = {
+ tabEvents: [],
+ windowEvents: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ let queue = args[0].startsWith("windows.")
+ ? this.windowEvents
+ : this.tabEvents;
+ queue.push(args);
+ if (queue.currentPromise) {
+ let p = queue.currentPromise;
+ queue.currentPromise = null;
+ p.resolve();
+ }
+ },
+ windowsOnCreated(...args) {
+ this.pushEvent("windows.onCreated", ...args);
+ },
+ windowsOnRemoved(...args) {
+ this.pushEvent("windows.onRemoved", ...args);
+ },
+ tabsOnCreated(...args) {
+ this.pushEvent("tabs.onCreated", ...args);
+ },
+ tabsOnRemoved(...args) {
+ this.pushEvent("tabs.onRemoved", ...args);
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ let queue = expectedEvent.startsWith("windows.")
+ ? this.windowEvents
+ : this.tabEvents;
+ if (queue.length == 0) {
+ await new Promise(
+ resolve => (queue.currentPromise = { resolve })
+ );
+ }
+ let [actualEvent, ...actualArgs] = queue.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(
+ typeof expectedArgs[i],
+ typeof actualArgs[i]
+ );
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(
+ expectedArgs[i][key],
+ actualArgs[i][key]
+ );
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.tabs.onCreated.addListener(
+ listener.tabsOnCreated.bind(listener)
+ );
+ browser.tabs.onRemoved.addListener(
+ listener.tabsOnRemoved.bind(listener)
+ );
+ browser.windows.onCreated.addListener(
+ listener.windowsOnCreated.bind(listener)
+ );
+ browser.windows.onRemoved.addListener(
+ listener.windowsOnRemoved.bind(listener)
+ );
+
+ browser.test.log(
+ "Collect the ID of the initial window (there must be only one) and tab."
+ );
+
+ let initialWindows = await browser.windows.getAll({ populate: true });
+ browser.test.assertEq(1, initialWindows.length);
+ let [{ id: initialWindow, tabs: initialTabs }] = initialWindows;
+ browser.test.assertEq(1, initialTabs.length);
+ browser.test.assertEq(0, initialTabs[0].index);
+ browser.test.assertTrue(initialTabs[0].mailTab);
+ let [{ id: initialTab }] = initialTabs;
+
+ browser.test.log("Open a new main window (messenger.xhtml).");
+
+ let primedMainWindowInfo = await window.sendMessage("openMainWindow");
+ let [{ id: mainWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ { type: "normal" }
+ );
+ let [{ id: mainTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: mainWindow,
+ active: true,
+ mailTab: true,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: mainWindow,
+ type: "normal",
+ },
+ ],
+ primedMainWindowInfo
+ );
+
+ browser.test.log("Open a compose window (messengercompose.xhtml).");
+
+ let primedComposeWindowInfo = await capturePrimedEvent(
+ "onCreated",
+ () => browser.compose.beginNew()
+ );
+ let [{ id: composeWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ {
+ type: "messageCompose",
+ }
+ );
+ let [{ id: composeTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: composeWindow,
+ active: true,
+ mailTab: false,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: composeWindow,
+ type: "messageCompose",
+ },
+ ],
+ primedComposeWindowInfo
+ );
+
+ browser.test.log("Open a message in a window (messageWindow.xhtml).");
+
+ let primedDisplayWindowInfo = await window.sendMessage(
+ "openDisplayWindow"
+ );
+ let [{ id: displayWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ {
+ type: "messageDisplay",
+ }
+ );
+ let [{ id: displayTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: displayWindow,
+ active: true,
+ mailTab: false,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: displayWindow,
+ type: "messageDisplay",
+ },
+ ],
+ primedDisplayWindowInfo
+ );
+
+ browser.test.log("Open a page in a popup window.");
+
+ let primedPopupWindowInfo = await capturePrimedEvent("onCreated", () =>
+ browser.windows.create({
+ url: "test.html",
+ type: "popup",
+ width: 800,
+ height: 500,
+ })
+ );
+ let [{ id: popupWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ {
+ type: "popup",
+ width: 800,
+ height: 500,
+ }
+ );
+ let [{ id: popupTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: popupWindow,
+ active: true,
+ mailTab: false,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: popupWindow,
+ type: "popup",
+ width: 800,
+ height: 500,
+ },
+ ],
+ primedPopupWindowInfo
+ );
+
+ browser.test.log("Pause to let windows load properly.");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2500));
+
+ browser.test.log("Change focused window.");
+
+ let focusInfoPromise = new Promise(resolve => {
+ let listener = windowId => {
+ browser.windows.onFocusChanged.removeListener(listener);
+ resolve(windowId);
+ };
+ browser.windows.onFocusChanged.addListener(listener);
+ });
+ let [primedFocusInfo] = await capturePrimedEvent("onFocusChanged", () =>
+ browser.windows.update(composeWindow, { focused: true })
+ );
+ let focusInfo = await focusInfoPromise;
+ let platformInfo = await browser.runtime.getPlatformInfo();
+
+ let expectedWindow = ["mac", "win"].includes(platformInfo.os)
+ ? composeWindow
+ : browser.windows.WINDOW_ID_NONE;
+ window.assertDeepEqual(expectedWindow, primedFocusInfo);
+ window.assertDeepEqual(expectedWindow, focusInfo);
+
+ browser.test.log("Close the new main window.");
+
+ let primedMainWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(mainWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", mainWindow);
+ await listener.checkEvent("tabs.onRemoved", mainTab, {
+ windowId: mainWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([mainWindow], primedMainWindowRemoveInfo);
+
+ browser.test.log("Close the compose window.");
+
+ let primedComposWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(composeWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", composeWindow);
+ await listener.checkEvent("tabs.onRemoved", composeTab, {
+ windowId: composeWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([composeWindow], primedComposWindowRemoveInfo);
+
+ browser.test.log("Close the message window.");
+
+ let primedDisplayWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(displayWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", displayWindow);
+ await listener.checkEvent("tabs.onRemoved", displayTab, {
+ windowId: displayWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([displayWindow], primedDisplayWindowRemoveInfo);
+
+ browser.test.log("Close the popup window.");
+
+ let primedPopupWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(popupWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", popupWindow);
+ await listener.checkEvent("tabs.onRemoved", popupTab, {
+ windowId: popupWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([popupWindow], primedPopupWindowRemoveInfo);
+
+ let finalWindows = await browser.windows.getAll({ populate: true });
+ browser.test.assertEq(1, finalWindows.length);
+ browser.test.assertEq(initialWindow, finalWindows[0].id);
+ browser.test.assertEq(1, finalWindows[0].tabs.length);
+ browser.test.assertEq(initialTab, finalWindows[0].tabs[0].id);
+
+ browser.test.assertEq(0, listener.tabEvents.length);
+ browser.test.assertEq(0, listener.windowEvents.length);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ // Function to start an event page extension (MV3), which can be called whenever
+ // the main test is about to trigger an event. The extension terminates its
+ // background and listens for that single event, verifying it is waking up correctly.
+ async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let eventName = browser.runtime.getManifest().description;
+
+ if (
+ ["onCreated", "onFocusChanged", "onRemoved"].includes(eventName)
+ ) {
+ browser.windows[eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: `windows.eventpage.${eventName}@mochi.test` },
+ },
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "windows", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "windows", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "windows", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+ }
+
+ extension.onMessage("openMainWindow", async () => {
+ let primedEventData = await event_page_extension("onCreated", () => {
+ return window.MsgOpenNewWindowForFolder(testFolder.URI);
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("openDisplayWindow", async () => {
+ let primedEventData = await event_page_extension("onCreated", () => {
+ return openMessageInWindow([...testFolder.messages][0]);
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution of the main test, after the event page extension has
+ // primed its event listeners.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_types.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_types.js
new file mode 100644
index 0000000000..af9ad35f8a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_types.js
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let files = {
+ "background.js": async () => {
+ // Message compose window.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ let windowDetail = await browser.windows.get(createdWindow.id, {
+ populate: true,
+ });
+ browser.test.assertEq("messageCompose", windowDetail.type);
+ browser.test.assertEq(1, windowDetail.tabs.length);
+ browser.test.assertEq("messageCompose", windowDetail.tabs[0].type);
+ // These three properties should not be present, but not fail either.
+ browser.test.assertEq(undefined, windowDetail.tabs[0].favIconUrl);
+ browser.test.assertEq(undefined, windowDetail.tabs[0].title);
+ browser.test.assertEq(undefined, windowDetail.tabs[0].url);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ await browser.tabs.remove(windowDetail.tabs[0].id);
+ await removedWindowPromise;
+
+ // Message display window.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ browser.test.sendMessage("openMessage");
+ [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageDisplay", createdWindow.type);
+
+ windowDetail = await browser.windows.get(createdWindow.id, {
+ populate: true,
+ });
+ browser.test.assertEq("messageDisplay", windowDetail.type);
+ browser.test.assertEq(1, windowDetail.tabs.length);
+ browser.test.assertEq("messageDisplay", windowDetail.tabs[0].type);
+ browser.test.assertEq("about:blank", windowDetail.tabs[0].url);
+ // These properties should not be present, but not fail either.
+ browser.test.assertEq(undefined, windowDetail.tabs[0].favIconUrl);
+ browser.test.assertEq(undefined, windowDetail.tabs[0].title);
+
+ removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.test.sendMessage("closeMessage");
+ await removedWindowPromise;
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks", "tabs"],
+ },
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 1);
+
+ await extension.startup();
+
+ await extension.awaitMessage("openMessage");
+ let newWindow = await openMessageInWindow([...subFolders.test1.messages][0]);
+
+ await extension.awaitMessage("closeMessage");
+ newWindow.close();
+
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_tabs_of_second_tabmail() {
+ let files = {
+ "background.js": async () => {
+ let testWindow = await browser.windows.create({ type: "normal" });
+ browser.test.assertEq("normal", testWindow.type);
+
+ let tabs = await await browser.tabs.query({ windowId: testWindow.id });
+ browser.test.assertEq(1, tabs.length);
+ browser.test.assertEq("mail", tabs[0].type);
+
+ await browser.windows.remove(testWindow.id);
+
+ browser.test.notifyPass();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["background.js"] },
+ },
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 1);
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/data/cloudFile1.txt b/comm/mail/components/extensions/test/browser/data/cloudFile1.txt
new file mode 100644
index 0000000000..42c5dbfae0
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/cloudFile1.txt
@@ -0,0 +1 @@
+you got the moves!
diff --git a/comm/mail/components/extensions/test/browser/data/cloudFile2.txt b/comm/mail/components/extensions/test/browser/data/cloudFile2.txt
new file mode 100644
index 0000000000..42c5dbfae0
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/cloudFile2.txt
@@ -0,0 +1 @@
+you got the moves!
diff --git a/comm/mail/components/extensions/test/browser/data/content.html b/comm/mail/components/extensions/test/browser/data/content.html
new file mode 100644
index 0000000000..6a56ee6a5a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/content.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>A test document</title>
+</head>
+<body>
+ <p id="description">This is text.</p>
+ <p><a href="http://mochi.test:8888/">This is a link with text.</a></p>
+ <p><img src="http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/tb-logo.png" width="304" height="84" /></p>
+</body>
+</html>
diff --git a/comm/mail/components/extensions/test/browser/data/content_body.html b/comm/mail/components/extensions/test/browser/data/content_body.html
new file mode 100644
index 0000000000..7652f2d84d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/content_body.html
@@ -0,0 +1 @@
+<p>This is text.</p><p><a href="http://mochi.test:8888/">This is a link with text.</a></p><p><img src="http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/tb-logo.png" width="304" height="84" /></p>
diff --git a/comm/mail/components/extensions/test/browser/data/linktest.html b/comm/mail/components/extensions/test/browser/data/linktest.html
new file mode 100644
index 0000000000..f8b49156d8
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/linktest.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>A test document</title>
+</head>
+<body>
+ <p><a id="linkExt1" href="https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html">self</a></p>
+ <p><a id="linkExt2" href="https://mozilla.org/">other</a></p>
+</body>
+</html>
diff --git a/comm/mail/components/extensions/test/browser/data/tb-logo.png b/comm/mail/components/extensions/test/browser/data/tb-logo.png
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/tb-logo.png
Binary files differ
diff --git a/comm/mail/components/extensions/test/browser/head.js b/comm/mail/components/extensions/test/browser/head.js
new file mode 100644
index 0000000000..ed25bde87f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/head.js
@@ -0,0 +1,1533 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+const { storeState, getState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { getDefaultItemIdsForSpace, getAvailableItemIdsForSpace } =
+ ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+// Persistent Listener test functionality
+var { assertPersistentListeners } = ExtensionTestUtils.testAssertions;
+
+// There are shutdown issues for which multiple rejections are left uncaught.
+// This bug should be fixed, but for the moment this directory is whitelisted.
+//
+// NOTE: Entire directory whitelisting should be kept to a minimum. Normally you
+// should use "expectUncaughtRejection" to flag individual failures.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(/No matching message handler/);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Receiving end does not exist/
+);
+
+// Adjust timeout to take care of code coverage runs and fission runs to be a
+// lot slower.
+let originalRequestLongerTimeout = requestLongerTimeout;
+// eslint-disable-next-line no-global-assign
+requestLongerTimeout = factor => {
+ let ccovMultiplier = AppConstants.MOZ_CODE_COVERAGE ? 2 : 1;
+ let fissionMultiplier = SpecialPowers.useRemoteSubframes ? 2 : 1;
+ originalRequestLongerTimeout(ccovMultiplier * fissionMultiplier * factor);
+};
+requestLongerTimeout(1);
+
+add_setup(async () => {
+ await check3PaneState(true, true);
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.tabInfo.length > 1) {
+ info(`Will close ${tabmail.tabInfo.length - 1} tabs left over from others`);
+ for (let i = tabmail.tabInfo.length - 1; i > 0; i--) {
+ tabmail.closeTab(i);
+ }
+ is(tabmail.tabInfo.length, 1, "One tab open from start");
+ }
+});
+registerCleanupFunction(() => {
+ let tabmail = document.getElementById("tabmail");
+ is(tabmail.tabInfo.length, 1, "Only one tab open at end of test");
+
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(tabmail.tabInfo[1]);
+ }
+
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ // Focus an element in the main window, then blur it again to avoid it
+ // hijacking keypresses.
+ let mainWindowElement = document.getElementById("button-appmenu");
+ mainWindowElement.focus();
+ mainWindowElement.blur();
+
+ MailServices.accounts.accounts.forEach(cleanUpAccount);
+ check3PaneState(true, true);
+
+ // The unified toolbar must have been cleaned up. If this fails, check if a
+ // test loaded an extension with a browser_action without setting "useAddonManager"
+ // to either "temporary" or "permanent", which triggers onUninstalled to be
+ // called on extension unload.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ is(
+ cachedAllowedSpaces.size,
+ 0,
+ `Stored known extension spaces should be cleared: ${JSON.stringify(
+ Object.fromEntries(cachedAllowedSpaces)
+ )}`
+ );
+ setCachedAllowedSpaces(new Map());
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ );
+});
+
+/**
+ * Enforce a certain state in the unified toolbar.
+ * @param {Object} state - A dictionary with arrays of buttons assigned to a space
+ */
+async function enforceState(state) {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState(state);
+ await stateChangeObserved;
+}
+
+async function check3PaneState(folderPaneOpen = null, messagePaneOpen = null) {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail.currentTabInfo;
+ if (tab.chromeBrowser.contentDocument.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(
+ tab.chromeBrowser.contentWindow,
+ "load"
+ );
+ }
+
+ let { paneLayout } = tabmail.currentAbout3Pane;
+ if (folderPaneOpen !== null) {
+ Assert.equal(
+ paneLayout.folderPaneVisible,
+ folderPaneOpen,
+ "State of folder pane splitter is correct"
+ );
+ paneLayout.folderPaneVisible = folderPaneOpen;
+ }
+
+ if (messagePaneOpen !== null) {
+ Assert.equal(
+ paneLayout.messagePaneVisible,
+ messagePaneOpen,
+ "State of message pane splitter is correct"
+ );
+ paneLayout.messagePaneVisible = messagePaneOpen;
+ }
+}
+
+function createAccount(type = "none") {
+ let account;
+
+ if (type == "local") {
+ MailServices.accounts.createLocalMailAccount();
+ account = MailServices.accounts.FindAccountForServer(
+ MailServices.accounts.localFoldersServer
+ );
+ } else {
+ account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ type
+ );
+ }
+
+ info(`Created account ${account.toString()}`);
+ return account;
+}
+
+function cleanUpAccount(account) {
+ // If the current displayed message/folder belongs to the account to be removed,
+ // select the root folder, otherwise the removal of this account will trigger
+ // a "shouldn't have any listeners left" assertion in nsMsgDatabase.cpp.
+ let [folder] = window.GetSelectedMsgFolders();
+ if (folder && folder.server && folder.server == account.incomingServer) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.currentAbout3Pane.displayFolder(folder.server.rootFolder.URI);
+ }
+
+ let serverKey = account.incomingServer.key;
+ let serverType = account.incomingServer.type;
+ info(
+ `Cleaning up ${serverType} account ${account.key} and server ${serverKey}`
+ );
+ MailServices.accounts.removeAccount(account, true);
+
+ try {
+ let server = MailServices.accounts.getIncomingServer(serverKey);
+ if (server) {
+ info(`Cleaning up leftover ${serverType} server ${serverKey}`);
+ MailServices.accounts.removeIncomingServer(server, false);
+ }
+ } catch (e) {}
+}
+
+function addIdentity(account, email = "mochitest@localhost") {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = email;
+ account.addIdentity(identity);
+ if (!account.defaultIdentity) {
+ account.defaultIdentity = identity;
+ }
+ info(`Created identity ${identity.toString()}`);
+ return identity;
+}
+
+async function createSubfolder(parent, name) {
+ parent.createSubfolder(name, null);
+ return parent.getChildNamed(name);
+}
+
+function createMessages(folder, makeMessagesArg) {
+ if (typeof makeMessagesArg == "number") {
+ makeMessagesArg = { count: makeMessagesArg };
+ }
+ if (!createMessages.messageGenerator) {
+ createMessages.messageGenerator = new MessageGenerator();
+ }
+
+ let messages = createMessages.messageGenerator.makeMessages(makeMessagesArg);
+ let messageStrings = messages.map(message => message.toMboxString());
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessageBatch(messageStrings);
+}
+
+async function createMessageFromFile(folder, path) {
+ let message = await IOUtils.readUTF8(path);
+
+ // A cheap hack to make this acceptable to addMessageBatch. It works for
+ // existing uses but may not work for future uses.
+ let fromAddress = message.match(/From: .* <(.*@.*)>/)[0];
+ message = `From ${fromAddress}\r\n${message}`;
+
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessageBatch([message]);
+ folder.callFilterPlugins(null);
+}
+
+async function promiseAnimationFrame(win = window) {
+ await new Promise(win.requestAnimationFrame);
+ // dispatchToMainThread throws if used as the first argument of Promise.
+ return new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+}
+
+async function focusWindow(win) {
+ if (Services.focus.activeWindow == win) {
+ return;
+ }
+
+ let promise = new Promise(resolve => {
+ win.addEventListener(
+ "focus",
+ function () {
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+
+ win.focus();
+ await promise;
+}
+
+function promisePopupShown(popup) {
+ return new Promise(resolve => {
+ if (popup.state == "open") {
+ resolve();
+ } else {
+ let onPopupShown = event => {
+ popup.removeEventListener("popupshown", onPopupShown);
+ resolve();
+ };
+ popup.addEventListener("popupshown", onPopupShown);
+ }
+ });
+}
+
+function getPanelForNode(node) {
+ while (node.localName != "panel") {
+ node = node.parentNode;
+ }
+ return node;
+}
+
+/**
+ * Wait until the browser is fully loaded.
+ *
+ * @param {xul:browser} browser - A xul:browser.
+ * @param {string|function} [wantLoad = null] - If a function, takes a URL and
+ * returns true if that's the load we're interested in. If a string, gives the
+ * URL of the load we're interested in. If not present, the first load resolves
+ * the promise.
+ *
+ * @returns {Promise} When a load event is triggered for the browser or the browser
+ * is already fully loaded.
+ */
+function awaitBrowserLoaded(browser, wantLoad) {
+ let testFn = () => true;
+ if (wantLoad) {
+ testFn = typeof wantLoad === "function" ? wantLoad : url => url == wantLoad;
+ }
+
+ return TestUtils.waitForCondition(
+ () =>
+ browser.ownerGlobal.document.readyState === "complete" &&
+ (browser.webProgress?.isLoadingDocument === false ||
+ browser.contentDocument?.readyState === "complete") &&
+ browser.currentURI &&
+ testFn(browser.currentURI.spec),
+ "Browser should be loaded"
+ );
+}
+
+var awaitExtensionPanel = async function (
+ extension,
+ win = window,
+ awaitLoad = true
+) {
+ let { originalTarget: browser } = await BrowserTestUtils.waitForEvent(
+ win.document,
+ "WebExtPopupLoaded",
+ true,
+ event => event.detail.extension.id === extension.id
+ );
+
+ if (awaitLoad) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+ }
+ await promisePopupShown(getPanelForNode(browser));
+
+ return browser;
+};
+
+function getBrowserActionPopup(extension, win = window) {
+ return win.top.document.getElementById("webextension-remote-preload-panel");
+}
+
+function closeBrowserAction(extension, win = window) {
+ let popup = getBrowserActionPopup(extension, win);
+ let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.hidePopup();
+
+ return hidden;
+}
+
+async function openNewMailWindow(options = {}) {
+ if (!options.newAccountWizard) {
+ Services.prefs.setBoolPref(
+ "mail.provider.suppress_dialog_on_startup",
+ true
+ );
+ }
+
+ let win = window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,all,dialog=no"
+ );
+ await Promise.all([
+ BrowserTestUtils.waitForEvent(win, "focus", true),
+ BrowserTestUtils.waitForEvent(win, "activate", true),
+ ]);
+
+ return win;
+}
+
+async function openComposeWindow(account) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.identity = account.defaultIdentity;
+ params.composeFields = composeFields;
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened(
+ undefined,
+ async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ if (
+ win.document.documentURI !=
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ ) {
+ return false;
+ }
+ await BrowserTestUtils.waitForEvent(win, "compose-editor-ready");
+ return true;
+ }
+ );
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ return composeWindowPromise;
+}
+
+async function openMessageInTab(msgHdr) {
+ if (!msgHdr.QueryInterface(Ci.nsIMsgDBHdr)) {
+ throw new Error("No message passed to openMessageInTab");
+ }
+
+ // Ensure the behaviour pref is set to open a new tab. It is the default,
+ // but you never know.
+ let oldPrefValue = Services.prefs.getIntPref("mail.openMessageBehavior");
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+ MailUtils.displayMessages([msgHdr]);
+ Services.prefs.setIntPref("mail.openMessageBehavior", oldPrefValue);
+
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ let tab = win.document.getElementById("tabmail").currentTabInfo;
+ await BrowserTestUtils.waitForEvent(tab.chromeBrowser, "MsgLoaded");
+ return tab;
+}
+
+async function openMessageInWindow(msgHdr) {
+ if (!msgHdr.QueryInterface(Ci.nsIMsgDBHdr)) {
+ throw new Error("No message passed to openMessageInWindow");
+ }
+
+ let messageWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ async win =>
+ win.document.documentURI ==
+ "chrome://messenger/content/messageWindow.xhtml"
+ );
+ MailUtils.openMessageInNewWindow(msgHdr);
+
+ let messageWindow = await messageWindowPromise;
+ await BrowserTestUtils.waitForEvent(messageWindow, "MsgLoaded");
+ return messageWindow;
+}
+
+async function promiseMessageLoaded(browser, msgHdr) {
+ let messageURI = msgHdr.folder.getUriForMsg(msgHdr);
+ messageURI = MailServices.messageServiceFromURI(messageURI).getUrlForUri(
+ messageURI,
+ null
+ );
+
+ await awaitBrowserLoaded(browser, uri => uri == messageURI.spec);
+}
+
+/**
+ * Check the headers of an open compose window against expected values.
+ *
+ * @param {object} expected - A dictionary of expected headers.
+ * Omit headers that should have no value.
+ * @param {string[]} [fields.to]
+ * @param {string[]} [fields.cc]
+ * @param {string[]} [fields.bcc]
+ * @param {string[]} [fields.replyTo]
+ * @param {string[]} [fields.followupTo]
+ * @param {string[]} [fields.newsgroups]
+ * @param {string} [fields.subject]
+ */
+async function checkComposeHeaders(expected) {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ let composeDocument = composeWindows[0].document;
+ let composeFields = composeWindows[0].gMsgCompose.compFields;
+
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ if ("identityId" in expected) {
+ is(composeWindows[0].getCurrentIdentityKey(), expected.identityId);
+ }
+
+ if (expected.attachVCard) {
+ is(
+ expected.attachVCard,
+ composeFields.attachVCard,
+ "attachVCard in window should be correct"
+ );
+ }
+
+ let checkField = (fieldName, elementId) => {
+ let pills = composeDocument
+ .getElementById(elementId)
+ .getElementsByTagName("mail-address-pill");
+
+ if (fieldName in expected) {
+ is(
+ pills.length,
+ expected[fieldName].length,
+ `${fieldName} has the right number of pills`
+ );
+ for (let i = 0; i < expected[fieldName].length; i++) {
+ is(pills[i].label, expected[fieldName][i]);
+ }
+ } else {
+ is(pills.length, 0, `${fieldName} is empty`);
+ }
+ };
+
+ checkField("to", "addressRowTo");
+ checkField("cc", "addressRowCc");
+ checkField("bcc", "addressRowBcc");
+ checkField("replyTo", "addressRowReply");
+ checkField("followupTo", "addressRowFollowup");
+ checkField("newsgroups", "addressRowNewsgroups");
+
+ let subject = composeDocument.getElementById("msgSubject").value;
+ if ("subject" in expected) {
+ is(subject, expected.subject, "subject is correct");
+ } else {
+ is(subject, "", "subject is empty");
+ }
+
+ if (expected.overrideDefaultFcc) {
+ if (expected.overrideDefaultFccFolder) {
+ let server = MailServices.accounts.getAccount(
+ expected.overrideDefaultFccFolder.accountId
+ ).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ is(
+ rootURI + expected.overrideDefaultFccFolder.path,
+ composeFields.fcc,
+ "fcc should be correct"
+ );
+ } else {
+ ok(
+ composeFields.fcc.startsWith("nocopy://"),
+ "fcc should start with nocopy://"
+ );
+ }
+ } else {
+ is("", composeFields.fcc, "fcc should be empty");
+ }
+
+ if (expected.additionalFccFolder) {
+ let server = MailServices.accounts.getAccount(
+ expected.additionalFccFolder.accountId
+ ).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ is(
+ rootURI + expected.additionalFccFolder.path,
+ composeFields.fcc2,
+ "fcc2 should be correct"
+ );
+ } else {
+ ok(
+ composeFields.fcc2 == "" || composeFields.fcc2.startsWith("nocopy://"),
+ "fcc2 should not contain a folder uri"
+ );
+ }
+
+ if (expected.hasOwnProperty("priority")) {
+ is(
+ composeFields.priority.toLowerCase(),
+ expected.priority == "normal" ? "" : expected.priority,
+ "priority in composeFields should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("returnReceipt")) {
+ is(
+ composeFields.returnReceipt,
+ expected.returnReceipt,
+ "returnReceipt in composeFields should be correct"
+ );
+ for (let item of composeDocument.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"],
+ toolbarbutton[command="cmd_toggleReturnReceipt"]`)) {
+ is(
+ item.getAttribute("checked") == "true",
+ expected.returnReceipt,
+ "returnReceipt in window should be correct"
+ );
+ }
+ }
+
+ if (expected.hasOwnProperty("deliveryStatusNotification")) {
+ is(
+ composeFields.DSN,
+ !!expected.deliveryStatusNotification,
+ "deliveryStatusNotification in composeFields should be correct"
+ );
+ is(
+ composeDocument.getElementById("dsnMenu").getAttribute("checked") ==
+ "true",
+ !!expected.deliveryStatusNotification,
+ "deliveryStatusNotification in window should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("deliveryFormat")) {
+ const deliveryFormats = {
+ auto: Ci.nsIMsgCompSendFormat.Auto,
+ plaintext: Ci.nsIMsgCompSendFormat.PlainText,
+ html: Ci.nsIMsgCompSendFormat.HTML,
+ both: Ci.nsIMsgCompSendFormat.Both,
+ };
+ const formatToId = new Map([
+ [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"],
+ [Ci.nsIMsgCompSendFormat.HTML, "format_html"],
+ [Ci.nsIMsgCompSendFormat.Both, "format_both"],
+ [Ci.nsIMsgCompSendFormat.Auto, "format_auto"],
+ ]);
+ let expectedFormat = deliveryFormats[expected.deliveryFormat || "auto"];
+ is(
+ expectedFormat,
+ composeFields.deliveryFormat,
+ "deliveryFormat in composeFields should be correct"
+ );
+ for (let [format, id] of formatToId.entries()) {
+ let menuitem = composeDocument.getElementById(id);
+ is(
+ format == expectedFormat,
+ menuitem.getAttribute("checked") == "true",
+ "checked state of the deliveryFormat menu item <${id}> in window should be correct"
+ );
+ }
+ }
+}
+
+async function synthesizeMouseAtCenterAndRetry(selector, event, browser) {
+ let success = false;
+ let type = event.type || "click";
+ for (let retries = 0; !success && retries < 2; retries++) {
+ let clickPromise = BrowserTestUtils.waitForContentEvent(browser, type).then(
+ () => true
+ );
+ // Linux: Sometimes the actor used to simulate the mouse event in the content process does not
+ // react, even though the content page signals to be fully loaded. There is no status signal
+ // we could wait for, the loaded page *should* be ready at this point. To mitigate, we wait
+ // for the click event and if we do not see it within a certain time, we click again.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ let failPromise = new Promise(r =>
+ browser.ownerGlobal.setTimeout(r, 500)
+ ).then(() => false);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(selector, event, browser);
+ success = await Promise.race([clickPromise, failPromise]);
+ }
+ Assert.ok(success, `Should have received ${type} event.`);
+}
+
+async function openContextMenu(selector = "#img1", win = window) {
+ let contentAreaContextMenu = win.document.getElementById("browserContext");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ let tabmail = document.getElementById("tabmail");
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "mousedown", button: 2 },
+ tabmail.selectedBrowser
+ );
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "contextmenu" },
+ tabmail.selectedBrowser
+ );
+ await popupShownPromise;
+ return contentAreaContextMenu;
+}
+
+async function openContextMenuInPopup(extension, selector, win = window) {
+ let contentAreaContextMenu =
+ win.top.document.getElementById("browserContext");
+ let stack = getBrowserActionPopup(extension, win);
+ let browser = stack.querySelector("browser");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "mousedown", button: 2 },
+ browser
+ );
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "contextmenu" },
+ browser
+ );
+ await popupShownPromise;
+ return contentAreaContextMenu;
+}
+
+async function closeExtensionContextMenu(
+ itemToSelect,
+ modifiers = {},
+ win = window
+) {
+ let contentAreaContextMenu =
+ win.top.document.getElementById("browserContext");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ if (itemToSelect) {
+ itemToSelect.closest("menupopup").activateItem(itemToSelect, modifiers);
+ } else {
+ contentAreaContextMenu.hidePopup();
+ }
+ await popupHiddenPromise;
+
+ // Bug 1351638: parent menu fails to close intermittently, make sure it does.
+ contentAreaContextMenu.hidePopup();
+}
+
+async function openSubmenu(submenuItem, win = window) {
+ const submenu = submenuItem.menupopup;
+ const shown = BrowserTestUtils.waitForEvent(submenu, "popupshown");
+ submenuItem.openMenu(true);
+ await shown;
+ return submenu;
+}
+
+async function closeContextMenu(contextMenu) {
+ let contentAreaContextMenu =
+ contextMenu || document.getElementById("browserContext");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ contentAreaContextMenu.hidePopup();
+ await popupHiddenPromise;
+}
+
+async function getUtilsJS() {
+ let response = await fetch(getRootDirectory(gTestPath) + "utils.js");
+ return response.text();
+}
+
+async function checkContent(browser, expected) {
+ await SpecialPowers.spawn(browser, [expected], expected => {
+ let body = content.document.body;
+ Assert.ok(body, "body");
+ let computedStyle = content.getComputedStyle(body);
+
+ if ("backgroundColor" in expected) {
+ Assert.equal(
+ computedStyle.backgroundColor,
+ expected.backgroundColor,
+ "backgroundColor"
+ );
+ }
+ if ("color" in expected) {
+ Assert.equal(computedStyle.color, expected.color, "color");
+ }
+ if ("foo" in expected) {
+ Assert.equal(body.getAttribute("foo"), expected.foo, "foo");
+ }
+ if ("textContent" in expected) {
+ // In message display, we only really want the message body, but the
+ // document body also has headers. For the purposes of these tests,
+ // we can just select an descendant node, since what really matters is
+ // whether (or not) a script ran, not the exact result.
+ body = body.querySelector(".moz-text-flowed") ?? body;
+ Assert.equal(body.textContent, expected.textContent, "textContent");
+ }
+ });
+}
+
+function contentTabOpenPromise(tabmail, url) {
+ return new Promise(resolve => {
+ let tabMonitor = {
+ onTabTitleChanged(aTab) {},
+ onTabClosing(aTab) {},
+ onTabPersist(aTab) {},
+ onTabRestored(aTab) {},
+ onTabSwitched(aNewTab, aOldTab) {},
+ async onTabOpened(aTab) {
+ let result = awaitBrowserLoaded(
+ aTab.linkedBrowser,
+ urlToMatch => urlToMatch == url
+ ).then(() => aTab);
+
+ let reporterListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ onStateChange() {},
+ onProgressChange() {},
+ onLocationChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in nsIURI*/ aLocation
+ ) {
+ if (aLocation.spec == url) {
+ aTab.browser.removeProgressListener(reporterListener);
+ tabmail.unregisterTabMonitor(tabMonitor);
+ TestUtils.executeSoon(() => resolve(result));
+ }
+ },
+ onStatusChange() {},
+ onSecurityChange() {},
+ onContentBlockingEvent() {},
+ };
+ aTab.browser.addProgressListener(reporterListener);
+ },
+ };
+ tabmail.registerTabMonitor(tabMonitor);
+ });
+}
+
+/**
+ * @typedef ConfigData
+ * @property {string} actionType - type of action button in underscore notation
+ * @property {string} window - the window to perform the test in
+ * @property {string} [testType] - supported tests are "open-with-mouse-click" and
+ * "open-with-menu-command"
+ * @property {string} [default_area] - area to be used for the test
+ * @property {boolean} [use_default_popup] - select if the default_popup should be
+ * used for the test
+ * @property {boolean} [disable_button] - select if the button should be disabled
+ * @property {Function} [backend_script] - custom backend script to be used for the
+ * test, will override the default backend_script of the selected test
+ * @property {Function} [background_script] - custom background script to be used for the
+ * test, will override the default background_script of the selected test
+ * @property {[string]} [permissions] - custom permissions to be used for the test,
+ * must not be specified together with testType
+ */
+
+/**
+ * Creates an extension with an action button and either runs one of the default
+ * tests, or loads a custom background script and a custom backend scripts to run
+ * an arbitrary test.
+ *
+ * @param {ConfigData} configData - test configuration
+ */
+async function run_popup_test(configData) {
+ if (!configData.actionType) {
+ throw new Error("Mandatory configData.actionType is missing");
+ }
+ if (!configData.window) {
+ throw new Error("Mandatory configData.window is missing");
+ }
+
+ // Get camelCase API names from action type.
+ configData.apiName = configData.actionType.replace(/_([a-z])/g, function (g) {
+ return g[1].toUpperCase();
+ });
+ configData.moduleName =
+ configData.actionType == "action" ? "browserAction" : configData.apiName;
+
+ let backend_script = configData.backend_script;
+
+ let extensionDetails = {
+ files: {
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 1000));
+ await browser.runtime.sendMessage("popup opened");
+ await new Promise(resolve => window.setTimeout(resolve));
+ window.close();
+ },
+ "utils.js": await getUtilsJS(),
+ "helper.js": function () {
+ window.actionType = browser.runtime.getManifest().description;
+ // Get camelCase API names from action type.
+ window.apiName = window.actionType.replace(/_([a-z])/g, function (g) {
+ return g[1].toUpperCase();
+ });
+ window.getPopupOpenedPromise = function () {
+ return new Promise(resolve => {
+ const handleMessage = async (message, sender, sendResponse) => {
+ if (message && message == "popup opened") {
+ sendResponse();
+ window.setTimeout(resolve);
+ browser.runtime.onMessage.removeListener(handleMessage);
+ }
+ };
+ browser.runtime.onMessage.addListener(handleMessage);
+ });
+ };
+ },
+ },
+ manifest: {
+ manifest_version: configData.manifest_version || 2,
+ browser_specific_settings: {
+ gecko: {
+ id: `${configData.actionType}@mochi.test`,
+ },
+ },
+ description: configData.actionType,
+ background: { scripts: ["utils.js", "helper.js", "background.js"] },
+ },
+ useAddonManager: "temporary",
+ };
+
+ switch (configData.testType) {
+ case "open-with-mouse-click":
+ backend_script = async function (extension, configData) {
+ let win = configData.window;
+
+ await extension.startup();
+ await promiseAnimationFrame(win);
+ await new Promise(resolve => win.setTimeout(resolve));
+ await extension.awaitMessage("ready");
+
+ let buttonId = `${configData.actionType}_mochi_test-${configData.moduleName}-toolbarbutton`;
+ let toolbarId;
+ switch (configData.actionType) {
+ case "compose_action":
+ toolbarId = "composeToolbar2";
+ if (configData.default_area == "formattoolbar") {
+ toolbarId = "FormatToolbar";
+ }
+ break;
+ case "action":
+ case "browser_action":
+ if (configData.default_windows?.join(",") === "messageDisplay") {
+ toolbarId = "mail-bar3";
+ } else {
+ toolbarId = "unified-toolbar";
+ }
+ break;
+ case "message_display_action":
+ toolbarId = "header-view-toolbar";
+ break;
+ default:
+ throw new Error(
+ `Unsupported configData.actionType: ${configData.actionType}`
+ );
+ }
+
+ let toolbar, button;
+ if (toolbarId === "unified-toolbar") {
+ toolbar = win.document.querySelector("unified-toolbar");
+ button = win.document.querySelector(
+ `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
+ );
+ } else {
+ toolbar = win.document.getElementById(toolbarId);
+ button = win.document.getElementById(buttonId);
+ }
+ ok(button, "Button created");
+ ok(toolbar.contains(button), "Button added to toolbar");
+ let label;
+ if (toolbarId === "unified-toolbar") {
+ const state = getState();
+ const itemId = `ext-${configData.actionType}@mochi.test`;
+ if (state.mail) {
+ ok(
+ state.mail.includes(itemId),
+ "Button should be in unified toolbar mail space"
+ );
+ }
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Button should be in default set for unified toolbar mail space"
+ );
+ ok(
+ getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Button should be available in unified toolbar mail space"
+ );
+
+ let icon = button.querySelector(".button-icon");
+ is(
+ getComputedStyle(icon).content,
+ `url("chrome://messenger/content/extension.svg")`,
+ "Default icon"
+ );
+ label = button.querySelector(".button-label");
+ is(label.textContent, "This is a test", "Correct label");
+ } else {
+ if (toolbar.hasAttribute("customizable")) {
+ ok(
+ toolbar.currentSet.split(",").includes(buttonId),
+ `Button should have been added to currentSet property of toolbar ${toolbarId}`
+ );
+ ok(
+ toolbar.getAttribute("currentset").split(",").includes(buttonId),
+ `Button should have been added to currentset attribute of toolbar ${toolbarId}`
+ );
+ }
+ ok(
+ Services.xulStore
+ .getValue(win.location.href, toolbarId, "currentset")
+ .split(",")
+ .includes(buttonId),
+ `Button should have been added to currentset xulStore of toolbar ${toolbarId}`
+ );
+
+ let icon = button.querySelector(".toolbarbutton-icon");
+ is(
+ getComputedStyle(icon).listStyleImage,
+ `url("chrome://messenger/content/extension.svg")`,
+ "Default icon"
+ );
+ label = button.querySelector(".toolbarbutton-text");
+ is(label.value, "This is a test", "Correct label");
+ }
+
+ if (
+ !configData.use_default_popup &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: false,
+ }
+ );
+ }
+ if (configData.terminateBackground) {
+ await extension.terminateBackground({
+ disableResetIdleForTest: true,
+ });
+ if (
+ !configData.use_default_popup &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: true,
+ }
+ );
+ }
+ }
+
+ let clickedPromise;
+ if (!configData.disable_button) {
+ clickedPromise = extension.awaitMessage("actionButtonClicked");
+ }
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, win);
+ if (configData.disable_button) {
+ // We're testing that nothing happens. Give it time to potentially happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => win.setTimeout(resolve, 500));
+ // In case the background was terminated, it should not restart.
+ // If it does, we will get an extra "ready" message and fail.
+ // Listeners should still be primed.
+ if (
+ configData.terminateBackground &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: true,
+ }
+ );
+ }
+ } else {
+ let hasFiredBefore = await clickedPromise;
+ await promiseAnimationFrame(win);
+ await new Promise(resolve => win.setTimeout(resolve));
+ if (toolbarId === "unified-toolbar") {
+ is(
+ win.document.querySelector(
+ `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
+ ),
+ button
+ );
+ label = button.querySelector(".button-label");
+ is(label.textContent, "New title", "Correct label");
+ } else {
+ is(win.document.getElementById(buttonId), button);
+ label = button.querySelector(".toolbarbutton-text");
+ is(label.value, "New title", "Correct label");
+ }
+
+ if (configData.terminateBackground) {
+ // The onClicked event should have restarted the background script.
+ await extension.awaitMessage("ready");
+ // Could be undefined, but it must not be true
+ is(false, !!hasFiredBefore);
+ }
+ if (
+ !configData.use_default_popup &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: false,
+ }
+ );
+ }
+ }
+
+ // Check the open state of the action button.
+ await TestUtils.waitForCondition(
+ () => button.getAttribute("open") != "true",
+ "Button should not have open state after the popup closed."
+ );
+
+ await extension.unload();
+ await promiseAnimationFrame(win);
+ await new Promise(resolve => win.setTimeout(resolve));
+
+ ok(!win.document.getElementById(buttonId), "Button destroyed");
+
+ if (toolbarId === "unified-toolbar") {
+ const state = getState();
+ const itemId = `ext-${configData.actionType}@mochi.test`;
+ if (state.mail) {
+ ok(
+ !state.mail.includes(itemId),
+ "Button should have been removed from unified toolbar mail space"
+ );
+ }
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Button should have been removed from default set for unified toolbar mail space"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Button should have no longer be available in unified toolbar mail space"
+ );
+ } else {
+ ok(
+ !Services.xulStore
+ .getValue(win.top.location.href, toolbarId, "currentset")
+ .split(",")
+ .includes(buttonId),
+ `Button should have been removed from currentset xulStore of toolbar ${toolbarId}`
+ );
+ }
+ };
+ if (configData.use_default_popup) {
+ // With popup.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("popup background script ran");
+ let popupPromise = window.getPopupOpenedPromise();
+ browser.test.sendMessage("ready");
+ await popupPromise;
+ await browser[window.apiName].setTitle({ title: "New title" });
+ browser.test.sendMessage("actionButtonClicked");
+ };
+ } else if (configData.disable_button) {
+ // Without popup and disabled button.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("nopopup & button disabled background script ran");
+ browser[window.apiName].onClicked.addListener(async (tab, info) => {
+ browser.test.fail(
+ "Should not have seen the onClicked event for a disabled button"
+ );
+ });
+ browser[window.apiName].disable();
+ browser.test.sendMessage("ready");
+ };
+ } else {
+ // Without popup.
+ extensionDetails.files["background.js"] = async function () {
+ let hasFiredBefore = false;
+ browser.test.log("nopopup background script ran");
+ browser[window.apiName].onClicked.addListener(async (tab, info) => {
+ browser.test.assertEq("object", typeof tab);
+ browser.test.assertEq("object", typeof info);
+ browser.test.assertEq(0, info.button);
+ browser.test.assertTrue(Array.isArray(info.modifiers));
+ browser.test.assertEq(0, info.modifiers.length);
+ let [currentTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ currentTab.id,
+ tab.id,
+ "Should find the correct tab"
+ );
+ await browser[window.apiName].setTitle({ title: "New title" });
+ await new Promise(resolve => window.setTimeout(resolve));
+ browser.test.sendMessage("actionButtonClicked", hasFiredBefore);
+ hasFiredBefore = true;
+ });
+ browser.test.sendMessage("ready");
+ };
+ }
+ break;
+
+ case "open-with-menu-command":
+ extensionDetails.manifest.permissions = ["menus"];
+ backend_script = async function (extension, configData) {
+ let win = configData.window;
+ let buttonId = `${configData.actionType}_mochi_test-${configData.moduleName}-toolbarbutton`;
+ let menuId = "toolbar-context-menu";
+ let isUnifiedToolbar = false;
+ if (
+ configData.actionType == "compose_action" &&
+ configData.default_area == "formattoolbar"
+ ) {
+ menuId = "format-toolbar-context-menu";
+ }
+ if (configData.actionType == "message_display_action") {
+ menuId = "header-toolbar-context-menu";
+ }
+ if (
+ (configData.actionType == "browser_action" ||
+ configData.actionType == "action") &&
+ configData.default_windows?.join(",") !== "messageDisplay"
+ ) {
+ menuId = "unifiedToolbarMenu";
+ isUnifiedToolbar = true;
+ }
+ const getButton = windowContent => {
+ if (isUnifiedToolbar) {
+ return windowContent.document.querySelector(
+ `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
+ );
+ }
+ return windowContent.document.getElementById(buttonId);
+ };
+
+ extension.onMessage("triggerClick", async () => {
+ let button = getButton(win);
+ let menu = win.document.getElementById(menuId);
+ let onShownPromise = extension.awaitMessage("onShown");
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { type: "contextmenu" },
+ win
+ );
+ await shownPromise;
+ await onShownPromise;
+ await new Promise(resolve => win.setTimeout(resolve));
+
+ let menuitem = win.document.getElementById(
+ `${configData.actionType}_mochi_test-menuitem-_testmenu`
+ );
+ Assert.ok(menuitem);
+ menuitem.parentNode.activateItem(menuitem);
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => win.setTimeout(r, 250));
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+
+ // Check the open state of the action button.
+ let button = getButton(win);
+ await TestUtils.waitForCondition(
+ () => button.getAttribute("open") != "true",
+ "Button should not have open state after the popup closed."
+ );
+
+ await extension.unload();
+ };
+ if (configData.use_default_popup) {
+ // With popup.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("popup background script ran");
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: "testmenu",
+ title: `Open ${window.actionType}`,
+ contexts: [window.actionType],
+ command: `_execute_${window.actionType}`,
+ },
+ resolve
+ );
+ });
+
+ await browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ let popupPromise = window.getPopupOpenedPromise();
+ await window.sendMessage("triggerClick");
+ await popupPromise;
+
+ browser.test.notifyPass();
+ };
+ } else if (configData.disable_button) {
+ // Without popup and disabled button.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("nopopup & button disabled background script ran");
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: "testmenu",
+ title: `Open ${window.actionType}`,
+ contexts: [window.actionType],
+ command: `_execute_${window.actionType}`,
+ },
+ resolve
+ );
+ });
+
+ await browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser[window.apiName].onClicked.addListener(async (tab, info) => {
+ browser.test.fail(
+ "Should not have seen the onClicked event for a disabled button"
+ );
+ });
+
+ await browser[window.apiName].disable();
+ await window.sendMessage("triggerClick");
+ browser.test.notifyPass();
+ };
+ } else {
+ // Without popup.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("nopopup background script ran");
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: "testmenu",
+ title: `Open ${window.actionType}`,
+ contexts: [window.actionType],
+ command: `_execute_${window.actionType}`,
+ },
+ resolve
+ );
+ });
+
+ await browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ let clickPromise = new Promise(resolve => {
+ let listener = async (tab, info) => {
+ browser[window.apiName].onClicked.removeListener(listener);
+ browser.test.assertEq("object", typeof tab);
+ browser.test.assertEq("object", typeof info);
+ browser.test.assertEq(0, info.button);
+ browser.test.assertTrue(Array.isArray(info.modifiers));
+ browser.test.assertEq(0, info.modifiers.length);
+ browser.test.log(`Tab ID is ${tab.id}`);
+ resolve();
+ };
+ browser[window.apiName].onClicked.addListener(listener);
+ });
+ await window.sendMessage("triggerClick");
+ await clickPromise;
+
+ browser.test.notifyPass();
+ };
+ }
+ break;
+ }
+
+ extensionDetails.manifest[configData.actionType] = {
+ default_title: "This is a test",
+ };
+ if (configData.use_default_popup) {
+ extensionDetails.manifest[configData.actionType].default_popup =
+ "popup.html";
+ }
+ if (configData.default_area) {
+ extensionDetails.manifest[configData.actionType].default_area =
+ configData.default_area;
+ }
+ if (configData.hasOwnProperty("background")) {
+ extensionDetails.files["background.js"] = configData.background_script;
+ }
+ if (configData.hasOwnProperty("permissions")) {
+ extensionDetails.manifest.permissions = configData.permissions;
+ }
+ if (configData.default_windows) {
+ extensionDetails.manifest[configData.actionType].default_windows =
+ configData.default_windows;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await backend_script(extension, configData);
+}
+
+async function run_action_button_order_test(configs, window, actionType) {
+ // Get camelCase API names from action type.
+ let apiName = actionType.replace(/_([a-z])/g, function (g) {
+ return g[1].toUpperCase();
+ });
+
+ function get_id(name) {
+ return `${name}_mochi_test-${apiName}-toolbarbutton`;
+ }
+
+ function test_buttons(configs, window, toolbars) {
+ for (let toolbarId of toolbars) {
+ let expected = configs.filter(e => e.toolbar == toolbarId);
+ let selector =
+ toolbarId === "unified-toolbar"
+ ? `#unifiedToolbarContent [extension$="@mochi.test"]`
+ : `#${toolbarId} toolbarbutton[id$="${get_id("")}"]`;
+ let buttons = window.document.querySelectorAll(selector);
+ Assert.equal(
+ expected.length,
+ buttons.length,
+ `Should find the correct number of buttons in ${toolbarId} toolbar`
+ );
+ for (let i = 0; i < buttons.length; i++) {
+ if (toolbarId === "unified-toolbar") {
+ Assert.equal(
+ `${expected[i].name}@mochi.test`,
+ buttons[i].getAttribute("extension"),
+ `Should find the correct button at location #${i}`
+ );
+ } else {
+ Assert.equal(
+ get_id(expected[i].name),
+ buttons[i].id,
+ `Should find the correct button at location #${i}`
+ );
+ }
+ }
+ }
+ }
+
+ // Create extension data.
+ let toolbars = new Set();
+ for (let config of configs) {
+ toolbars.add(config.toolbar);
+ config.extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: `${config.name}@mochi.test`,
+ },
+ },
+ [actionType]: {
+ default_title: config.name,
+ },
+ },
+ };
+ if (config.area) {
+ config.extensionData.manifest[actionType].default_area = config.area;
+ }
+ if (config.default_windows) {
+ config.extensionData.manifest[actionType].default_windows =
+ config.default_windows;
+ }
+ }
+
+ // Test order of buttons after first install.
+ for (let config of configs) {
+ config.extension = ExtensionTestUtils.loadExtension(config.extensionData);
+ await config.extension.startup();
+ }
+ test_buttons(configs, window, toolbars);
+
+ // Disable all buttons.
+ for (let config of configs) {
+ let addon = await AddonManager.getAddonByID(config.extension.id);
+ await addon.disable();
+ }
+ test_buttons([], window, toolbars);
+
+ // Re-enable all buttons in reversed order, displayed order should not change.
+ for (let config of [...configs].reverse()) {
+ let addon = await AddonManager.getAddonByID(config.extension.id);
+ await addon.enable();
+ }
+ test_buttons(configs, window, toolbars);
+
+ // Re-install all extensions in reversed order, displayed order should not change.
+ for (let config of [...configs].reverse()) {
+ config.extension2 = ExtensionTestUtils.loadExtension(config.extensionData);
+ await config.extension2.startup();
+ }
+ test_buttons(configs, window, toolbars);
+
+ // Remove all extensions.
+ for (let config of [...configs].reverse()) {
+ await config.extension.unload();
+ await config.extension2.unload();
+ }
+ test_buttons([], window, toolbars);
+}
+
+/**
+ * Helper method to switch to a cards view with vertical layout.
+ */
+async function ensure_cards_view() {
+ const { threadTree, threadPane } =
+ document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 2);
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "cards"
+ );
+ threadPane.updateThreadView("cards");
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view switched to a cards layout"
+ );
+}
+
+/**
+ * Helper method to switch to a table view with classic layout.
+ */
+async function ensure_table_view() {
+ const { threadTree, threadPane } =
+ document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 0);
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "table"
+ );
+ threadPane.updateThreadView("table");
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+}
diff --git a/comm/mail/components/extensions/test/browser/head_menus.js b/comm/mail/components/extensions/test/browser/head_menus.js
new file mode 100644
index 0000000000..346c4ca044
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/head_menus.js
@@ -0,0 +1,733 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals synthesizeMouseAtCenterAndRetry, awaitBrowserLoaded */
+
+"use strict";
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const treeClick = mailTestUtils.treeClick.bind(null, EventUtils, window);
+
+var URL_BASE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";
+
+/**
+ * Left-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function leftClick(menu, element) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(element, {}, element.ownerGlobal);
+ return shownPromise;
+}
+/**
+ * Right-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function rightClick(menu, element) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ element,
+ { type: "contextmenu" },
+ element.ownerGlobal
+ );
+ return shownPromise;
+}
+
+/**
+ * Right-click on something in a content document and wait for the context
+ * menu to appear.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {string} selector - CSS selector of the element to be clicked on.
+ * @param {Element} browser - <browser> containing the element.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+async function rightClickOnContent(menu, selector, browser) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "contextmenu" },
+ browser
+ );
+ return shownPromise;
+}
+
+/**
+ * Check the parameters of a browser.onShown event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {Array?} expectedInfo.menuIds
+ * @param {Array?} expectedInfo.contexts
+ * @param {Array?} expectedInfo.attachments
+ * @param {object?} expectedInfo.displayedFolder
+ * @param {object?} expectedInfo.selectedFolder
+ * @param {Array?} expectedInfo.selectedMessages
+ * @param {RegExp?} expectedInfo.pageUrl
+ * @param {string?} expectedInfo.selectionText
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkShownEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onShown");
+ Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
+ Assert.deepEqual(info.contexts, expectedInfo.contexts);
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ Assert.equal(info.attachments.length, expectedInfo.attachments.length);
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ }
+ }
+
+ for (let infoKey of ["displayedFolder", "selectedFolder"]) {
+ Assert.equal(
+ !!info[infoKey],
+ !!expectedInfo[infoKey],
+ `${infoKey} in info`
+ );
+ if (expectedInfo[infoKey]) {
+ Assert.equal(info[infoKey].accountId, expectedInfo[infoKey].accountId);
+ Assert.equal(info[infoKey].path, expectedInfo[infoKey].path);
+ Assert.ok(Array.isArray(info[infoKey].subFolders));
+ }
+ }
+
+ Assert.equal(
+ !!info.selectedMessages,
+ !!expectedInfo.selectedMessages,
+ "selectedMessages in info"
+ );
+ if (expectedInfo.selectedMessages) {
+ Assert.equal(info.selectedMessages.id, null);
+ Assert.equal(
+ info.selectedMessages.messages.length,
+ expectedInfo.selectedMessages.messages.length
+ );
+ for (let i = 0; i < expectedInfo.selectedMessages.messages.length; i++) {
+ Assert.equal(
+ info.selectedMessages.messages[i].subject,
+ expectedInfo.selectedMessages.messages[i].subject
+ );
+ }
+ }
+
+ Assert.equal(!!info.pageUrl, !!expectedInfo.pageUrl, "pageUrl in info");
+ if (expectedInfo.pageUrl) {
+ if (typeof expectedInfo.pageUrl == "string") {
+ Assert.equal(info.pageUrl, expectedInfo.pageUrl);
+ } else {
+ Assert.ok(info.pageUrl.match(expectedInfo.pageUrl));
+ }
+ }
+
+ Assert.equal(
+ !!info.selectionText,
+ !!expectedInfo.selectionText,
+ "selectionText in info"
+ );
+ if (expectedInfo.selectionText) {
+ Assert.equal(info.selectionText, expectedInfo.selectionText);
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+/**
+ * Check the parameters of a browser.onClicked event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {string?} expectedInfo.selectionText
+ * @param {string?} expectedInfo.linkText
+ * @param {RegExp?} expectedInfo.pageUrl
+ * @param {RegExp?} expectedInfo.linkUrl
+ * @param {RegExp?} expectedInfo.srcUrl
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkClickedEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onClicked");
+
+ Assert.equal(info.selectionText, expectedInfo.selectionText, "selectionText");
+ Assert.equal(info.linkText, expectedInfo.linkText, "linkText");
+ if (expectedInfo.menuItemId) {
+ Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
+ }
+
+ for (let infoKey of ["pageUrl", "linkUrl", "srcUrl"]) {
+ Assert.equal(
+ !!info[infoKey],
+ !!expectedInfo[infoKey],
+ `${infoKey} in info`
+ );
+ if (expectedInfo[infoKey]) {
+ if (typeof expectedInfo[infoKey] == "string") {
+ Assert.equal(info[infoKey], expectedInfo[infoKey]);
+ } else {
+ Assert.ok(info[infoKey].match(expectedInfo[infoKey]));
+ }
+ }
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+async function getMenuExtension(manifest) {
+ let details = {
+ files: {
+ "background.js": async () => {
+ let contexts = [
+ "audio",
+ "compose_action",
+ "compose_action_menu",
+ "message_display_action",
+ "message_display_action_menu",
+ "editable",
+ "frame",
+ "image",
+ "link",
+ "page",
+ "password",
+ "selection",
+ "tab",
+ "video",
+ "message_list",
+ "folder_pane",
+ "compose_attachments",
+ "compose_body",
+ "tools_menu",
+ ];
+ if (browser.runtime.getManifest().manifest_version > 2) {
+ contexts.push("action", "action_menu");
+ } else {
+ contexts.push("browser_action", "browser_action_menu");
+ }
+
+ for (let context of contexts) {
+ browser.menus.create({
+ id: context,
+ title: context,
+ contexts: [context],
+ });
+ }
+
+ browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser.menus.onClicked.addListener((...args) => {
+ browser.test.sendMessage("onClicked", args);
+ });
+ browser.test.sendMessage("menus-created");
+ },
+ },
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "menus@mochi.test",
+ },
+ },
+ background: { scripts: ["background.js"] },
+ ...manifest,
+ },
+ useAddonManager: "temporary",
+ };
+
+ if (!details.manifest.permissions) {
+ details.manifest.permissions = [];
+ }
+ details.manifest.permissions.push("menus");
+ console.log(JSON.stringify(details, 2));
+ let extension = ExtensionTestUtils.loadExtension(details);
+ if (details.manifest.host_permissions) {
+ // MV3 has to manually grant the requested permission.
+ await ExtensionPermissions.add("menus@mochi.test", {
+ permissions: [],
+ origins: details.manifest.host_permissions,
+ });
+ }
+ return extension;
+}
+
+async function subtest_content(
+ extension,
+ extensionHasPermission,
+ browser,
+ pageUrl,
+ tab
+) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ let menuId = browser.getAttribute("context");
+ let ownerDocument;
+ if (browser.ownerGlobal.parent.location.href == "about:3pane") {
+ ownerDocument = browser.ownerGlobal.parent.document;
+ } else if (menuId == "browserContext") {
+ ownerDocument = browser.ownerGlobal.top.document;
+ } else {
+ ownerDocument = browser.ownerDocument;
+ }
+ let menu = ownerDocument.getElementById(menuId);
+
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser);
+
+ info("Test a part of the page with no content.");
+
+ await rightClickOnContent(menu, "body", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_page"));
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["page"],
+ contexts: ["page", "all"],
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ },
+ tab
+ );
+
+ info("Test selection.");
+
+ await SpecialPowers.spawn(browser, [], () => {
+ let text = content.document.querySelector("p");
+ content.getSelection().selectAllChildren(text);
+ });
+ await rightClickOnContent(menu, "p", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_selection"));
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ selectionText: extensionHasPermission ? "This is text." : undefined,
+ menuIds: ["selection"],
+ contexts: ["selection", "all"],
+ },
+ tab
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ selectionText: "This is text.",
+ },
+ tab
+ );
+ menu.activateItem(
+ menu.querySelector("#menus_mochi_test-menuitem-_selection")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+
+ info("Test link.");
+
+ await rightClickOnContent(menu, "a", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_link"));
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["link"],
+ contexts: ["link", "all"],
+ },
+ tab
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ linkUrl: "http://mochi.test:8888/",
+ linkText: "This is a link with text.",
+ },
+ tab
+ );
+ menu.activateItem(menu.querySelector("#menus_mochi_test-menuitem-_link"));
+ await clickedPromise;
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ info("Test image.");
+
+ await rightClickOnContent(menu, "img", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_image"));
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["image"],
+ contexts: ["image", "all"],
+ },
+ tab
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ srcUrl: `${URL_BASE}/tb-logo.png`,
+ },
+ tab
+ );
+ menu.activateItem(menu.querySelector("#menus_mochi_test-menuitem-_image"));
+ await clickedPromise;
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+}
+
+async function openExtensionSubMenu(menu) {
+ // The extension submenu ends with a number, which increases over time, but it
+ // does not have a underscore.
+ let submenu;
+ for (let item of menu.querySelectorAll("[id^=menus_mochi_test-menuitem-]")) {
+ if (!item.id.includes("-_")) {
+ submenu = item;
+ break;
+ }
+ }
+ Assert.ok(submenu, `Found submenu: ${submenu.id}`);
+
+ // Open submenu.
+ let submenuPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ submenu.openMenu(true);
+ await submenuPromise;
+
+ return submenu;
+}
+
+async function subtest_compose_body(
+ extension,
+ extensionHasPermission,
+ browser,
+ pageUrl,
+ tab
+) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ let ownerDocument = browser.ownerDocument;
+ let menu = ownerDocument.getElementById(browser.getAttribute("context"));
+
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser);
+
+ info("Test a part of the page with no content.");
+ {
+ await rightClickOnContent(menu, "body", browser);
+ Assert.ok(menu.querySelector(`#menus_mochi_test-menuitem-_compose_body`));
+ Assert.ok(menu.querySelector(`#menus_mochi_test-menuitem-_editable`));
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["editable", "compose_body"],
+ contexts: ["editable", "compose_body", "all"],
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ },
+ tab
+ );
+ }
+
+ info("Test selection.");
+ {
+ await SpecialPowers.spawn(browser, [], () => {
+ let text = content.document.querySelector("p");
+ content.getSelection().selectAllChildren(text);
+ });
+
+ await rightClickOnContent(menu, "p", browser);
+ let submenu = await openExtensionSubMenu(menu);
+
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ selectionText: extensionHasPermission ? "This is text." : undefined,
+ menuIds: ["editable", "selection", "compose_body"],
+ contexts: ["editable", "selection", "compose_body", "all"],
+ },
+ tab
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_selection"));
+ Assert.ok(
+ submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(submenu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ selectionText: "This is text.",
+ },
+ tab
+ );
+ menu.activateItem(
+ submenu.querySelector("#menus_mochi_test-menuitem-_selection")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+ }
+
+ info("Test link.");
+ {
+ await rightClickOnContent(menu, "a", browser);
+ let submenu = await openExtensionSubMenu(menu);
+
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["editable", "link", "compose_body"],
+ contexts: ["editable", "link", "compose_body", "all"],
+ },
+ tab
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_link"));
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
+ Assert.ok(
+ submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(submenu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ linkUrl: "http://mochi.test:8888/",
+ linkText: "This is a link with text.",
+ },
+ tab
+ );
+ menu.activateItem(
+ submenu.querySelector("#menus_mochi_test-menuitem-_link")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+ }
+
+ info("Test image.");
+ {
+ await rightClickOnContent(menu, "img", browser);
+ let submenu = await openExtensionSubMenu(menu);
+
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["editable", "image", "compose_body"],
+ contexts: ["editable", "image", "compose_body", "all"],
+ },
+ tab
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_image"));
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
+ Assert.ok(
+ submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ srcUrl: `${URL_BASE}/tb-logo.png`,
+ },
+ tab
+ );
+ menu.activateItem(
+ submenu.querySelector("#menus_mochi_test-menuitem-_image")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+ }
+}
+
+// Test UI elements which have been made accessible for the menus API.
+// Assumed to be run after subtest_content, so we know everything has finished
+// loading.
+async function subtest_element(
+ extension,
+ extensionHasPermission,
+ element,
+ pageUrl,
+ tab
+) {
+ for (let selectedTest of [false, true]) {
+ element.focus();
+ if (selectedTest) {
+ element.value = "This is selected text.";
+ element.select();
+ } else {
+ element.value = "";
+ }
+
+ let event = await rightClick(element.ownerGlobal, element);
+ let menu = event.target;
+ let trigger = menu.triggerNode;
+ let menuitem = menu.querySelector("#menus_mochi_test-menuitem-_editable");
+ Assert.equal(
+ element.id,
+ trigger.id,
+ "Contextmenu of correct element has been triggered."
+ );
+ Assert.equal(
+ menuitem.id,
+ "menus_mochi_test-menuitem-_editable",
+ "Contextmenu includes menu."
+ );
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: selectedTest ? ["editable", "selection"] : ["editable"],
+ contexts: selectedTest
+ ? ["editable", "selection", "all"]
+ : ["editable", "all"],
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ selectionText:
+ extensionHasPermission && selectedTest
+ ? "This is selected text."
+ : undefined,
+ },
+ tab
+ );
+
+ // With text being selected, there will be two "context" entries in an
+ // extension submenu. Open the submenu.
+ let submenu = null;
+ if (selectedTest) {
+ for (let foundMenu of menu.querySelectorAll(
+ "[id^='menus_mochi_test-menuitem-']"
+ )) {
+ if (!foundMenu.id.startsWith("menus_mochi_test-menuitem-_")) {
+ submenu = foundMenu;
+ }
+ }
+ Assert.ok(submenu, "Submenu found.");
+ let submenuPromise = BrowserTestUtils.waitForEvent(
+ element.ownerGlobal,
+ "popupshown"
+ );
+ submenu.openMenu(true);
+ await submenuPromise;
+ }
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ element.ownerGlobal,
+ "popuphidden"
+ );
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ selectionText: selectedTest ? "This is selected text." : undefined,
+ },
+ tab
+ );
+ if (submenu) {
+ submenu.menupopup.activateItem(menuitem);
+ } else {
+ menu.activateItem(menuitem);
+ }
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ }
+}
diff --git a/comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml b/comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml
new file mode 100644
index 0000000000..0575e8542c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml
@@ -0,0 +1,186 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+Cc: Robin <damian@wayne-enterprises.com>
+From: Batman <bruce@wayne-enterprises.com>
+Subject: Attached message with attachments
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This message has one normal attachment and one email attachment,
+ which itself has 3 attachments.<br>
+ </p>
+ </body>
+</html>
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: message/rfc822; charset=UTF-8; name="sample02.eml"
+Content-Disposition: attachment; filename="sample02.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-attached.eml@mime.sample>
+From: Superman <clark.kent@dailyplanet.com>
+To: =?iso-8859-1?Q?Heinz_M=FCller?= <mueller@examples.com>
+Cc: Jimmy <jimmy.Olsen@dailyplanet.com>
+Subject: Test message
+Date: Wed, 17 May 2000 19:32:47 -0400
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0002_01BFC036.AE309650"
+X-Priority: 3 (Normal)
+X-MSMail-Priority: Normal
+X-Mailer: Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)
+Importance: Normal
+X-MimeOLE: Produced By Microsoft MimeOLE V5.00.2314.1300
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+Die Hasen und die Fr=F6sche=20
+=20
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="blueball1.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="blueball2.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAgAABAAABgAAAAA
+CCkAEEIAEEoACDEAEFIIIXMIKXsIKYQIIWsAGFoACDkIIWMQOZwYQqUYQq0YQrUQOaUQMZQAGFIQ
+MYwpUrU5Y8Y5Y84pWs4YSs4YQs4YQr1Ca8Z7nNacvd6Mtd5jlOcxa94hUt4YStYYQsYQMaUAACHO
+5+/n7++cxu9ShO8pWucQOa1Ke86tzt6lzu9ajO8QMZxahNat1ufO7++Mve9Ke+8YOaUYSsaMvee1
+5++Uve8AAClajOdzpe9rnO8IKYwxY+8pWu8IIXsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAADBMg1VAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAGISURBVHicddJtV5swGAbgEk6AJhBSk4bMCUynBSLaqovbrG/bfPn/vyh70lbssceb
+L5xznTsh5BmNhgQoRChwo50EOIohUYLDj4zHhKYQkrEoQdvock4ne0IKMVUpKZLQDeqSTIsv+18P
+yqqWUw2IBsRM7307PPp+fDJrWtnpLDJvewYxnewfnvanZ+fzpmwXijC8KbqEa3Fx2ff91Y95U9XC
+UpaDeQwiMpHXP/v+1++bWVPWQoGFawtjury9vru/f/C1Vi7ezT0WWpQHf/7+u/G71aLThK/MjRxm
+T6KdzZ9fGk9yatMsTgZLl3XVgFRAC6spj/13enssqJVtWVa3NdBSacL8+VZmYqKmdd1CSYoOiMOS
+GwtzlqqlFFIuOqv0a1ZEZrUkWICLLFW266y1KvWE1zV/iDAH1EopnVLCiygZCIomH3NCKX0lnI+B
+1iuuzCGTxwXjnDO4d7NpbX42YJJHkBwmAm2TxwAZg40J3+Xtbv1rgOAZwG0NxW62p+lT+Yi747sD
+/wEUVMzYmWkOvwAAACV0RVh0Q29tbWVudABjbGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZz
+O7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="greenball.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAAAEAAAGAAAIQAA
+CAAAMQAAQgAAUgAAWgAASgAIYwAIcwAIewAQjAAIawAAOQAAYwAQlAAQnAAhpQAQpQAhrQBCvRhj
+xjFjxjlSxiEpzgAYvQAQrQAYrQAhvQCU1mOt1nuE1lJK3hgh1gAYxgAYtQAAKQBCzhDO55Te563G
+55SU52NS5yEh3gAYzgBS3iGc52vW75y974yE71JC7xCt73ul3nNa7ykh5wAY1gAx5wBS7yFr7zlK
+7xgp5wAp7wAx7wAIhAAQtQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAp1fnZAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAFtSURBVHicddJtV8IgFAdwD2zIgMEE1+NcqdsoK+m5tCyz7/+ZiLmHsyzvq53zO/cy
++N9ery1bVe9PWQA9z4MQ+H8Yoj7GASZ95IHfaBGmLOSchyIgyOu22mgQSjUcDuNYcoGjLiLK1cHh
+0fHJaTKKOcMItgYxT89OzsfjyTTLC8UF0c2ZNmKquJhczq6ub+YmSVUYRF59GeDastu7+9nD41Nm
+kiJ2jc2J3kAWZ9Pr55fH18XSmRuKUTXUaqHy7O19tfr4NFle/w3YDrWRUIlZrL/W86XJkyJVG9Ea
+EjIx2XyZmZJGioeUaL+2AY8TY8omR6nkLKhu70zjUKVJXsp3quS2DVSJWNh3zzJKCyexI0ZxBP3a
+fE0ElyqOlZJyw8r3BE2SFiJCyxA434SCkg65RhdeQBljQtCg39LWrA90RDDG1EWrYUO23hMANUKR
+Rl61E529cR++D2G5LK002dr/qrcfu9u0V3bxn/XdhR/NYeeN0ggsLAAAACV0RVh0Q29tbWVudABj
+bGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZzO7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="redball.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAABAAALAAAVAAAa
+AAAXAAARAAAKAAADAAAcAAAyAABEAABNAABIAAA9AAAjAAAWAAAmAABhAAB7AACGAACHAAB9AAB0
+AABgAAA5AAAUAAAGAAAnAABLAABvAACQAAClAAC7AAC/AACrAAChAACMAABzAABbAAAuAAAIAABM
+AAB3AACZAAC0GRnKODjVPT3bKSndBQW4AACoAAB5AAAxAAAYAAAEAABFAACaAAC7JCTRYWHfhITm
+f3/mVlbqHx/SAAC5AACjAABdAABCAAAoAAAJAABnAAC6Dw/QVFTek5PlrKzpmZntZWXvJSXXAADB
+AACxAACcAABtAABTAAA2AAAbAAAFAABKAACBAADLICDdZ2fonJzrpqbtiorvUVHvFBTRAADDAAC2
+AAB4AABeAABAAAAiAABXAACSAADCAADaGxvoVVXseHjveHjvV1fvJibhAADOAAC3AACnAACVAABH
+AAArAAAPAACdAADFAADhBQXrKCjvPDzvNTXvGxvjAADQAADJAAC1AACXAACEAABsAABPAAASAAAC
+AABiAADpAADvAgLnAADYAADLAAC6AACwAABwAAATAAAkAABYAADIAADTAADNAACzAACDAABuAAAe
+AAB+AADAAACkAACNAAB/AABpAABQAAAwAACRAACpAAC8AACqAACbAABlAABJAAAqAAAOAAA0AACs
+AACvAACtAACmAACJAAB6AABrAABaAAA+AAApAABqAACCAACfAACeAACWAACPAAB8AAAZAAAHAABV
+AACOAACKAAA4AAAQAAA/AAByAACAAABcAAA3AAAsAABmAABDAABWAAAgAAAzAAA8AAA6AAAfAAAM
+AAAdAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAD8LtlFAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAIISURBVHicY2CAg/8QwIABmJhZWFnZ2Dk4MaU5uLh5eHn5+LkFBDlQJf8zC/EIi4iK
+iUtI8koJScsgyf5nlpWTV1BUUlZRVVPX4NFk1UJIyghp6+jq6RsYGhmbKJgK85mZW8Dk/rNaSlhZ
+29ja2Ts4Ojkr6Li4urFDNf53N/Ow8vTy9vH18w8IDAoWDQkNC4+ASP5ni4wKio6JjYtPSExKTnFW
+SE1LF4A69n9GZlZ2Tm5efkFhUXFySWlZlEd5RSVY7j+TkGRVdU1tXX1DY1Ozcktpa1t7h2YnOAj+
+d7l1tyo79vT29SdNSJ44SbFVdHIo9xSIHNPUaWqTpifNSJrZnK00S0U1a/acUG5piNz/uXLzVJ2q
+m6dXz584S2WB1cJFi5cshZr539xVftnyFKUVTi2TVjqvyhJLXb1m7TqoHPt6F/HW0g0bN63crGqV
+tWXrtu07BJihcsw71+zanRW8Z89eq337RQ/Ip60xO3gIElX/LbikDm8T36KwbNmRo7O3zpHkPSZw
+HBqL//8flz1x2OOkyKJTi7aqbzutfUZI2gIuF8F2lr/D5dw2+fZdwpl8YVOlI+CJ4/9/joOyYed5
+QzMvhGqnm2V0WiClm///D0lfXHtJ6vLlK9w7rx7vQk5SQJbFtSms1y9evXid7QZacgOxmSxktNzd
+tSwwU+J/VICaCPFIYU3XAJhIOtjf5sfyAAAAJXRFWHRDb21tZW50AGNsaXAyZ2lmIHYuMC42IGJ5
+IFl2ZXMgUGlndWV0NnM7vAAAAABJRU5ErkJggg==
+
+------=_NextPart_000_0002_01BFC036.AE309650--
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: image/png;
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="yellowball.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAgAABAAABgA
+AAAACCkAEEIAEEoACDEAEFIIIXMIKXsIKYQIIWsAGFoACDkIIWMQOZwYQqUYQq0YQrUQOaUQ
+MZQAGFIQMYwpUrU5Y8Y5Y84pWs4YSs4YQs4YQr1Ca8Z7nNacvd6Mtd5jlOcxa94hUt4YStYY
+QsYQMaUAACHO5+/n7++cxu9ShO8pWucQOa1Ke86tzt6lzu9ajO8QMZxahNat1ufO7++Mve9K
+e+8YOaUYSsaMvee15++Uve8AAClajOdzpe9rnO8IKYwxY+8pWu8IIXsAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADB
+Mg1VAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAuMT1evmgAAAGI
+SURBVHicddJtV5swGAbgEk6AJhBSk4bMCUynBSLaqovbrG/bfPn/vyh70lbsscebL5xznTsh
+5BmNhgQoRChwo50EOIohUYLDj4zHhKYQkrEoQdvock4ne0IKMVUpKZLQDeqSTIsv+18PyqqW
+Uw2IBsRM7307PPp+fDJrWtnpLDJvewYxnewfnvanZ+fzpmwXijC8KbqEa3Fx2ff91Y95U9XC
+UpaDeQwiMpHXP/v+1++bWVPWQoGFawtjury9vru/f/C1Vi7ezT0WWpQHf/7+u/G71aLThK/M
+jRxmT6KdzZ9fGk9yatMsTgZLl3XVgFRAC6spj/13enssqJVtWVa3NdBSacL8+VZmYqKmdd1C
+SYoOiMOSGwtzlqqlFFIuOqv0a1ZEZrUkWICLLFW266y1KvWE1zV/iDAH1EopnVLCiygZCIom
+H3NCKX0lnI+B1iuuzCGTxwXjnDO4d7NpbX42YJJHkBwmAm2TxwAZg40J3+Xtbv1rgOAZwG0N
+xW62p+lT+Yi747sD/wEUVMzYmWkOvwAAACV0RVh0Q29tbWVudABjbGlwMmdpZiB2LjAuNiBi
+eSBZdmVzIFBpZ3VldDZzO7wAAAAASUVORK5CYII=
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/browser/messages/messageWithLink.eml b/comm/mail/components/extensions/test/browser/messages/messageWithLink.eml
new file mode 100644
index 0000000000..469a799f05
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/messages/messageWithLink.eml
@@ -0,0 +1,26 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+Cc: Robin <damian@wayne-enterprises.com>
+From: Batman <bruce@wayne-enterprises.com>
+Subject: Message with a link
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This is an interesting <a id="link" href="https://www.example.de/messageLink.html">link</a></p>
+ </body>
+</html>
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/browser/test_browserAction.js b/comm/mail/components/extensions/test/browser/test_browserAction.js
new file mode 100644
index 0000000000..209c701168
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/test_browserAction.js
@@ -0,0 +1,845 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let account;
+let messages;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+});
+
+// This test uses a command from the menus API to open the popup.
+add_task(async function test_popup_open_with_menu_command_mv2() {
+ info("3-pane tab");
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-menu-command",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-menu-command",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ messageWindow.close();
+ }
+});
+
+add_task(async function test_popup_open_with_menu_command_mv3() {
+ info("3-pane tab");
+ let testConfig = {
+ manifest_version: 3,
+ actionType: "action",
+ testType: "open-with-menu-command",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ manifest_version: 3,
+ actionType: "action",
+ testType: "open-with-menu-command",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ messageWindow.close();
+ }
+});
+
+add_task(async function test_theme_icons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_properties@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ await extension.startup();
+ await unifiedToolbarUpdate;
+ await TestUtils.waitForCondition(
+ () =>
+ document.querySelector(
+ `#unifiedToolbarContent [extension="browser_action_properties@mochi.test"]`
+ ),
+ "Button added to unified toolbar"
+ );
+
+ let uuid = extension.uuid;
+ let icon = document.querySelector(
+ `#unifiedToolbarContent [extension="browser_action_properties@mochi.test"] .button-icon`
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+ await extension.unload();
+});
+
+add_task(async function test_theme_icons_messagewindow() {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_properties@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ default_windows: ["messageDisplay"],
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let uuid = extension.uuid;
+ let button = messageWindow.document.getElementById(
+ "browser_action_properties_mochi_test-browserAction-toolbarbutton"
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+
+ await extension.unload();
+ messageWindow.close();
+});
+
+add_task(async function test_button_order() {
+ info("3-pane tab");
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "unified-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "unified-toolbar",
+ },
+ {
+ name: "addon3",
+ toolbar: "unified-toolbar",
+ },
+ {
+ name: "addon4",
+ toolbar: "unified-toolbar",
+ },
+ ],
+ window,
+ "browser_action"
+ );
+
+ info("Message window");
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "mail-bar3",
+ default_windows: ["messageDisplay"],
+ },
+ {
+ name: "addon2",
+ toolbar: "mail-bar3",
+ default_windows: ["messageDisplay"],
+ },
+ ],
+ messageWindow,
+ "browser_action"
+ );
+ messageWindow.close();
+});
+
+add_task(async function test_upgrade() {
+ // Add a browser_action, to make sure the currentSet has been initialized.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension1",
+ applications: { gecko: { id: "Extension1@mochi.test" } },
+ browser_action: {
+ default_title: "Extension1",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension1 ready");
+ },
+ });
+ await extension1.startup();
+ await extension1.awaitMessage("Extension1 ready");
+
+ // Add extension without a browser_action.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 ready");
+ },
+ });
+ await extension2.startup();
+ await extension2.awaitMessage("Extension2 ready");
+
+ // Update the extension, now including a browser_action.
+ let updatedExtension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "2.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ browser_action: {
+ default_title: "Extension2",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 updated");
+ },
+ });
+ await updatedExtension2.startup();
+ await updatedExtension2.awaitMessage("Extension2 updated");
+
+ let button = document.querySelector(
+ `.unified-toolbar [extension="Extension2@mochi.test"]`
+ );
+
+ Assert.ok(button, "Button should exist");
+
+ await extension1.unload();
+ await extension2.unload();
+ await updatedExtension2.unload();
+});
+
+add_task(async function test_iconPath() {
+ // String values for the default_icon manifest entry have been tested in the
+ // theme_icons test already. Here we test imagePath objects for the manifest key
+ // and string values as well as objects for the setIcons() function.
+ let files = {
+ "background.js": async () => {
+ await window.sendMessage("checkState", "icon1.png");
+
+ await browser.browserAction.setIcon({ path: "icon2.png" });
+ await window.sendMessage("checkState", "icon2.png");
+
+ await browser.browserAction.setIcon({ path: { 16: "icon3.png" } });
+ await window.sendMessage("checkState", "icon3.png");
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "default",
+ default_icon: { 16: "icon1.png" },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkState", async expected => {
+ let uuid = extension.uuid;
+ let icon = document.querySelector(
+ `.unified-toolbar [extension="browser_action@mochi.test"] .button-icon`
+ );
+
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/${expected}")`,
+ `Icon path should be correct.`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_allowedSpaces() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["calendar", "default"],
+ },
+ },
+ });
+
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button shouldn't be in the mail space toolbar"
+ );
+
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should be in the calendar space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the default space toolbar");
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button should be hidden again in the mail space toolbar"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_allowedInAllSpaces() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_all_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_all_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: [],
+ },
+ },
+ });
+
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the mail space toolbar");
+
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should be in the calendar space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the default space toolbar");
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should still be in the mail space toolbar"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_allowedSpacesDefault() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_default_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_default_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "Test Action",
+ },
+ },
+ });
+
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the mail space toolbar");
+
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button should not be in the calendar space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button should not be in the default space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should still be in the mail space toolbar again"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_update_allowedSpaces() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ async function closeSpaceTab() {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.closeTab();
+ await toolbarMutation;
+ }
+
+ async function ensureActiveMailSpace() {
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ if (window.gSpacesToolbar.currentSpace != mailSpace) {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+ }
+ }
+
+ async function checkUnifiedToolbar(extension, expectedSpaces) {
+ // Make sure the mail space is open.
+ await ensureActiveMailSpace();
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ // Test mail space.
+ {
+ let expected = expectedSpaces.includes("mail");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${expected ? " " : " not "}be in the mail space toolbar`
+ );
+ }
+
+ // Test calendar space.
+ {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ let expected = expectedSpaces.includes("calendar");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${
+ expected ? " " : " not "
+ }be in the calendar space toolbar`
+ );
+ await closeSpaceTab();
+ }
+
+ // Test default space.
+ {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ let expected = expectedSpaces.includes("default");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${
+ expected ? " " : " not "
+ }be in the default space toolbar`
+ );
+ await closeSpaceTab();
+ }
+
+ // Test mail space again.
+ {
+ await ensureActiveMailSpace();
+ let expected = expectedSpaces.includes("mail");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${expected ? " " : " not "}be in the mail space toolbar`
+ );
+ }
+ }
+
+ // Install extension and test that the button is shown in the default space and
+ // in the calendar space.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["calendar", "default"],
+ },
+ },
+ });
+ await checkUnifiedToolbar(extension1, ["calendar", "default"]);
+
+ // Update extension by installing a newer version on top. Verify that it is now
+ // also shown in the mail space.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["mail", "calendar", "default"],
+ },
+ },
+ });
+ await checkUnifiedToolbar(extension2, ["mail", "calendar", "default"]);
+
+ // Update extension by installing a newer version on top. Verify that it is now
+ // no longer shown in the calendar space.
+ let extension3 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["mail", "default"],
+ },
+ },
+ });
+ await checkUnifiedToolbar(extension3, ["mail", "default"]);
+
+ await extension1.unload();
+ await extension2.unload();
+ await extension3.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/.eslintrc.js b/comm/mail/components/extensions/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..60d784b53c
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,13 @@
+"use strict";
+
+module.exports = {
+ env: {
+ // The tests in this folder are testing based on WebExtensions, so lets
+ // just define the webextensions environment here.
+ webextensions: true,
+ // Many parts of WebExtensions test definitions (e.g. content scripts) also
+ // interact with the browser environment, so define that here as we don't
+ // have an easy way to handle per-function/scope usage yet.
+ browser: true,
+ },
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/data/utils.js b/comm/mail/components/extensions/test/xpcshell/data/utils.js
new file mode 100644
index 0000000000..9025982e33
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/data/utils.js
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Functions for extensions to use, so that we avoid repeating ourselves.
+
+function assertDeepEqual(
+ expected,
+ actual,
+ description = "Values should be equal",
+ options = {}
+) {
+ let ok;
+ let strict = !!options?.strict;
+ try {
+ ok = assertDeepEqualNested(expected, actual, strict);
+ } catch (e) {
+ ok = false;
+ }
+ if (!ok) {
+ browser.test.fail(
+ `Deep equal test. \n Expected value: ${JSON.stringify(
+ expected
+ )} \n Actual value: ${JSON.stringify(actual)},
+ ${description}`
+ );
+ }
+}
+
+function assertDeepEqualNested(expected, actual, strict) {
+ if (expected === null) {
+ browser.test.assertTrue(actual === null);
+ return actual === null;
+ }
+
+ if (expected === undefined) {
+ browser.test.assertTrue(actual === undefined);
+ return actual === undefined;
+ }
+
+ if (["boolean", "number", "string"].includes(typeof expected)) {
+ browser.test.assertEq(typeof expected, typeof actual);
+ browser.test.assertEq(expected, actual);
+ return typeof expected == typeof actual && expected == actual;
+ }
+
+ if (Array.isArray(expected)) {
+ browser.test.assertTrue(Array.isArray(actual));
+ browser.test.assertEq(expected.length, actual.length);
+ let ok = 0;
+ let all = 0;
+ for (let i = 0; i < expected.length; i++) {
+ all++;
+ if (assertDeepEqualNested(expected[i], actual[i], strict)) {
+ ok++;
+ }
+ }
+ return (
+ Array.isArray(actual) && expected.length == actual.length && all == ok
+ );
+ }
+
+ let expectedKeys = Object.keys(expected);
+ let actualKeys = Object.keys(actual);
+ // Ignore any extra keys on the actual object in non-strict mode (default).
+ let lengthOk = strict
+ ? expectedKeys.length == actualKeys.length
+ : expectedKeys.length <= actualKeys.length;
+ browser.test.assertTrue(lengthOk);
+
+ let ok = 0;
+ let all = 0;
+ for (let key of expectedKeys) {
+ all++;
+ browser.test.assertTrue(actualKeys.includes(key), `Key ${key} exists`);
+ if (assertDeepEqualNested(expected[key], actual[key], strict)) {
+ ok++;
+ }
+ }
+ return all == ok && lengthOk;
+}
+
+function waitForMessage() {
+ return waitForEvent("test.onMessage");
+}
+
+function waitForEvent(eventName) {
+ let [namespace, name] = eventName.split(".");
+ return new Promise(resolve => {
+ browser[namespace][name].addListener(function listener(...args) {
+ browser[namespace][name].removeListener(listener);
+ resolve(args);
+ });
+ });
+}
+
+async function waitForCondition(condition, msg, interval = 100, maxTries = 50) {
+ let conditionPassed = false;
+ let tries = 0;
+ for (; tries < maxTries && !conditionPassed; tries++) {
+ await new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ window.setTimeout(resolve, interval)
+ );
+ try {
+ conditionPassed = await condition();
+ } catch (e) {
+ throw Error(`${msg} - threw exception: ${e}`);
+ }
+ }
+ if (conditionPassed) {
+ browser.test.succeed(
+ `waitForCondition succeeded after ${tries} retries - ${msg}`
+ );
+ } else {
+ browser.test.fail(`${msg} - timed out after ${maxTries} retries`);
+ }
+}
+
+function sendMessage(...args) {
+ let replyPromise = waitForMessage();
+ browser.test.sendMessage(...args);
+ return replyPromise;
+}
diff --git a/comm/mail/components/extensions/test/xpcshell/head-imap.js b/comm/mail/components/extensions/test/xpcshell/head-imap.js
new file mode 100644
index 0000000000..ac85c52b64
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/head-imap.js
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from head.js */
+
+var IS_IMAP = true;
+
+let wrappedCreateAccount = createAccount;
+createAccount = function (type = "imap") {
+ return wrappedCreateAccount(type);
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/head-nntp.js b/comm/mail/components/extensions/test/xpcshell/head-nntp.js
new file mode 100644
index 0000000000..0b4a56d0dc
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/head-nntp.js
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from head.js */
+
+var IS_NNTP = true;
+
+let wrappedCreateAccount = createAccount;
+createAccount = function (type = "nntp") {
+ return wrappedCreateAccount(type);
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/head.js b/comm/mail/components/extensions/test/xpcshell/head.js
new file mode 100644
index 0000000000..f8c0c0e7b9
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/head.js
@@ -0,0 +1,298 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { fsDebugAll, gThreadManager, nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+// Persistent Listener test functionality
+var { assertPersistentListeners } = ExtensionTestUtils.testAssertions;
+
+ExtensionTestUtils.init(this);
+
+var IS_IMAP = false;
+var IS_NNTP = false;
+
+function formatVCard(strings, ...values) {
+ let arr = [];
+ for (let str of strings) {
+ arr.push(str);
+ arr.push(values.shift());
+ }
+ let lines = arr.join("").split("\n");
+ let indent = lines[1].length - lines[1].trimLeft().length;
+ let outLines = [];
+ for (let line of lines) {
+ if (line.length > 0) {
+ outLines.push(line.substring(indent) + "\r\n");
+ }
+ }
+ return outLines.join("");
+}
+
+function createAccount(type = "none") {
+ let account;
+
+ if (type == "local") {
+ MailServices.accounts.createLocalMailAccount();
+ account = MailServices.accounts.FindAccountForServer(
+ MailServices.accounts.localFoldersServer
+ );
+ } else {
+ account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ type
+ );
+ }
+
+ if (type == "imap") {
+ IMAPServer.open();
+ account.incomingServer.port = IMAPServer.port;
+ account.incomingServer.username = "user";
+ account.incomingServer.password = "password";
+ }
+
+ if (type == "nntp") {
+ NNTPServer.open();
+ account.incomingServer.port = NNTPServer.port;
+ }
+ info(`Created account ${account.toString()}`);
+ return account;
+}
+
+function cleanUpAccount(account) {
+ let serverKey = account.incomingServer.key;
+ let serverType = account.incomingServer.type;
+ info(
+ `Cleaning up ${serverType} account ${account.key} and server ${serverKey}`
+ );
+ MailServices.accounts.removeAccount(account, true);
+
+ try {
+ let server = MailServices.accounts.getIncomingServer(serverKey);
+ if (server) {
+ info(`Cleaning up leftover ${serverType} server ${serverKey}`);
+ MailServices.accounts.removeIncomingServer(server, false);
+ }
+ } catch (e) {}
+}
+
+registerCleanupFunction(() => {
+ MailServices.accounts.accounts.forEach(cleanUpAccount);
+});
+
+function addIdentity(account, email = "xpcshell@localhost") {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = email;
+ account.addIdentity(identity);
+ if (!account.defaultIdentity) {
+ account.defaultIdentity = identity;
+ }
+ info(`Created identity ${identity.toString()}`);
+ return identity;
+}
+
+async function createSubfolder(parent, name) {
+ if (parent.server.type == "nntp") {
+ createNewsgroup(name);
+ let account = MailServices.accounts.FindAccountForServer(parent.server);
+ subscribeNewsgroup(account, name);
+ return parent.getChildNamed(name);
+ }
+
+ let promiseAdded = PromiseTestUtils.promiseFolderAdded(name);
+ parent.createSubfolder(name, null);
+ await promiseAdded;
+ return parent.getChildNamed(name);
+}
+
+function createMessages(folder, makeMessagesArg) {
+ if (typeof makeMessagesArg == "number") {
+ makeMessagesArg = { count: makeMessagesArg };
+ }
+ if (!createMessages.messageGenerator) {
+ createMessages.messageGenerator = new MessageGenerator();
+ }
+
+ let messages = createMessages.messageGenerator.makeMessages(makeMessagesArg);
+ return addGeneratedMessages(folder, messages);
+}
+
+class FakeGeneratedMessage {
+ constructor(msg) {
+ this.msg = msg;
+ }
+ toMessageString() {
+ return this.msg;
+ }
+ toMboxString() {
+ // A cheap hack. It works for existing uses but may not work for future uses.
+ let fromAddress = this.msg.match(/From: .* <(.*@.*)>/)[0];
+ let mBoxString = `From ${fromAddress}\r\n${this.msg}`;
+ // Ensure a trailing empty line.
+ if (!mBoxString.endsWith("\r\n")) {
+ mBoxString = mBoxString + "\r\n";
+ }
+ return mBoxString;
+ }
+}
+
+async function createMessageFromFile(folder, path) {
+ let message = await IOUtils.readUTF8(path);
+ return addGeneratedMessages(folder, [new FakeGeneratedMessage(message)]);
+}
+
+async function createMessageFromString(folder, message) {
+ return addGeneratedMessages(folder, [new FakeGeneratedMessage(message)]);
+}
+
+async function addGeneratedMessages(folder, messages) {
+ if (folder.server.type == "imap") {
+ return IMAPServer.addMessages(folder, messages);
+ }
+ if (folder.server.type == "nntp") {
+ return NNTPServer.addMessages(folder, messages);
+ }
+
+ let messageStrings = messages.map(message => message.toMboxString());
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessageBatch(messageStrings);
+ folder.callFilterPlugins(null);
+ return Promise.resolve();
+}
+
+async function getUtilsJS() {
+ return IOUtils.readUTF8(do_get_file("data/utils.js").path);
+}
+
+var IMAPServer = {
+ open() {
+ let { ImapDaemon, ImapMessage, IMAP_RFC3501_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Imapd.jsm"
+ );
+ IMAPServer.ImapMessage = ImapMessage;
+
+ this.daemon = new ImapDaemon();
+ this.server = new nsMailServer(
+ daemon => new IMAP_RFC3501_handler(daemon),
+ this.daemon
+ );
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+
+ addMessages(folder, messages) {
+ let fakeFolder = IMAPServer.daemon.getMailbox(folder.name);
+ messages.forEach(message => {
+ if (typeof message != "string") {
+ message = message.toMessageString();
+ }
+ let msgURI = Services.io.newURI(
+ "data:text/plain;base64," + btoa(message)
+ );
+ let imapMsg = new IMAPServer.ImapMessage(
+ msgURI.spec,
+ fakeFolder.uidnext++,
+ []
+ );
+ fakeFolder.addMessage(imapMsg);
+ });
+
+ return new Promise(resolve =>
+ mailTestUtils.updateFolderAndNotify(folder, resolve)
+ );
+ },
+};
+
+function subscribeNewsgroup(account, group) {
+ account.incomingServer.QueryInterface(Ci.nsINntpIncomingServer);
+ account.incomingServer.subscribeToNewsgroup(group);
+ account.incomingServer.maximumConnectionsNumber = 1;
+}
+
+function createNewsgroup(group) {
+ if (!NNTPServer.hasGroup(group)) {
+ NNTPServer.addGroup(group);
+ }
+}
+
+var NNTPServer = {
+ open() {
+ let { NNTP_RFC977_handler, NntpDaemon } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+ );
+
+ this.daemon = new NntpDaemon();
+ this.server = new nsMailServer(
+ daemon => new NNTP_RFC977_handler(daemon),
+ this.daemon
+ );
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+
+ addGroup(group) {
+ return this.daemon.addGroup(group);
+ },
+
+ hasGroup(group) {
+ return this.daemon.getGroup(group) != null;
+ },
+
+ addMessages(folder, messages) {
+ let { NewsArticle } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+ );
+
+ let group = folder.name;
+ messages.forEach(message => {
+ if (typeof message != "string") {
+ message = message.toMessageString();
+ }
+ // The NNTP daemon needs a trailing empty line.
+ if (!message.endsWith("\r\n")) {
+ message = message + "\r\n";
+ }
+ let article = new NewsArticle(message);
+ article.groups = [group];
+ this.daemon.addArticle(article);
+ });
+
+ return new Promise(resolve => {
+ mailTestUtils.updateFolderAndNotify(folder, resolve);
+ });
+ },
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/images/redPixel.png b/comm/mail/components/extensions/test/xpcshell/images/redPixel.png
new file mode 100644
index 0000000000..abda018027
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/images/redPixel.png
Binary files differ
diff --git a/comm/mail/components/extensions/test/xpcshell/images/whitePixel.png b/comm/mail/components/extensions/test/xpcshell/images/whitePixel.png
new file mode 100644
index 0000000000..5514ad40e9
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/images/whitePixel.png
Binary files differ
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/alternative.eml b/comm/mail/components/extensions/test/xpcshell/messages/alternative.eml
new file mode 100644
index 0000000000..11de6a87d6
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/alternative.eml
@@ -0,0 +1,23 @@
+Message-ID: <alternative.eml@mime.sample>
+Date: Fri, 19 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>, Karl <friedrich@example.com>
+From: Doug Sauder <dwsauder@example.com>
+Subject: Default content-types
+Mime-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="=====================_714967308==_.ALT"
+
+This message is in MIME format. The first part should be readable text,
+while the remaining parts are likely unreadable without MIME-aware tools.
+
+--=====================_714967308==_.ALT
+Content-Transfer-Encoding: quoted-printable
+
+I am TEXT!
+
+--=====================_714967308==_.ALT
+Content-Type: text/html
+
+<html><body>I <b>am</b> HTML!</body></html>
+
+--=====================_714967308==_.ALT--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml b/comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml
new file mode 100644
index 0000000000..85a54b66c5
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml
@@ -0,0 +1,35 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+From: Batman <bruce@example.com>
+Subject: Attached message without subject
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This message has one email attachment with missing headers.<br>
+ </p>
+ </body>
+</html>
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: message/rfc822; charset=UTF-8; name="message1.eml"
+Content-Disposition: attachment; filename="message1.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-attached.eml@mime.sample>
+MIME-Version: 1.0
+
+This is my body
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml b/comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml
new file mode 100644
index 0000000000..5ced639ff8
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml
@@ -0,0 +1,127 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+Cc: Robin <damian@wayne-enterprises.com>
+From: Batman <bruce@wayne-enterprises.com>
+Subject: Attached message with attachments
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This message has one normal attachment and one email attachment,
+ which itself has 3 attachments.<br>
+ </p>
+ </body>
+</html>
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: message/rfc822; charset=UTF-8; name="message1.eml"
+Content-Disposition: attachment; filename="message1.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-attached.eml@mime.sample>
+From: Superman <clark.kent@dailyplanet.com>
+To: Jimmy <jimmy.olsen@dailyplanet.com>
+Subject: Test message 1
+Date: Wed, 17 May 2000 19:32:47 -0400
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0002_01BFC036.AE309650"
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: 7bit
+
+Message with multiple attachments.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="whitePixel.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="whitePixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnn
+AAAAAElFTkSuQmCC
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="greenPixel.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
+jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+C76AoAAhUBJel4xsMAAAAASUVO
+RK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="redPixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
+jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+hgkAYAAbcApOp/9LEAAAAASUVO
+RK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: message/rfc822; charset=UTF-8; name="message2.eml"
+Content-Disposition: attachment; filename="message2.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-nested-attached.eml@mime.sample>
+From: Jimmy <jimmy.olsen@dailyplanet.com>
+To: Superman <clark.kent@dailyplanet.com>
+Subject: Test message 2
+Date: Wed, 16 May 2000 19:32:47 -0400
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0003_01BFC036.AE309650"
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0003_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: 7bit
+
+This message has an attachment
+
+------=_NextPart_000_0003_01BFC036.AE309650
+Content-Type: image/png;
+ name="whitePixel.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="whitePixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnn
+AAAAAElFTkSuQmCC
+
+------=_NextPart_000_0003_01BFC036.AE309650--
+
+------=_NextPart_000_0002_01BFC036.AE309650--
+
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: image/png;
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="yellowPixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1B
+AACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY/j/iQEABOUB8pypNlQA
+AAAASUVORK5CYII=
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample01.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample01.eml
new file mode 100644
index 0000000000..f7ac14a07d
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample01.eml
@@ -0,0 +1,11 @@
+From: Bug Reporter <new@thunderbird.bug>
+Newsgroups: gmane.comp.mozilla.thundebird.user
+Subject: =?UTF-8?B?zrHOu8+GzqzOss63z4TOvw==?=
+Date: Thu, 27 May 2021 21:23:35 +0100
+Message-ID: <01.eml@mime.sample>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8;
+Content-Transfer-Encoding: base64
+Content-Disposition: inline
+
+zobOu8+GzrEK
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample02.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample02.eml
new file mode 100644
index 0000000000..74b60b5665
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample02.eml
@@ -0,0 +1,121 @@
+From: "Doug Sauder" <doug@example.com>
+To: =?iso-8859-1?Q?Heinz_M=FCller?= <mueller@example.com>
+Subject: Test message from Microsoft Outlook 00
+Date: Wed, 17 May 2000 19:32:47 -0400
+Message-ID: <02.eml@mime.sample>
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0002_01BFC036.AE309650"
+X-Priority: 3 (Normal)
+X-MSMail-Priority: Normal
+X-Mailer: Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)
+Importance: Normal
+X-MimeOLE: Produced By Microsoft MimeOLE V5.00.2314.1300
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+Die Hasen und die Fr=F6sche=20
+=20
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="blueball1.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="blueball2.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAgAABAAABgAAAAA
+CCkAEEIAEEoACDEAEFIIIXMIKXsIKYQIIWsAGFoACDkIIWMQOZwYQqUYQq0YQrUQOaUQMZQAGFIQ
+MYwpUrU5Y8Y5Y84pWs4YSs4YQs4YQr1Ca8Z7nNacvd6Mtd5jlOcxa94hUt4YStYYQsYQMaUAACHO
+5+/n7++cxu9ShO8pWucQOa1Ke86tzt6lzu9ajO8QMZxahNat1ufO7++Mve9Ke+8YOaUYSsaMvee1
+5++Uve8AAClajOdzpe9rnO8IKYwxY+8pWu8IIXsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAADBMg1VAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAGISURBVHicddJtV5swGAbgEk6AJhBSk4bMCUynBSLaqovbrG/bfPn/vyh70lbssceb
+L5xznTsh5BmNhgQoRChwo50EOIohUYLDj4zHhKYQkrEoQdvock4ne0IKMVUpKZLQDeqSTIsv+18P
+yqqWUw2IBsRM7307PPp+fDJrWtnpLDJvewYxnewfnvanZ+fzpmwXijC8KbqEa3Fx2ff91Y95U9XC
+UpaDeQwiMpHXP/v+1++bWVPWQoGFawtjury9vru/f/C1Vi7ezT0WWpQHf/7+u/G71aLThK/MjRxm
+T6KdzZ9fGk9yatMsTgZLl3XVgFRAC6spj/13enssqJVtWVa3NdBSacL8+VZmYqKmdd1CSYoOiMOS
+GwtzlqqlFFIuOqv0a1ZEZrUkWICLLFW266y1KvWE1zV/iDAH1EopnVLCiygZCIomH3NCKX0lnI+B
+1iuuzCGTxwXjnDO4d7NpbX42YJJHkBwmAm2TxwAZg40J3+Xtbv1rgOAZwG0NxW62p+lT+Yi747sD
+/wEUVMzYmWkOvwAAACV0RVh0Q29tbWVudABjbGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZz
+O7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="greenball.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAAAEAAAGAAAIQAA
+CAAAMQAAQgAAUgAAWgAASgAIYwAIcwAIewAQjAAIawAAOQAAYwAQlAAQnAAhpQAQpQAhrQBCvRhj
+xjFjxjlSxiEpzgAYvQAQrQAYrQAhvQCU1mOt1nuE1lJK3hgh1gAYxgAYtQAAKQBCzhDO55Te563G
+55SU52NS5yEh3gAYzgBS3iGc52vW75y974yE71JC7xCt73ul3nNa7ykh5wAY1gAx5wBS7yFr7zlK
+7xgp5wAp7wAx7wAIhAAQtQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAp1fnZAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAFtSURBVHicddJtV8IgFAdwD2zIgMEE1+NcqdsoK+m5tCyz7/+ZiLmHsyzvq53zO/cy
++N9ery1bVe9PWQA9z4MQ+H8Yoj7GASZ95IHfaBGmLOSchyIgyOu22mgQSjUcDuNYcoGjLiLK1cHh
+0fHJaTKKOcMItgYxT89OzsfjyTTLC8UF0c2ZNmKquJhczq6ub+YmSVUYRF59GeDastu7+9nD41Nm
+kiJ2jc2J3kAWZ9Pr55fH18XSmRuKUTXUaqHy7O19tfr4NFle/w3YDrWRUIlZrL/W86XJkyJVG9Ea
+EjIx2XyZmZJGioeUaL+2AY8TY8omR6nkLKhu70zjUKVJXsp3quS2DVSJWNh3zzJKCyexI0ZxBP3a
+fE0ElyqOlZJyw8r3BE2SFiJCyxA434SCkg65RhdeQBljQtCg39LWrA90RDDG1EWrYUO23hMANUKR
+Rl61E529cR++D2G5LK002dr/qrcfu9u0V3bxn/XdhR/NYeeN0ggsLAAAACV0RVh0Q29tbWVudABj
+bGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZzO7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="redball.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAABAAALAAAVAAAa
+AAAXAAARAAAKAAADAAAcAAAyAABEAABNAABIAAA9AAAjAAAWAAAmAABhAAB7AACGAACHAAB9AAB0
+AABgAAA5AAAUAAAGAAAnAABLAABvAACQAAClAAC7AAC/AACrAAChAACMAABzAABbAAAuAAAIAABM
+AAB3AACZAAC0GRnKODjVPT3bKSndBQW4AACoAAB5AAAxAAAYAAAEAABFAACaAAC7JCTRYWHfhITm
+f3/mVlbqHx/SAAC5AACjAABdAABCAAAoAAAJAABnAAC6Dw/QVFTek5PlrKzpmZntZWXvJSXXAADB
+AACxAACcAABtAABTAAA2AAAbAAAFAABKAACBAADLICDdZ2fonJzrpqbtiorvUVHvFBTRAADDAAC2
+AAB4AABeAABAAAAiAABXAACSAADCAADaGxvoVVXseHjveHjvV1fvJibhAADOAAC3AACnAACVAABH
+AAArAAAPAACdAADFAADhBQXrKCjvPDzvNTXvGxvjAADQAADJAAC1AACXAACEAABsAABPAAASAAAC
+AABiAADpAADvAgLnAADYAADLAAC6AACwAABwAAATAAAkAABYAADIAADTAADNAACzAACDAABuAAAe
+AAB+AADAAACkAACNAAB/AABpAABQAAAwAACRAACpAAC8AACqAACbAABlAABJAAAqAAAOAAA0AACs
+AACvAACtAACmAACJAAB6AABrAABaAAA+AAApAABqAACCAACfAACeAACWAACPAAB8AAAZAAAHAABV
+AACOAACKAAA4AAAQAAA/AAByAACAAABcAAA3AAAsAABmAABDAABWAAAgAAAzAAA8AAA6AAAfAAAM
+AAAdAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAD8LtlFAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAIISURBVHicY2CAg/8QwIABmJhZWFnZ2Dk4MaU5uLh5eHn5+LkFBDlQJf8zC/EIi4iK
+iUtI8koJScsgyf5nlpWTV1BUUlZRVVPX4NFk1UJIyghp6+jq6RsYGhmbKJgK85mZW8Dk/rNaSlhZ
+29ja2Ts4Ojkr6Li4urFDNf53N/Ow8vTy9vH18w8IDAoWDQkNC4+ASP5ni4wKio6JjYtPSExKTnFW
+SE1LF4A69n9GZlZ2Tm5efkFhUXFySWlZlEd5RSVY7j+TkGRVdU1tXX1DY1Ozcktpa1t7h2YnOAj+
+d7l1tyo79vT29SdNSJ44SbFVdHIo9xSIHNPUaWqTpifNSJrZnK00S0U1a/acUG5piNz/uXLzVJ2q
+m6dXz584S2WB1cJFi5cshZr539xVftnyFKUVTi2TVjqvyhJLXb1m7TqoHPt6F/HW0g0bN63crGqV
+tWXrtu07BJihcsw71+zanRW8Z89eq337RQ/Ip60xO3gIElX/LbikDm8T36KwbNmRo7O3zpHkPSZw
+HBqL//8flz1x2OOkyKJTi7aqbzutfUZI2gIuF8F2lr/D5dw2+fZdwpl8YVOlI+CJ4/9/joOyYed5
+QzMvhGqnm2V0WiClm///D0lfXHtJ6vLlK9w7rx7vQk5SQJbFtSms1y9evXid7QZacgOxmSxktNzd
+tSwwU+J/VICaCPFIYU3XAJhIOtjf5sfyAAAAJXRFWHRDb21tZW50AGNsaXAyZ2lmIHYuMC42IGJ5
+IFl2ZXMgUGlndWV0NnM7vAAAAABJRU5ErkJggg==
+
+------=_NextPart_000_0002_01BFC036.AE309650--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample03.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample03.eml
new file mode 100644
index 0000000000..3eb8e06802
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample03.eml
@@ -0,0 +1,43 @@
+From: =?iso-8859-1?Q?Heinz_M=FCller?= <mueller@example.com>
+To: "Joe Blow" <jblow@example.com>
+Subject: Test message from Microsoft Outlook 00
+Date: Wed, 17 May 2000 19:35:05 -0400
+Message-ID: <03.eml@mime.sample>
+MIME-Version: 1.0
+Content-Type: image/png;
+ name="doubelspace ball.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="doubelspace ball.png"
+X-Priority: 3 (Normal)
+X-MSMail-Priority: Normal
+X-Mailer: Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)
+Importance: Normal
+X-MimeOLE: Produced By Microsoft MimeOLE V5.00.2314.1300
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAABAAALAAAVAAAa
+AAAXAAARAAAKAAADAAAcAAAyAABEAABNAABIAAA9AAAjAAAWAAAmAABhAAB7AACGAACHAAB9AAB0
+AABgAAA5AAAUAAAGAAAnAABLAABvAACQAAClAAC7AAC/AACrAAChAACMAABzAABbAAAuAAAIAABM
+AAB3AACZAAC0GRnKODjVPT3bKSndBQW4AACoAAB5AAAxAAAYAAAEAABFAACaAAC7JCTRYWHfhITm
+f3/mVlbqHx/SAAC5AACjAABdAABCAAAoAAAJAABnAAC6Dw/QVFTek5PlrKzpmZntZWXvJSXXAADB
+AACxAACcAABtAABTAAA2AAAbAAAFAABKAACBAADLICDdZ2fonJzrpqbtiorvUVHvFBTRAADDAAC2
+AAB4AABeAABAAAAiAABXAACSAADCAADaGxvoVVXseHjveHjvV1fvJibhAADOAAC3AACnAACVAABH
+AAArAAAPAACdAADFAADhBQXrKCjvPDzvNTXvGxvjAADQAADJAAC1AACXAACEAABsAABPAAASAAAC
+AABiAADpAADvAgLnAADYAADLAAC6AACwAABwAAATAAAkAABYAADIAADTAADNAACzAACDAABuAAAe
+AAB+AADAAACkAACNAAB/AABpAABQAAAwAACRAACpAAC8AACqAACbAABlAABJAAAqAAAOAAA0AACs
+AACvAACtAACmAACJAAB6AABrAABaAAA+AAApAABqAACCAACfAACeAACWAACPAAB8AAAZAAAHAABV
+AACOAACKAAA4AAAQAAA/AAByAACAAABcAAA3AAAsAABmAABDAABWAAAgAAAzAAA8AAA6AAAfAAAM
+AAAdAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAD8LtlFAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAIISURBVHicY2CAg/8QwIABmJhZWFnZ2Dk4MaU5uLh5eHn5+LkFBDlQJf8zC/EIi4iK
+iUtI8koJScsgyf5nlpWTV1BUUlZRVVPX4NFk1UJIyghp6+jq6RsYGhmbKJgK85mZW8Dk/rNaSlhZ
+29ja2Ts4Ojkr6Li4urFDNf53N/Ow8vTy9vH18w8IDAoWDQkNC4+ASP5ni4wKio6JjYtPSExKTnFW
+SE1LF4A69n9GZlZ2Tm5efkFhUXFySWlZlEd5RSVY7j+TkGRVdU1tXX1DY1Ozcktpa1t7h2YnOAj+
+d7l1tyo79vT29SdNSJ44SbFVdHIo9xSIHNPUaWqTpifNSJrZnK00S0U1a/acUG5piNz/uXLzVJ2q
+m6dXz584S2WB1cJFi5cshZr539xVftnyFKUVTi2TVjqvyhJLXb1m7TqoHPt6F/HW0g0bN63crGqV
+tWXrtu07BJihcsw71+zanRW8Z89eq337RQ/Ip60xO3gIElX/LbikDm8T36KwbNmRo7O3zpHkPSZw
+HBqL//8flz1x2OOkyKJTi7aqbzutfUZI2gIuF8F2lr/D5dw2+fZdwpl8YVOlI+CJ4/9/joOyYed5
+QzMvhGqnm2V0WiClm///D0lfXHtJ6vLlK9w7rx7vQk5SQJbFtSms1y9evXid7QZacgOxmSxktNzd
+tSwwU+J/VICaCPFIYU3XAJhIOtjf5sfyAAAAJXRFWHRDb21tZW50AGNsaXAyZ2lmIHYuMC42IGJ5
+IFl2ZXMgUGlndWV0NnM7vAAAAABJRU5ErkJggg==
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample04.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample04.eml
new file mode 100644
index 0000000000..6dd2a94b56
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample04.eml
@@ -0,0 +1,10 @@
+Newsgroups: gmane.comp.mozilla.thundebird.user
+From: Bug Reporter <new@thunderbird.bug>
+Subject: =?koi8-r?B?4czGwdfJ1Ao=?=
+Date: Sun, 27 May 2001 21:23:35 +0100
+MIME-Version: 1.0
+Message-ID: <04.eml@mime.sample>
+Content-Type: text/plain; charset=koi8-r;
+Content-Transfer-Encoding: base64
+
+98/Q0s/TCg== \ No newline at end of file
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample05.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample05.eml
new file mode 100644
index 0000000000..6e70eee744
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample05.eml
@@ -0,0 +1,10 @@
+Newsgroups: gmane.comp.mozilla.thundebird.user
+From: Bug Reporter <new@thunderbird.bug>
+Subject: =?windows-1251?B?wOv04OLo8go=?=
+Date: Sun, 27 May 2001 21:23:35 +0100
+MIME-Version: 1.0
+Message-ID: <05.eml@mime.sample>
+Content-Type: text/plain; charset=windows-1251;
+Content-Transfer-Encoding: base64
+
+wu7v8O7xCg== \ No newline at end of file
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample06.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample06.eml
new file mode 100644
index 0000000000..a5b3a40ac5
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample06.eml
@@ -0,0 +1,8 @@
+Newsgroups: gmane.comp.mozilla.thundebird.user
+From: Bug Reporter <new@thunderbird.bug>
+Subject: I have no content type
+Date: Sun, 27 May 2001 21:23:35 +0100
+MIME-Version: 1.0
+Message-ID: <06.eml@mime.sample>
+
+No content type
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample07.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample07.eml
new file mode 100644
index 0000000000..29283b2ce0
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample07.eml
@@ -0,0 +1,24 @@
+Message-ID: <07.eml@mime.sample>
+Date: Fri, 19 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+From: Doug Sauder <dwsauder@example.com>
+Subject: Default content-types
+Mime-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="=====================_714967308==_.ALT"
+
+This message is in MIME format. The first part should be readable text,
+while the remaining parts are likely unreadable without MIME-aware tools.
+
+--=====================_714967308==_.ALT
+Content-Transfer-Encoding: quoted-printable
+
+Die Hasen
+
+--=====================_714967308==_.ALT
+Content-Type: text/html
+
+<html><body><b>Die Hasen</b></body></html>
+
+--=====================_714967308==_.ALT--
+
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js
new file mode 100644
index 0000000000..ac6f5482ce
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js
@@ -0,0 +1,1089 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function test_accounts() {
+ // Here all the accounts are local but the first account will behave as
+ // an actual local account and will be kept last always.
+ let files = {
+ "background.js": async () => {
+ let [account1Id, account1Name] = await window.waitForMessage();
+
+ let defaultAccount = await browser.accounts.getDefault();
+ browser.test.assertEq(
+ null,
+ defaultAccount,
+ "The default account should be null, as none is defined."
+ );
+
+ let result1 = await browser.accounts.list();
+ browser.test.assertEq(1, result1.length);
+ window.assertDeepEqual(
+ {
+ id: account1Id,
+ name: account1Name,
+ type: "none",
+ folders: [
+ {
+ accountId: account1Id,
+ name: "Trash",
+ path: "/Trash",
+ type: "trash",
+ },
+ {
+ accountId: account1Id,
+ name: "Outbox",
+ path: "/Unsent Messages",
+ type: "outbox",
+ },
+ ],
+ },
+ result1[0]
+ );
+
+ // Test that excluding folders works.
+ let result1WithOutFolders = await browser.accounts.list(false);
+ for (let account of result1WithOutFolders) {
+ browser.test.assertEq(null, account.folders, "Folders not included");
+ }
+
+ let [account2Id, account2Name] = await window.sendMessage(
+ "create account 2"
+ );
+ // The new account is defined as default and should be returned first.
+ let result2 = await browser.accounts.list();
+ browser.test.assertEq(2, result2.length);
+ window.assertDeepEqual(
+ [
+ {
+ id: account2Id,
+ name: account2Name,
+ type: "imap",
+ folders: [
+ {
+ accountId: account2Id,
+ name: "Inbox",
+ path: "/INBOX",
+ type: "inbox",
+ },
+ ],
+ },
+ {
+ id: account1Id,
+ name: account1Name,
+ type: "none",
+ folders: [
+ {
+ accountId: account1Id,
+ name: "Trash",
+ path: "/Trash",
+ type: "trash",
+ },
+ {
+ accountId: account1Id,
+ name: "Outbox",
+ path: "/Unsent Messages",
+ type: "outbox",
+ },
+ ],
+ },
+ ],
+ result2
+ );
+
+ let result3 = await browser.accounts.get(account1Id);
+ window.assertDeepEqual(result1[0], result3);
+ let result4 = await browser.accounts.get(account2Id);
+ window.assertDeepEqual(result2[0], result4);
+
+ let result3WithoutFolders = await browser.accounts.get(account1Id, false);
+ browser.test.assertEq(
+ null,
+ result3WithoutFolders.folders,
+ "Folders not included"
+ );
+ let result4WithoutFolders = await browser.accounts.get(account2Id, false);
+ browser.test.assertEq(
+ null,
+ result4WithoutFolders.folders,
+ "Folders not included"
+ );
+
+ await window.sendMessage("create folders");
+ let result5 = await browser.accounts.get(account1Id);
+ let platformInfo = await browser.runtime.getPlatformInfo();
+ window.assertDeepEqual(
+ [
+ {
+ accountId: account1Id,
+ name: "Trash",
+ path: "/Trash",
+ subFolders: [
+ {
+ accountId: account1Id,
+ name: "%foo %test% 'bar'(!)+",
+ path: "/Trash/%foo %test% 'bar'(!)+",
+ },
+ {
+ accountId: account1Id,
+ name: "Ϟ",
+ // This character is not supported on Windows, so it gets hashed,
+ // by NS_MsgHashIfNecessary.
+ path: platformInfo.os == "win" ? "/Trash/b52bc214" : "/Trash/Ϟ",
+ },
+ ],
+ type: "trash",
+ },
+ {
+ accountId: account1Id,
+ name: "Outbox",
+ path: "/Unsent Messages",
+ type: "outbox",
+ },
+ ],
+ result5.folders
+ );
+
+ // Check we can access the folders through folderPathToURI.
+ for (let folder of result5.folders) {
+ await browser.messages.list(folder);
+ }
+
+ let result6 = await browser.accounts.get(account2Id);
+ window.assertDeepEqual(
+ [
+ {
+ accountId: account2Id,
+ name: "Inbox",
+ path: "/INBOX",
+ subFolders: [
+ {
+ accountId: account2Id,
+ name: "%foo %test% 'bar'(!)+",
+ path: "/INBOX/%foo %test% 'bar'(!)+",
+ },
+ {
+ accountId: account2Id,
+ name: "Ϟ",
+ path: "/INBOX/&A94-",
+ },
+ ],
+ type: "inbox",
+ },
+ {
+ // The trash folder magically appears at this point.
+ // It wasn't here before.
+ accountId: "account2",
+ name: "Trash",
+ path: "/Trash",
+ type: "trash",
+ },
+ ],
+ result6.folders
+ );
+
+ // Check we can access the folders through folderPathToURI.
+ for (let folder of result6.folders) {
+ await browser.messages.list(folder);
+ }
+
+ defaultAccount = await browser.accounts.getDefault();
+ browser.test.assertEq(result2[0].id, defaultAccount.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ let account1 = createAccount();
+ extension.sendMessage(account1.key, account1.incomingServer.prettyName);
+
+ await extension.awaitMessage("create account 2");
+ let account2 = createAccount("imap");
+ IMAPServer.open();
+ account2.incomingServer.port = IMAPServer.port;
+ account2.incomingServer.username = "user";
+ account2.incomingServer.password = "password";
+ MailServices.accounts.defaultAccount = account2;
+ extension.sendMessage(account2.key, account2.incomingServer.prettyName);
+
+ await extension.awaitMessage("create folders");
+ let inbox1 = account1.incomingServer.rootFolder.subFolders[0];
+ // Test our code can handle characters that might be escaped.
+ inbox1.createSubfolder("%foo %test% 'bar'(!)+", null);
+ inbox1.createSubfolder("Ϟ", null); // Test our code can handle unicode.
+
+ let inbox2 = account2.incomingServer.rootFolder.subFolders[0];
+ inbox2.QueryInterface(Ci.nsIMsgImapMailFolder).hierarchyDelimiter = "/";
+ // Test our code can handle characters that might be escaped.
+ inbox2.createSubfolder("%foo %test% 'bar'(!)+", null);
+ await PromiseTestUtils.promiseFolderAdded("%foo %test% 'bar'(!)+");
+ inbox2.createSubfolder("Ϟ", null); // Test our code can handle unicode.
+ await PromiseTestUtils.promiseFolderAdded("Ϟ");
+
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account1);
+ cleanUpAccount(account2);
+});
+
+add_task(async function test_identities() {
+ let account1 = createAccount();
+ let account2 = createAccount("imap");
+ let identity0 = addIdentity(account1, "id0@invalid");
+ let identity1 = addIdentity(account1, "id1@invalid");
+ let identity2 = addIdentity(account1, "id2@invalid");
+ let identity3 = addIdentity(account2, "id3@invalid");
+ addIdentity(account2, "id4@invalid");
+ identity2.label = "A label";
+ identity2.fullName = "Identity 2!";
+ identity2.organization = "Dis Organization";
+ identity2.replyTo = "reply@invalid";
+ identity2.composeHtml = true;
+ identity2.htmlSigText = "This is me. And this is my Dog.";
+ identity2.htmlSigFormat = false;
+
+ equal(account1.defaultIdentity.key, identity0.key);
+ equal(account2.defaultIdentity.key, identity3.key);
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length);
+
+ const localAccount = accounts.find(account => account.type == "none");
+ const imapAccount = accounts.find(account => account.type == "imap");
+
+ // Register event listener.
+ let onCreatedLog = [];
+ browser.identities.onCreated.addListener((id, created) => {
+ onCreatedLog.push({ id, created });
+ });
+ let onUpdatedLog = [];
+ browser.identities.onUpdated.addListener((id, changed) => {
+ onUpdatedLog.push({ id, changed });
+ });
+ let onDeletedLog = [];
+ browser.identities.onDeleted.addListener(id => {
+ onDeletedLog.push(id);
+ });
+
+ const { id: accountId, identities } = localAccount;
+ const identityIds = identities.map(i => i.id);
+ browser.test.assertEq(3, identities.length);
+
+ browser.test.assertEq(accountId, identities[0].accountId);
+ browser.test.assertEq("id0@invalid", identities[0].email);
+ browser.test.assertEq(accountId, identities[1].accountId);
+ browser.test.assertEq("id1@invalid", identities[1].email);
+ browser.test.assertEq(accountId, identities[2].accountId);
+ browser.test.assertEq("id2@invalid", identities[2].email);
+ browser.test.assertEq("A label", identities[2].label);
+ browser.test.assertEq("Identity 2!", identities[2].name);
+ browser.test.assertEq("Dis Organization", identities[2].organization);
+ browser.test.assertEq("reply@invalid", identities[2].replyTo);
+ browser.test.assertEq(true, identities[2].composeHtml);
+ browser.test.assertEq(
+ "This is me. And this is my Dog.",
+ identities[2].signature
+ );
+ browser.test.assertEq(true, identities[2].signatureIsPlainText);
+
+ // Testing browser.identities.list().
+
+ let allIdentities = await browser.identities.list();
+ browser.test.assertEq(5, allIdentities.length);
+
+ let localIdentities = await browser.identities.list(localAccount.id);
+ browser.test.assertEq(
+ 3,
+ localIdentities.length,
+ "number of local identities is correct"
+ );
+ for (let i = 0; i < 2; i++) {
+ browser.test.assertEq(
+ localAccount.identities[i].id,
+ localIdentities[i].id,
+ "returned local identity is correct"
+ );
+ }
+
+ let imapIdentities = await browser.identities.list(imapAccount.id);
+ browser.test.assertEq(
+ 2,
+ imapIdentities.length,
+ "number of imap identities is correct"
+ );
+ for (let i = 0; i < 1; i++) {
+ browser.test.assertEq(
+ imapAccount.identities[i].id,
+ imapIdentities[i].id,
+ "returned imap identity is correct"
+ );
+ }
+
+ // Testing browser.identities.get().
+
+ let badIdentity = await browser.identities.get("funny");
+ browser.test.assertEq(null, badIdentity);
+
+ for (let identity of identities) {
+ let testIdentity = await browser.identities.get(identity.id);
+ for (let prop of Object.keys(identity)) {
+ browser.test.assertEq(
+ identity[prop],
+ testIdentity[prop],
+ `Testing identity.${prop}`
+ );
+ }
+ }
+
+ // Testing browser.identities.delete().
+
+ let imapDefaultIdentity = await browser.identities.getDefault(
+ imapAccount.id
+ );
+ let imapNonDefaultIdentity = imapIdentities.find(
+ identity => identity.id != imapDefaultIdentity.id
+ );
+
+ await browser.identities.delete(imapNonDefaultIdentity.id);
+ imapIdentities = await browser.identities.list(imapAccount.id);
+ browser.test.assertEq(
+ 1,
+ imapIdentities.length,
+ "number of imap identities after delete is correct"
+ );
+ browser.test.assertEq(
+ imapDefaultIdentity.id,
+ imapIdentities[0].id,
+ "leftover identity after delete is correct"
+ );
+
+ await browser.test.assertRejects(
+ browser.identities.delete(imapDefaultIdentity.id),
+ `Identity ${imapDefaultIdentity.id} is the default identity of account ${imapAccount.id} and cannot be deleted`,
+ "browser.identities.delete threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.identities.delete("somethingInvalid"),
+ "Identity not found: somethingInvalid",
+ "browser.identities.delete threw exception"
+ );
+
+ // Testing browser.identities.create().
+
+ let createTests = [
+ {
+ // Set all.
+ accountId: imapAccount.id,
+ details: {
+ email: "id0+test@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "id0+test@invalid",
+ signature: "This is Bruce. And this is my Cat.",
+ composeHtml: true,
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ // Set some.
+ accountId: imapAccount.id,
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ },
+ {
+ // Set none.
+ accountId: imapAccount.id,
+ details: {},
+ },
+ {
+ // Set some on an invalid account.
+ accountId: "somethingInvalid",
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ expectedThrow: `Account not found: somethingInvalid`,
+ },
+ {
+ // Try to set a protected property.
+ accountId: imapAccount.id,
+ details: {
+ accountId: "accountId5",
+ },
+ expectedThrow: `Setting the accountId property of a MailIdentity is not supported.`,
+ },
+ {
+ // Try to set a protected property together with others.
+ accountId: imapAccount.id,
+ details: {
+ id: "id8",
+ email: "id0+work@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ signatureIsPlainText: false,
+ },
+ expectedThrow: `Setting the id property of a MailIdentity is not supported.`,
+ },
+ ];
+ for (let createTest of createTests) {
+ if (createTest.expectedThrow) {
+ await browser.test.assertRejects(
+ browser.identities.create(createTest.accountId, createTest.details),
+ createTest.expectedThrow,
+ `It rejects as expected: ${createTest.expectedThrow}.`
+ );
+ } else {
+ let createPromise = new Promise(resolve => {
+ const callback = (id, identity) => {
+ browser.identities.onCreated.removeListener(callback);
+ resolve(identity);
+ };
+ browser.identities.onCreated.addListener(callback);
+ });
+ let createdIdentity = await browser.identities.create(
+ createTest.accountId,
+ createTest.details
+ );
+ let createdIdentity2 = await createPromise;
+
+ let expected = createTest.details;
+ for (let prop of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected[prop],
+ createdIdentity[prop],
+ `Testing created identity.${prop}`
+ );
+ browser.test.assertEq(
+ expected[prop],
+ createdIdentity2[prop],
+ `Testing created identity.${prop}`
+ );
+ }
+ await browser.identities.delete(createdIdentity.id);
+ }
+
+ let foundIdentities = await browser.identities.list(imapAccount.id);
+ browser.test.assertEq(
+ 1,
+ foundIdentities.length,
+ "number of imap identities after create/delete is correct"
+ );
+ }
+
+ // Testing browser.identities.update().
+
+ let updateTests = [
+ {
+ // Set all.
+ identityId: identities[2].id,
+ details: {
+ email: "id0+test@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "id0+test@invalid",
+ signature: "This is Bruce. And this is my Cat.",
+ composeHtml: true,
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ // Set some.
+ identityId: identities[2].id,
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ expected: {
+ email: "id0+work@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ // Clear.
+ identityId: identities[2].id,
+ details: {
+ email: "",
+ label: "",
+ name: "",
+ organization: "",
+ replyTo: "",
+ signature: "",
+ composeHtml: false,
+ signatureIsPlainText: true,
+ },
+ },
+ {
+ // Try to update an invalid identity.
+ identityId: "somethingInvalid",
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ expectedThrow: "Identity not found: somethingInvalid",
+ },
+ {
+ // Try to update a protected property.
+ identityId: identities[2].id,
+ details: {
+ accountId: "accountId5",
+ },
+ expectedThrow:
+ "Setting the accountId property of a MailIdentity is not supported.",
+ },
+ {
+ // Try to update another protected property together with others.
+ identityId: identities[2].id,
+ details: {
+ id: "id8",
+ email: "id0+work@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ signatureIsPlainText: false,
+ },
+ expectedThrow:
+ "Setting the id property of a MailIdentity is not supported.",
+ },
+ ];
+ for (let updateTest of updateTests) {
+ if (updateTest.expectedThrow) {
+ await browser.test.assertRejects(
+ browser.identities.update(
+ updateTest.identityId,
+ updateTest.details
+ ),
+ updateTest.expectedThrow,
+ `It rejects as expected: ${updateTest.expectedThrow}.`
+ );
+ continue;
+ }
+
+ let updatePromise = new Promise(resolve => {
+ const callback = (id, changed) => {
+ browser.identities.onUpdated.removeListener(callback);
+ resolve(changed);
+ };
+ browser.identities.onUpdated.addListener(callback);
+ });
+ let updatedIdentity = await browser.identities.update(
+ updateTest.identityId,
+ updateTest.details
+ );
+ await updatePromise;
+
+ let returnedIdentity = await browser.identities.get(
+ updateTest.identityId
+ );
+
+ let expected = updateTest.expected || updateTest.details;
+ for (let prop of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected[prop],
+ updatedIdentity[prop],
+ `Testing updated identity.${prop}`
+ );
+ browser.test.assertEq(
+ expected[prop],
+ returnedIdentity[prop],
+ `Testing returned identity.${prop}`
+ );
+ }
+ }
+
+ // Testing getDefault().
+
+ let defaultIdentity = await browser.identities.getDefault(accountId);
+ browser.test.assertEq(identities[0].id, defaultIdentity.id);
+
+ await browser.identities.setDefault(accountId, identityIds[2]);
+ defaultIdentity = await browser.identities.getDefault(accountId);
+ browser.test.assertEq(identities[2].id, defaultIdentity.id);
+
+ let { identities: newIdentities } = await browser.accounts.get(accountId);
+ browser.test.assertEq(3, newIdentities.length);
+ browser.test.assertEq(identityIds[2], newIdentities[0].id);
+ browser.test.assertEq(identityIds[0], newIdentities[1].id);
+ browser.test.assertEq(identityIds[1], newIdentities[2].id);
+
+ await browser.identities.setDefault(accountId, identityIds[1]);
+ defaultIdentity = await browser.identities.getDefault(accountId);
+ browser.test.assertEq(identities[1].id, defaultIdentity.id);
+
+ ({ identities: newIdentities } = await browser.accounts.get(accountId));
+ browser.test.assertEq(3, newIdentities.length);
+ browser.test.assertEq(identityIds[1], newIdentities[0].id);
+ browser.test.assertEq(identityIds[2], newIdentities[1].id);
+ browser.test.assertEq(identityIds[0], newIdentities[2].id);
+
+ // Check event listeners.
+ window.assertDeepEqual(
+ onCreatedLog,
+ [
+ {
+ id: "id6",
+ created: {
+ accountId: "account4",
+ id: "id6",
+ label: "TestLabel",
+ name: "Mr. Test",
+ email: "id0+test@invalid",
+ replyTo: "id0+test@invalid",
+ organization: "MZLA",
+ composeHtml: true,
+ signature: "This is Bruce. And this is my Cat.",
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ id: "id7",
+ created: {
+ accountId: "account4",
+ id: "id7",
+ label: "",
+ name: "",
+ email: "id0+work@invalid",
+ replyTo: "",
+ organization: "",
+ composeHtml: false,
+ signature: "I am Batman.",
+ signatureIsPlainText: true,
+ },
+ },
+ {
+ id: "id8",
+ created: {
+ accountId: "account4",
+ id: "id8",
+ label: "",
+ name: "",
+ email: "",
+ replyTo: "",
+ organization: "",
+ composeHtml: true,
+ signature: "",
+ signatureIsPlainText: true,
+ },
+ },
+ ],
+ "captured onCreated events are correct"
+ );
+ window.assertDeepEqual(
+ onUpdatedLog,
+ [
+ {
+ id: "id3",
+ changed: {
+ label: "TestLabel",
+ name: "Mr. Test",
+ email: "id0+test@invalid",
+ replyTo: "id0+test@invalid",
+ organization: "MZLA",
+ signature: "This is Bruce. And this is my Cat.",
+ signatureIsPlainText: false,
+ accountId: "account3",
+ id: "id3",
+ },
+ },
+ {
+ id: "id3",
+ changed: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ composeHtml: false,
+ signature: "I am Batman.",
+ accountId: "account3",
+ id: "id3",
+ },
+ },
+ {
+ id: "id3",
+ changed: {
+ label: "",
+ name: "",
+ email: "",
+ organization: "",
+ signature: "",
+ signatureIsPlainText: true,
+ accountId: "account3",
+ id: "id3",
+ },
+ },
+ ],
+ "captured onUpdated events are correct"
+ );
+ window.assertDeepEqual(
+ onDeletedLog,
+ ["id5", "id6", "id7", "id8"],
+ "captured onDeleted events are correct"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ equal(account1.defaultIdentity.key, identity1.key);
+
+ cleanUpAccount(account1);
+ cleanUpAccount(account2);
+});
+
+add_task(async function test_identities_without_write_permissions() {
+ let account = createAccount();
+ let identity0 = addIdentity(account, "id0@invalid");
+
+ equal(account.defaultIdentity.key, identity0.key);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ const [{ identities }] = accounts;
+ browser.test.assertEq(1, identities.length);
+
+ // Testing browser.identities.update().
+
+ await browser.test.assertThrows(
+ () => browser.identities.update(identities[0].id, {}),
+ "browser.identities.update is not a function",
+ "It rejects for a missing permission."
+ );
+
+ // Testing browser.identities.delete().
+
+ await browser.test.assertThrows(
+ () => browser.identities.delete(identities[0].id),
+ "browser.identities.delete is not a function",
+ "It rejects for a missing permission."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ permissions: ["accountsRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account);
+});
+
+add_task(async function test_accounts_events() {
+ let account1 = createAccount();
+ addIdentity(account1, "id1@invalid");
+
+ let files = {
+ "background.js": async () => {
+ // Register event listener.
+ let onCreatedLog = [];
+ let onUpdatedLog = [];
+ let onDeletedLog = [];
+
+ let createListener = (id, created) => {
+ onCreatedLog.push({ id, created });
+ };
+ let updateListener = (id, changed) => {
+ onUpdatedLog.push({ id, changed });
+ };
+ let deleteListener = id => {
+ onDeletedLog.push(id);
+ };
+
+ await browser.accounts.onCreated.addListener(createListener);
+ await browser.accounts.onUpdated.addListener(updateListener);
+ await browser.accounts.onDeleted.addListener(deleteListener);
+
+ // Create accounts.
+ let imapAccountKey = await window.sendMessage("createAccount", {
+ type: "imap",
+ identity: "user@invalidImap",
+ });
+ let localAccountKey = await window.sendMessage("createAccount", {
+ type: "none",
+ identity: "user@invalidLocal",
+ });
+ let popAccountKey = await window.sendMessage("createAccount", {
+ type: "pop3",
+ identity: "user@invalidPop",
+ });
+
+ // Update account identities.
+ let accounts = await browser.accounts.list();
+ let imapAccount = accounts.find(a => a.id == imapAccountKey);
+ let localAccount = accounts.find(a => a.id == localAccountKey);
+ let popAccount = accounts.find(a => a.id == popAccountKey);
+
+ let id1 = await browser.identities.create(imapAccount.id, {
+ composeHtml: true,
+ email: "user1@inter.net",
+ name: "user1",
+ });
+ let id2 = await browser.identities.create(localAccount.id, {
+ composeHtml: false,
+ email: "user2@inter.net",
+ name: "user2",
+ });
+ let id3 = await browser.identities.create(popAccount.id, {
+ composeHtml: false,
+ email: "user3@inter.net",
+ name: "user3",
+ });
+
+ await browser.identities.setDefault(imapAccount.id, id1.id);
+ browser.test.assertEq(
+ id1.id,
+ (await browser.identities.getDefault(imapAccount.id)).id
+ );
+ await browser.identities.setDefault(localAccount.id, id2.id);
+ browser.test.assertEq(
+ id2.id,
+ (await browser.identities.getDefault(localAccount.id)).id
+ );
+ await browser.identities.setDefault(popAccount.id, id3.id);
+ browser.test.assertEq(
+ id3.id,
+ (await browser.identities.getDefault(popAccount.id)).id
+ );
+
+ // Update account names.
+ await window.sendMessage("updateAccountName", {
+ accountKey: imapAccountKey,
+ name: "Test1",
+ });
+ await window.sendMessage("updateAccountName", {
+ accountKey: localAccountKey,
+ name: "Test2",
+ });
+ await window.sendMessage("updateAccountName", {
+ accountKey: popAccountKey,
+ name: "Test3",
+ });
+
+ // Delete accounts.
+ await window.sendMessage("removeAccount", {
+ accountKey: imapAccountKey,
+ });
+ await window.sendMessage("removeAccount", {
+ accountKey: localAccountKey,
+ });
+ await window.sendMessage("removeAccount", {
+ accountKey: popAccountKey,
+ });
+
+ await browser.accounts.onCreated.removeListener(createListener);
+ await browser.accounts.onUpdated.removeListener(updateListener);
+ await browser.accounts.onDeleted.removeListener(deleteListener);
+
+ // Check event listeners.
+ browser.test.assertEq(3, onCreatedLog.length);
+ window.assertDeepEqual(
+ [
+ {
+ id: "account7",
+ created: {
+ id: "account7",
+ type: "imap",
+ identities: [],
+ name: "Mail for account7user@localhost",
+ folders: null,
+ },
+ },
+ {
+ id: "account8",
+ created: {
+ id: "account8",
+ type: "none",
+ identities: [],
+ name: "account8user on localhost",
+ folders: null,
+ },
+ },
+ {
+ id: "account9",
+ created: {
+ id: "account9",
+ type: "pop3",
+ identities: [],
+ name: "account9user on localhost",
+ folders: null,
+ },
+ },
+ ],
+ onCreatedLog,
+ "captured onCreated events are correct"
+ );
+ window.assertDeepEqual(
+ [
+ {
+ id: "account7",
+ changed: { id: "account7", name: "Mail for user@localhost" },
+ },
+ {
+ id: "account7",
+ changed: {
+ id: "account7",
+ defaultIdentity: { id: "id11" },
+ },
+ },
+ {
+ id: "account8",
+ changed: {
+ id: "account8",
+ defaultIdentity: { id: "id12" },
+ },
+ },
+ {
+ id: "account9",
+ changed: {
+ id: "account9",
+ defaultIdentity: { id: "id13" },
+ },
+ },
+ {
+ id: "account7",
+ changed: {
+ id: "account7",
+ defaultIdentity: { id: "id14" },
+ },
+ },
+ {
+ id: "account8",
+ changed: {
+ id: "account8",
+ defaultIdentity: { id: "id15" },
+ },
+ },
+ {
+ id: "account9",
+ changed: {
+ id: "account9",
+ defaultIdentity: { id: "id16" },
+ },
+ },
+ {
+ id: "account7",
+ changed: {
+ id: "account7",
+ name: "Test1",
+ },
+ },
+ {
+ id: "account8",
+ changed: {
+ id: "account8",
+ name: "Test2",
+ },
+ },
+ {
+ id: "account9",
+ changed: {
+ id: "account9",
+ name: "Test3",
+ },
+ },
+ ],
+ onUpdatedLog,
+ "captured onUpdated events are correct"
+ );
+ window.assertDeepEqual(
+ ["account7", "account8", "account9"],
+ onDeletedLog,
+ "captured onDeleted events are correct"
+ );
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 250));
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ },
+ });
+
+ extension.onMessage("createAccount", details => {
+ let account = createAccount(details.type);
+ addIdentity(account, details.identity);
+ extension.sendMessage(account.key);
+ });
+ extension.onMessage("updateAccountName", details => {
+ let account = MailServices.accounts.getAccount(details.accountKey);
+ account.incomingServer.prettyName = details.name;
+ extension.sendMessage();
+ });
+ extension.onMessage("removeAccount", details => {
+ let account = MailServices.accounts.getAccount(details.accountKey);
+ cleanUpAccount(account);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account1);
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js
new file mode 100644
index 0000000000..0ac4394f40
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+add_task(async function test_accounts_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ for (let eventName of ["onCreated", "onUpdated", "onDeleted"]) {
+ browser.accounts[eventName].addListener(async (...args) => {
+ browser.test.sendMessage(`${eventName} event received`, {
+ eventCount: ++eventCounter,
+ args,
+ });
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "accounts.onCreated",
+ "accounts.onUpdated",
+ "accounts.onDeleted",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let testData = [
+ {
+ type: "imap",
+ identity: "user@invalidImap",
+ expectedUpdate: true,
+ expectedName: accountKey => `Mail for ${accountKey}user@localhost`,
+ expectedType: "imap",
+ updatedName: "Test1",
+ },
+ {
+ type: "pop3",
+ identity: "user@invalidPop",
+ expectedUpdate: false,
+ expectedName: accountKey => `${accountKey}user on localhost`,
+ expectedType: "pop3",
+ updatedName: "Test2",
+ },
+ {
+ type: "none",
+ identity: "user@invalidLocal",
+ expectedUpdate: false,
+ expectedName: accountKey => `${accountKey}user on localhost`,
+ expectedType: "none",
+ updatedName: "Test3",
+ },
+ {
+ type: "local",
+ identity: "user@invalidLocal",
+ expectedUpdate: false,
+ expectedName: accountKey => "Local Folders",
+ expectedType: "none",
+ updatedName: "Test4",
+ },
+ ];
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+
+ // Create.
+
+ for (let details of testData) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ let account = createAccount(details.type);
+ details.account = account;
+
+ {
+ let rv = await extension.awaitMessage("onCreated event received");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ args: [
+ details.account.key,
+ {
+ id: details.account.key,
+ name: details.expectedName(account.key),
+ type: details.expectedType,
+ folders: null,
+ identities: [],
+ },
+ ],
+ },
+ rv,
+ "The primed onCreated event should return the correct values"
+ );
+ }
+
+ if (details.expectedUpdate) {
+ let rv = await extension.awaitMessage("onUpdated event received");
+ Assert.deepEqual(
+ {
+ eventCount: 2,
+ args: [
+ details.account.key,
+ { id: details.account.key, name: "Mail for user@localhost" },
+ ],
+ },
+ rv,
+ "The non-primed onUpdated event should return the correct values"
+ );
+ }
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ // Update.
+
+ for (let details of testData) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ let account = MailServices.accounts.getAccount(details.account.key);
+ account.incomingServer.prettyName = details.updatedName;
+ let rv = await extension.awaitMessage("onUpdated event received");
+
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ args: [
+ details.account.key,
+ {
+ id: details.account.key,
+ name: details.updatedName,
+ },
+ ],
+ },
+ rv,
+ "The primed onUpdated event should return the correct values"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ // Delete.
+
+ for (let details of testData) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ cleanUpAccount(details.account);
+ let rv = await extension.awaitMessage("onDeleted event received");
+
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ args: [details.account.key],
+ },
+ rv,
+ "The primed onDeleted event should return the correct values"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js
new file mode 100644
index 0000000000..8fcc3ca14f
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js
@@ -0,0 +1,2043 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ AddrBookUtils: "resource:///modules/AddrBookUtils.jsm",
+});
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+add_setup(async () => {
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+});
+
+add_task(async function test_addressBooks() {
+ async function background() {
+ let firstBookId, secondBookId, newContactId;
+
+ let events = [];
+ let eventPromise;
+ let eventPromiseResolve;
+ for (let eventNamespace of ["addressBooks", "contacts", "mailingLists"]) {
+ for (let eventName of [
+ "onCreated",
+ "onUpdated",
+ "onDeleted",
+ "onMemberAdded",
+ "onMemberRemoved",
+ ]) {
+ if (eventName in browser[eventNamespace]) {
+ browser[eventNamespace][eventName].addListener((...args) => {
+ events.push({ namespace: eventNamespace, name: eventName, args });
+ if (eventPromiseResolve) {
+ let resolve = eventPromiseResolve;
+ eventPromiseResolve = null;
+ resolve();
+ }
+ });
+ }
+ }
+ }
+
+ let outsideEvent = function (action, ...args) {
+ eventPromise = new Promise(resolve => {
+ eventPromiseResolve = resolve;
+ });
+ return window.sendMessage("outsideEventsTest", action, ...args);
+ };
+ let checkEvents = async function (...expectedEvents) {
+ if (eventPromiseResolve) {
+ await eventPromise;
+ }
+
+ browser.test.assertEq(
+ expectedEvents.length,
+ events.length,
+ "Correct number of events"
+ );
+
+ if (expectedEvents.length != events.length) {
+ for (let event of events) {
+ let args = event.args.join(", ");
+ browser.test.log(`${event.namespace}.${event.name}(${args})`);
+ }
+ throw new Error("Wrong number of events, stopping.");
+ }
+
+ for (let [namespace, name, ...expectedArgs] of expectedEvents) {
+ let event = events.shift();
+ browser.test.assertEq(
+ namespace,
+ event.namespace,
+ "Event namespace is correct"
+ );
+ browser.test.assertEq(name, event.name, "Event type is correct");
+ browser.test.assertEq(
+ expectedArgs.length,
+ event.args.length,
+ "Argument count is correct"
+ );
+ window.assertDeepEqual(expectedArgs, event.args);
+ if (expectedEvents.length == 1) {
+ return event.args;
+ }
+ }
+
+ return null;
+ };
+
+ async function addressBookTest() {
+ browser.test.log("Starting addressBookTest");
+ let list = await browser.addressBooks.list();
+ browser.test.assertEq(2, list.length);
+ for (let b of list) {
+ browser.test.assertEq(5, Object.keys(b).length);
+ browser.test.assertEq(36, b.id.length);
+ browser.test.assertEq("addressBook", b.type);
+ browser.test.assertTrue("name" in b);
+ browser.test.assertFalse(b.readOnly);
+ browser.test.assertFalse(b.remote);
+ }
+
+ let completeList = await browser.addressBooks.list(true);
+ browser.test.assertEq(2, completeList.length);
+ for (let b of completeList) {
+ browser.test.assertEq(7, Object.keys(b).length);
+ }
+
+ firstBookId = list[0].id;
+ secondBookId = list[1].id;
+
+ let firstBook = await browser.addressBooks.get(firstBookId);
+ browser.test.assertEq(5, Object.keys(firstBook).length);
+
+ let secondBook = await browser.addressBooks.get(secondBookId, true);
+ browser.test.assertEq(7, Object.keys(secondBook).length);
+ browser.test.assertTrue(Array.isArray(secondBook.contacts));
+ browser.test.assertEq(0, secondBook.contacts.length);
+ browser.test.assertTrue(Array.isArray(secondBook.mailingLists));
+ browser.test.assertEq(0, secondBook.mailingLists.length);
+ let newBookId = await browser.addressBooks.create({ name: "test name" });
+ browser.test.assertEq(36, newBookId.length);
+ await checkEvents([
+ "addressBooks",
+ "onCreated",
+ { type: "addressBook", id: newBookId },
+ ]);
+
+ list = await browser.addressBooks.list();
+ browser.test.assertEq(3, list.length);
+
+ let newBook = await browser.addressBooks.get(newBookId);
+ browser.test.assertEq(newBookId, newBook.id);
+ browser.test.assertEq("addressBook", newBook.type);
+ browser.test.assertEq("test name", newBook.name);
+
+ await browser.addressBooks.update(newBookId, { name: "new name" });
+ await checkEvents([
+ "addressBooks",
+ "onUpdated",
+ { type: "addressBook", id: newBookId },
+ ]);
+ let updatedBook = await browser.addressBooks.get(newBookId);
+ browser.test.assertEq("new name", updatedBook.name);
+
+ list = await browser.addressBooks.list();
+ browser.test.assertEq(3, list.length);
+
+ await browser.addressBooks.delete(newBookId);
+ await checkEvents(["addressBooks", "onDeleted", newBookId]);
+
+ list = await browser.addressBooks.list();
+ browser.test.assertEq(2, list.length);
+
+ for (let operation of ["get", "update", "delete"]) {
+ let args = [newBookId];
+ if (operation == "update") {
+ args.push({ name: "" });
+ }
+
+ try {
+ await browser.addressBooks[operation].apply(
+ browser.addressBooks,
+ args
+ );
+ browser.test.fail(
+ `Calling ${operation} on a non-existent address book should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `addressBook with id=${newBookId} could not be found.`,
+ ex.message,
+ `browser.addressBooks.${operation} threw exception`
+ );
+ }
+ }
+
+ // Test the prevention of creating new address book with an empty name
+ await browser.test.assertRejects(
+ browser.addressBooks.create({ name: "" }),
+ "An unexpected error occurred",
+ "browser.addressBooks.create threw exception"
+ );
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed addressBookTest");
+ }
+
+ async function contactsTest() {
+ browser.test.log("Starting contactsTest");
+ let contacts = await browser.contacts.list(firstBookId);
+ browser.test.assertTrue(Array.isArray(contacts));
+ browser.test.assertEq(0, contacts.length);
+
+ newContactId = await browser.contacts.create(firstBookId, {
+ FirstName: "first",
+ LastName: "last",
+ Notes: "Notes",
+ SomethingCustom: "Custom property",
+ });
+ browser.test.assertEq(36, newContactId.length);
+ await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ ]);
+
+ contacts = await browser.contacts.list(firstBookId);
+ browser.test.assertEq(1, contacts.length, "Contact added to first book.");
+ browser.test.assertEq(contacts[0].id, newContactId);
+
+ contacts = await browser.contacts.list(secondBookId);
+ browser.test.assertEq(
+ 0,
+ contacts.length,
+ "Contact not added to second book."
+ );
+
+ let newContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(6, Object.keys(newContact).length);
+ browser.test.assertEq(newContactId, newContact.id);
+ browser.test.assertEq(firstBookId, newContact.parentId);
+ browser.test.assertEq("contact", newContact.type);
+ browser.test.assertEq(false, newContact.readOnly);
+ browser.test.assertEq(false, newContact.remote);
+ browser.test.assertEq(5, Object.keys(newContact.properties).length);
+ browser.test.assertEq("first", newContact.properties.FirstName);
+ browser.test.assertEq("last", newContact.properties.LastName);
+ browser.test.assertEq("Notes", newContact.properties.Notes);
+ browser.test.assertEq(
+ "Custom property",
+ newContact.properties.SomethingCustom
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:Notes\r\nN:last;first;;;\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ newContact.properties.vCard
+ );
+
+ // Changing the UID should throw.
+ try {
+ await browser.contacts.update(newContactId, {
+ vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:SomethingNew\r\nEND:VCARD\r\n`,
+ });
+ browser.test.fail(
+ `Updating a contact with a vCard with a differnt UID should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `The card's UID ${newContactId} may not be changed: BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:SomethingNew\r\nEND:VCARD\r\n.`,
+ ex.message,
+ `browser.contacts.update threw exception`
+ );
+ }
+
+ // Test Custom1.
+ {
+ await browser.contacts.update(newContactId, {
+ vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:Notes\r\nN:last;first;;;\r\nX-CUSTOM1;VALUE=TEXT:Original custom value\r\nEND:VCARD`,
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ Custom1: { oldValue: null, newValue: "Original custom value" },
+ },
+ ]);
+ let updContact1 = await browser.contacts.get(newContactId);
+ browser.test.assertEq(
+ "Original custom value",
+ updContact1.properties.Custom1
+ );
+
+ await browser.contacts.update(newContactId, {
+ Custom1: "Updated custom value",
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ Custom1: {
+ oldValue: "Original custom value",
+ newValue: "Updated custom value",
+ },
+ },
+ ]);
+ let updContact2 = await browser.contacts.get(newContactId);
+ browser.test.assertEq(
+ "Updated custom value",
+ updContact2.properties.Custom1
+ );
+ browser.test.assertTrue(
+ updContact2.properties.vCard.includes(
+ "X-CUSTOM1;VALUE=TEXT:Updated custom value"
+ ),
+ "vCard should include the correct x-custom1 entry"
+ );
+ }
+
+ // If a vCard and legacy properties are given, vCard must win.
+ await browser.contacts.update(newContactId, {
+ vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ FirstName: "Superman",
+ PrimaryEmail: "c.kent@dailyplanet.com",
+ PreferDisplayName: "0",
+ OtherCustom: "Yet another custom property",
+ Notes: "Ignored Notes",
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ PrimaryEmail: { oldValue: null, newValue: "first@last" },
+ LastName: { oldValue: "last", newValue: null },
+ OtherCustom: {
+ oldValue: null,
+ newValue: "Yet another custom property",
+ },
+ PreferDisplayName: { oldValue: null, newValue: "0" },
+ Custom1: { oldValue: "Updated custom value", newValue: null },
+ },
+ ]);
+
+ let updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(6, Object.keys(updatedContact.properties).length);
+ browser.test.assertEq("first", updatedContact.properties.FirstName);
+ browser.test.assertEq(
+ "first@last",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertTrue(!("LastName" in updatedContact.properties));
+ browser.test.assertTrue(
+ !("Notes" in updatedContact.properties),
+ "The vCard is not specifying Notes and the specified Notes property should be ignored."
+ );
+ browser.test.assertEq(
+ "Custom property",
+ updatedContact.properties.SomethingCustom,
+ "Untouched custom properties should not be changed by updating the vCard"
+ );
+ browser.test.assertEq(
+ "Yet another custom property",
+ updatedContact.properties.OtherCustom,
+ "Custom properties should be added even while updating a vCard"
+ );
+ browser.test.assertEq(
+ "0",
+ updatedContact.properties.PreferDisplayName,
+ "Setting non-banished properties parallel to a vCard should update"
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Manually Remove properties.
+ await browser.contacts.update(newContactId, {
+ LastName: "lastname",
+ PrimaryEmail: null,
+ SecondEmail: "test@invalid.de",
+ SomethingCustom: null,
+ OtherCustom: null,
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ LastName: { oldValue: null, newValue: "lastname" },
+ // It is how it is. Defining a 2nd email with no 1st, will make it the first.
+ PrimaryEmail: { oldValue: "first@last", newValue: "test@invalid.de" },
+ SomethingCustom: { oldValue: "Custom property", newValue: null },
+ OtherCustom: {
+ oldValue: "Yet another custom property",
+ newValue: null,
+ },
+ },
+ ]);
+
+ updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(5, Object.keys(updatedContact.properties).length);
+ // LastName and FirstName are stored in the same multi field property and changing LastName should not change FirstName.
+ browser.test.assertEq("first", updatedContact.properties.FirstName);
+ browser.test.assertEq("lastname", updatedContact.properties.LastName);
+ browser.test.assertEq(
+ "test@invalid.de",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertTrue(
+ !("SomethingCustom" in updatedContact.properties)
+ );
+ browser.test.assertTrue(!("OtherCustom" in updatedContact.properties));
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;first;;;\r\nEMAIL:test@invalid.de\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Add an email address, going from 1 to 2.Also remove FirstName, LastName should stay.
+ await browser.contacts.update(newContactId, {
+ FirstName: null,
+ PrimaryEmail: "new1@invalid.de",
+ SecondEmail: "new2@invalid.de",
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ PrimaryEmail: {
+ oldValue: "test@invalid.de",
+ newValue: "new1@invalid.de",
+ },
+ SecondEmail: { oldValue: null, newValue: "new2@invalid.de" },
+ FirstName: { oldValue: "first", newValue: null },
+ },
+ ]);
+
+ updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(5, Object.keys(updatedContact.properties).length);
+ browser.test.assertEq("lastname", updatedContact.properties.LastName);
+ browser.test.assertEq(
+ "new1@invalid.de",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertEq(
+ "new2@invalid.de",
+ updatedContact.properties.SecondEmail
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;;;;\r\nEMAIL;PREF=1:new1@invalid.de\r\nUID:${newContactId}\r\nEMAIL:new2@invalid.de\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Remove and email address, going from 2 to 1.
+ await browser.contacts.update(newContactId, {
+ SecondEmail: null,
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ SecondEmail: { oldValue: "new2@invalid.de", newValue: null },
+ },
+ ]);
+
+ updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(4, Object.keys(updatedContact.properties).length);
+ browser.test.assertEq("lastname", updatedContact.properties.LastName);
+ browser.test.assertEq(
+ "new1@invalid.de",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;;;;\r\nEMAIL;PREF=1:new1@invalid.de\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Set a fixed UID.
+ let fixedContactId = await browser.contacts.create(
+ firstBookId,
+ "this is a test",
+ {
+ FirstName: "a",
+ LastName: "test",
+ }
+ );
+ browser.test.assertEq("this is a test", fixedContactId);
+ await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: firstBookId, id: "this is a test" },
+ ]);
+
+ let fixedContact = await browser.contacts.get("this is a test");
+ browser.test.assertEq("this is a test", fixedContact.id);
+
+ await browser.contacts.delete("this is a test");
+ await checkEvents([
+ "contacts",
+ "onDeleted",
+ firstBookId,
+ "this is a test",
+ ]);
+
+ try {
+ await browser.contacts.create(firstBookId, newContactId, {
+ FirstName: "uh",
+ LastName: "oh",
+ });
+ browser.test.fail(`Adding a contact with a duplicate id should throw`);
+ } catch (ex) {
+ browser.test.assertEq(
+ `Duplicate contact id: ${newContactId}`,
+ ex.message,
+ `browser.contacts.create threw exception`
+ );
+ }
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed contactsTest");
+ }
+
+ async function mailingListsTest() {
+ browser.test.log("Starting mailingListsTest");
+ let mailingLists = await browser.mailingLists.list(firstBookId);
+ browser.test.assertTrue(Array.isArray(mailingLists));
+ browser.test.assertEq(0, mailingLists.length);
+
+ let newMailingListId = await browser.mailingLists.create(firstBookId, {
+ name: "name",
+ });
+ browser.test.assertEq(36, newMailingListId.length);
+ await checkEvents([
+ "mailingLists",
+ "onCreated",
+ { type: "mailingList", parentId: firstBookId, id: newMailingListId },
+ ]);
+
+ mailingLists = await browser.mailingLists.list(firstBookId);
+ browser.test.assertEq(
+ 1,
+ mailingLists.length,
+ "List added to first book."
+ );
+
+ mailingLists = await browser.mailingLists.list(secondBookId);
+ browser.test.assertEq(
+ 0,
+ mailingLists.length,
+ "List not added to second book."
+ );
+
+ let newAddressList = await browser.mailingLists.get(newMailingListId);
+ browser.test.assertEq(8, Object.keys(newAddressList).length);
+ browser.test.assertEq(newMailingListId, newAddressList.id);
+ browser.test.assertEq(firstBookId, newAddressList.parentId);
+ browser.test.assertEq("mailingList", newAddressList.type);
+ browser.test.assertEq("name", newAddressList.name);
+ browser.test.assertEq("", newAddressList.nickName);
+ browser.test.assertEq("", newAddressList.description);
+ browser.test.assertEq(false, newAddressList.readOnly);
+ browser.test.assertEq(false, newAddressList.remote);
+
+ // Test that a valid name is ensured for an existing mail list
+ await browser.test.assertRejects(
+ browser.mailingLists.update(newMailingListId, {
+ name: "",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.update(newMailingListId, {
+ name: "Two spaces invalid name",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.update(newMailingListId, {
+ name: "><<<",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.mailingLists.update(newMailingListId, {
+ name: "name!",
+ nickName: "nickname!",
+ description: "description!",
+ });
+ await checkEvents([
+ "mailingLists",
+ "onUpdated",
+ { type: "mailingList", parentId: firstBookId, id: newMailingListId },
+ ]);
+
+ let updatedMailingList = await browser.mailingLists.get(newMailingListId);
+ browser.test.assertEq("name!", updatedMailingList.name);
+ browser.test.assertEq("nickname!", updatedMailingList.nickName);
+ browser.test.assertEq("description!", updatedMailingList.description);
+
+ await browser.mailingLists.addMember(newMailingListId, newContactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberAdded",
+ { type: "contact", parentId: newMailingListId, id: newContactId },
+ ]);
+
+ let listMembers = await browser.mailingLists.listMembers(
+ newMailingListId
+ );
+ browser.test.assertTrue(Array.isArray(listMembers));
+ browser.test.assertEq(1, listMembers.length);
+
+ let anotherContactId = await browser.contacts.create(firstBookId, {
+ FirstName: "second",
+ LastName: "last",
+ PrimaryEmail: "em@il",
+ });
+ await checkEvents([
+ "contacts",
+ "onCreated",
+ {
+ type: "contact",
+ parentId: firstBookId,
+ id: anotherContactId,
+ readOnly: false,
+ },
+ ]);
+
+ await browser.mailingLists.addMember(newMailingListId, anotherContactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberAdded",
+ { type: "contact", parentId: newMailingListId, id: anotherContactId },
+ ]);
+
+ listMembers = await browser.mailingLists.listMembers(newMailingListId);
+ browser.test.assertEq(2, listMembers.length);
+
+ await browser.contacts.delete(anotherContactId);
+ await checkEvents(
+ ["contacts", "onDeleted", firstBookId, anotherContactId],
+ ["mailingLists", "onMemberRemoved", newMailingListId, anotherContactId]
+ );
+ listMembers = await browser.mailingLists.listMembers(newMailingListId);
+ browser.test.assertEq(1, listMembers.length);
+
+ await browser.mailingLists.removeMember(newMailingListId, newContactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberRemoved",
+ newMailingListId,
+ newContactId,
+ ]);
+ listMembers = await browser.mailingLists.listMembers(newMailingListId);
+ browser.test.assertEq(0, listMembers.length);
+
+ await browser.mailingLists.delete(newMailingListId);
+ await checkEvents([
+ "mailingLists",
+ "onDeleted",
+ firstBookId,
+ newMailingListId,
+ ]);
+
+ mailingLists = await browser.mailingLists.list(firstBookId);
+ browser.test.assertEq(0, mailingLists.length);
+
+ for (let operation of [
+ "get",
+ "update",
+ "delete",
+ "listMembers",
+ "addMember",
+ "removeMember",
+ ]) {
+ let args = [newMailingListId];
+ switch (operation) {
+ case "update":
+ args.push({ name: "" });
+ break;
+ case "addMember":
+ case "removeMember":
+ args.push(newContactId);
+ break;
+ }
+
+ try {
+ await browser.mailingLists[operation].apply(
+ browser.mailingLists,
+ args
+ );
+ browser.test.fail(
+ `Calling ${operation} on a non-existent mailing list should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `mailingList with id=${newMailingListId} could not be found.`,
+ ex.message,
+ `browser.mailingLists.${operation} threw exception`
+ );
+ }
+ }
+
+ // Test that a valid name is ensured for a new mail list
+ await browser.test.assertRejects(
+ browser.mailingLists.create(firstBookId, {
+ name: "",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.create(firstBookId, {
+ name: "Two spaces invalid name",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.create(firstBookId, {
+ name: "><<<",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed mailingListsTest");
+ }
+
+ async function contactRemovalTest() {
+ browser.test.log("Starting contactRemovalTest");
+ await browser.contacts.delete(newContactId);
+ await checkEvents(["contacts", "onDeleted", firstBookId, newContactId]);
+
+ for (let operation of ["get", "update", "delete"]) {
+ let args = [newContactId];
+ if (operation == "update") {
+ args.push({});
+ }
+
+ try {
+ await browser.contacts[operation].apply(browser.contacts, args);
+ browser.test.fail(
+ `Calling ${operation} on a non-existent contact should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `contact with id=${newContactId} could not be found.`,
+ ex.message,
+ `browser.contacts.${operation} threw exception`
+ );
+ }
+ }
+
+ let contacts = await browser.contacts.list(firstBookId);
+ browser.test.assertEq(0, contacts.length);
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed contactRemovalTest");
+ }
+
+ async function outsideEventsTest() {
+ browser.test.log("Starting outsideEventsTest");
+ let [bookId, newBookPrefId] = await outsideEvent("createAddressBook");
+ let [newBook] = await checkEvents([
+ "addressBooks",
+ "onCreated",
+ { type: "addressBook", id: bookId },
+ ]);
+ browser.test.assertEq("external add", newBook.name);
+
+ await outsideEvent("updateAddressBook", newBookPrefId);
+ let [updatedBook] = await checkEvents([
+ "addressBooks",
+ "onUpdated",
+ { type: "addressBook", id: bookId },
+ ]);
+ browser.test.assertEq("external edit", updatedBook.name);
+
+ await outsideEvent("deleteAddressBook", newBookPrefId);
+ await checkEvents(["addressBooks", "onDeleted", bookId]);
+
+ let [parentId1, contactId] = await outsideEvent("createContact");
+ let [newContact] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId1, id: contactId },
+ ]);
+ browser.test.assertEq("external", newContact.properties.FirstName);
+ browser.test.assertEq("add", newContact.properties.LastName);
+ browser.test.assertTrue(
+ newContact.properties.vCard.includes("VERSION:4.0"),
+ "vCard should be version 4.0"
+ );
+
+ // Update the contact from outside.
+ await outsideEvent("updateContact", contactId);
+ let [updatedContact] = await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: parentId1, id: contactId },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ]);
+ browser.test.assertEq("external", updatedContact.properties.FirstName);
+ browser.test.assertEq("edit", updatedContact.properties.LastName);
+
+ let [parentId2, listId] = await outsideEvent("createMailingList");
+ let [newList] = await checkEvents([
+ "mailingLists",
+ "onCreated",
+ { type: "mailingList", parentId: parentId2, id: listId },
+ ]);
+ browser.test.assertEq("external add", newList.name);
+
+ await outsideEvent("updateMailingList", listId);
+ let [updatedList] = await checkEvents([
+ "mailingLists",
+ "onUpdated",
+ { type: "mailingList", parentId: parentId2, id: listId },
+ ]);
+ browser.test.assertEq("external edit", updatedList.name);
+
+ await outsideEvent("addMailingListMember", listId, contactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberAdded",
+ { type: "contact", parentId: listId, id: contactId },
+ ]);
+ let listMembers = await browser.mailingLists.listMembers(listId);
+ browser.test.assertEq(1, listMembers.length);
+
+ await outsideEvent("removeMailingListMember", listId, contactId);
+ await checkEvents(["mailingLists", "onMemberRemoved", listId, contactId]);
+
+ await outsideEvent("deleteMailingList", listId);
+ await checkEvents(["mailingLists", "onDeleted", parentId2, listId]);
+
+ await outsideEvent("deleteContact", contactId);
+ await checkEvents(["contacts", "onDeleted", parentId1, contactId]);
+
+ browser.test.log("Completed outsideEventsTest");
+ }
+
+ await addressBookTest();
+ await contactsTest();
+ await mailingListsTest();
+ await contactRemovalTest();
+ await outsideEventsTest();
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let parent = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ function findContact(id) {
+ for (let child of parent.childCards) {
+ if (child.UID == id) {
+ return child;
+ }
+ }
+ return null;
+ }
+ function findMailingList(id) {
+ for (let list of parent.childNodes) {
+ if (list.UID == id) {
+ return list;
+ }
+ }
+ return null;
+ }
+
+ extension.onMessage("outsideEventsTest", async (action, ...args) => {
+ switch (action) {
+ case "createAddressBook": {
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "external add",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ extension.sendMessage(book.UID, dirPrefId);
+ return;
+ }
+ case "updateAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ book.dirName = "external edit";
+ extension.sendMessage();
+ return;
+ }
+ case "deleteAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ MailServices.ab.deleteAddressBook(book.URI);
+ extension.sendMessage();
+ return;
+ }
+ case "createContact": {
+ let contact = new AddrBookCard();
+ contact.firstName = "external";
+ contact.lastName = "add";
+ contact.primaryEmail = "test@invalid";
+
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID);
+ return;
+ }
+ case "updateContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.firstName = "external";
+ contact.lastName = "edit";
+ parent.modifyCard(contact);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "deleteContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ parent.deleteCards([contact]);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "createMailingList": {
+ let list = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ list.isMailList = true;
+ list.dirName = "external add";
+
+ let newList = parent.addMailList(list);
+ extension.sendMessage(parent.UID, newList.UID);
+ return;
+ }
+ case "updateMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ list.dirName = "external edit";
+ list.editMailListToDatabase(null);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "deleteMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ parent.deleteDirectory(list);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "addMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.addCard(contact);
+ equal(1, list.childCards.length);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "removeMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.deleteCards([contact]);
+ equal(0, list.childCards.length);
+ ok(findContact(args[1]), "Contact was not removed");
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ }
+ throw new Error(
+ `Message "${action}" passed to handler didn't do anything.`
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
+
+add_task(async function test_addressBooks_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ // Create and register event listener.
+ for (let event of [
+ "addressBooks.onCreated",
+ "addressBooks.onUpdated",
+ "addressBooks.onDeleted",
+ "contacts.onCreated",
+ "contacts.onUpdated",
+ "contacts.onDeleted",
+ "mailingLists.onCreated",
+ "mailingLists.onUpdated",
+ "mailingLists.onDeleted",
+ "mailingLists.onMemberAdded",
+ "mailingLists.onMemberRemoved",
+ ]) {
+ let [apiName, eventName] = event.split(".");
+ browser[apiName][eventName].addListener((...args) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${apiName}.${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ browser_specific_settings: { gecko: { id: "addressbook@xpcshell.test" } },
+ },
+ });
+
+ let parent = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ function findContact(id) {
+ for (let child of parent.childCards) {
+ if (child.UID == id) {
+ return child;
+ }
+ }
+ return null;
+ }
+ function findMailingList(id) {
+ for (let list of parent.childNodes) {
+ if (list.UID == id) {
+ return list;
+ }
+ }
+ return null;
+ }
+ function outsideEvent(action, ...args) {
+ switch (action) {
+ case "createAddressBook": {
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "external add",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ return [book, dirPrefId];
+ }
+ case "updateAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ book.dirName = "external edit";
+ return [];
+ }
+ case "deleteAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ MailServices.ab.deleteAddressBook(book.URI);
+ return [];
+ }
+ case "createContact": {
+ let contact = new AddrBookCard();
+ contact.firstName = "external";
+ contact.lastName = "add";
+ contact.primaryEmail = "test@invalid";
+
+ let newContact = parent.addCard(contact);
+ return [parent.UID, newContact.UID];
+ }
+ case "updateContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.firstName = "external";
+ contact.lastName = "edit";
+ parent.modifyCard(contact);
+ return [];
+ }
+ break;
+ }
+ case "deleteContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ parent.deleteCards([contact]);
+ return [];
+ }
+ break;
+ }
+ case "createMailingList": {
+ let list = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ list.isMailList = true;
+ list.dirName = "external add";
+
+ let newList = parent.addMailList(list);
+ return [parent.UID, newList.UID];
+ }
+ case "updateMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ list.dirName = "external edit";
+ list.editMailListToDatabase(null);
+ return [];
+ }
+ break;
+ }
+ case "deleteMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ parent.deleteDirectory(list);
+ return [];
+ }
+ break;
+ }
+ case "addMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.addCard(contact);
+ equal(1, list.childCards.length);
+ return [];
+ }
+ break;
+ }
+ case "removeMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.deleteCards([contact]);
+ equal(0, list.childCards.length);
+ ok(findContact(args[1]), "Contact was not removed");
+ return [];
+ }
+ break;
+ }
+ }
+ throw new Error(
+ `Message "${action}" passed to handler didn't do anything.`
+ );
+ }
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "addressBook.onAddressBookCreated",
+ "addressBook.onAddressBookUpdated",
+ "addressBook.onAddressBookDeleted",
+ "addressBook.onContactCreated",
+ "addressBook.onContactUpdated",
+ "addressBook.onContactDeleted",
+ "addressBook.onMailingListCreated",
+ "addressBook.onMailingListUpdated",
+ "addressBook.onMailingListDeleted",
+ "addressBook.onMemberAdded",
+ "addressBook.onMemberRemoved",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ checkPersistentListeners({ primed: false });
+
+ // addressBooks.onCreated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ let [newBook, dirPrefId] = outsideEvent("createAddressBook");
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ id: newBook.UID,
+ type: "addressBook",
+ name: "external add",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("addressBooks.onCreated received"),
+ "The primed addressBooks.onCreated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // addressBooks.onUpdated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("updateAddressBook", dirPrefId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ id: newBook.UID,
+ type: "addressBook",
+ name: "external edit",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("addressBooks.onUpdated received"),
+ "The primed addressBooks.onUpdated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // addressBooks.onDeleted.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("deleteAddressBook", dirPrefId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [newBook.UID],
+ await extension.awaitMessage("addressBooks.onDeleted received"),
+ "The primed addressBooks.onDeleted event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // contacts.onCreated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ let [parentId1, contactId] = outsideEvent("createContact");
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ let [createdNode] = await extension.awaitMessage(
+ "contacts.onCreated received"
+ );
+ Assert.deepEqual(
+ {
+ type: "contact",
+ parentId: parentId1,
+ id: contactId,
+ },
+ {
+ type: createdNode.type,
+ parentId: createdNode.parentId,
+ id: createdNode.id,
+ },
+ "The primed contacts.onCreated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // contacts.onUpdated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("updateContact", contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ let [updatedNode, changedProperties] = await extension.awaitMessage(
+ "contacts.onUpdated received"
+ );
+ Assert.deepEqual(
+ [
+ { type: "contact", parentId: parentId1, id: contactId },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ],
+ [
+ {
+ type: updatedNode.type,
+ parentId: updatedNode.parentId,
+ id: updatedNode.id,
+ },
+ changedProperties,
+ ],
+ "The primed contacts.onUpdated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingLists.onCreated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ let [parentId2, listId] = outsideEvent("createMailingList");
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ type: "mailingList",
+ parentId: parentId2,
+ id: listId,
+ name: "external add",
+ nickName: "",
+ description: "",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("mailingLists.onCreated received"),
+ "The primed mailingLists.onCreated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onUpdated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("updateMailingList", listId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ type: "mailingList",
+ parentId: parentId2,
+ id: listId,
+ name: "external edit",
+ nickName: "",
+ description: "",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("mailingLists.onUpdated received"),
+ "The primed mailingLists.onUpdated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onMemberAdded.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("addMailingListMember", listId, contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ let [addedNode] = await extension.awaitMessage(
+ "mailingLists.onMemberAdded received"
+ );
+ Assert.deepEqual(
+ { type: "contact", parentId: listId, id: contactId },
+ { type: addedNode.type, parentId: addedNode.parentId, id: addedNode.id },
+ "The primed mailingLists.onMemberAdded event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onMemberRemoved.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("removeMailingListMember", listId, contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [listId, contactId],
+ await extension.awaitMessage("mailingLists.onMemberRemoved received"),
+ "The primed mailingLists.onMemberRemoved event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onDeleted.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("deleteMailingList", listId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [parentId2, listId],
+ await extension.awaitMessage("mailingLists.onDeleted received"),
+ "The primed mailingLists.onDeleted event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // contacts.onDeleted.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("deleteContact", contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [parentId1, contactId],
+ await extension.awaitMessage("contacts.onDeleted received"),
+ "The primed contacts.onDeleted event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_photos() {
+ async function background() {
+ let events = [];
+ let eventPromise;
+ let eventPromiseResolve;
+ for (let eventNamespace of ["addressBooks", "contacts"]) {
+ for (let eventName of ["onCreated", "onUpdated", "onDeleted"]) {
+ if (eventName in browser[eventNamespace]) {
+ browser[eventNamespace][eventName].addListener((...args) => {
+ events.push({ namespace: eventNamespace, name: eventName, args });
+ if (eventPromiseResolve) {
+ let resolve = eventPromiseResolve;
+ eventPromiseResolve = null;
+ resolve();
+ }
+ });
+ }
+ }
+ }
+
+ let getDataUrl = function (file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = function () {
+ resolve(reader.result);
+ };
+ reader.onerror = function (error) {
+ reject(new Error(error));
+ };
+ });
+ };
+
+ let updateAndVerifyPhoto = async function (
+ parentId,
+ id,
+ photoFile,
+ photoData
+ ) {
+ eventPromise = new Promise(resolve => {
+ eventPromiseResolve = resolve;
+ });
+ await browser.contacts.setPhoto(id, photoFile);
+
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId, id },
+ {},
+ ]);
+ let updatedPhoto = await browser.contacts.getPhoto(id);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(updatedPhoto instanceof File);
+ browser.test.assertEq("image/png", updatedPhoto.type);
+ browser.test.assertEq(`${id}.png`, updatedPhoto.name);
+ browser.test.assertEq(photoData, await getDataUrl(updatedPhoto));
+ };
+ let normalizeVCard = function (vCard) {
+ return vCard
+ .replaceAll("\r\n", "")
+ .replaceAll("\n", "")
+ .replaceAll(" ", "");
+ };
+ let outsideEvent = function (action, ...args) {
+ eventPromise = new Promise(resolve => {
+ eventPromiseResolve = resolve;
+ });
+ return window.sendMessage("outsideEventsTest", action, ...args);
+ };
+ let checkEvents = async function (...expectedEvents) {
+ if (eventPromiseResolve) {
+ await eventPromise;
+ }
+
+ browser.test.assertEq(
+ expectedEvents.length,
+ events.length,
+ "Correct number of events"
+ );
+
+ if (expectedEvents.length != events.length) {
+ for (let event of events) {
+ let args = event.args.join(", ");
+ browser.test.log(`${event.namespace}.${event.name}(${args})`);
+ }
+ throw new Error("Wrong number of events, stopping.");
+ }
+
+ for (let [namespace, name, ...expectedArgs] of expectedEvents) {
+ let event = events.shift();
+ browser.test.assertEq(
+ namespace,
+ event.namespace,
+ "Event namespace is correct"
+ );
+ browser.test.assertEq(name, event.name, "Event type is correct");
+ browser.test.assertEq(
+ expectedArgs.length,
+ event.args.length,
+ "Argument count is correct"
+ );
+ window.assertDeepEqual(expectedArgs, event.args);
+ if (expectedEvents.length == 1) {
+ return event.args;
+ }
+ }
+
+ return null;
+ };
+
+ let whitePixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC";
+ let bluePixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==";
+ let greenPixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY5CeYAMAAbEA6ASxSWcAAAAASUVORK5CYII=";
+ let redPixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY3growIAAycBLhVrvukAAAAASUVORK5CYII=";
+ let vCard3WhitePixel =
+ "PHOTO;ENCODING=B;TYPE=PNG:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC";
+ let vCard4WhitePixel =
+ "PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC";
+ let vCard4BluePixel =
+ "PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==";
+
+ // Create a photo file, which is linked to a local file to simulate a file
+ // opened through a filepicker.
+ let [redPixelRealFile] = await window.sendMessage("getRedPixelFile");
+
+ // Create a photo file, which is a simple data blob.
+ let greenPixelFile = await fetch(greenPixelData)
+ .then(res => res.arrayBuffer())
+ .then(buf => new File([buf], "greenPixel.png", { type: "image/png" }));
+
+ // -------------------------------------------------------------------------
+ // Test vCard v4 with a photoName set.
+ // -------------------------------------------------------------------------
+
+ let [parentId1, contactId1, photoName1] = await outsideEvent(
+ "createV4ContactWithPhotoName"
+ );
+ let [newContact] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId1, id: contactId1 },
+ ]);
+ browser.test.assertEq("external", newContact.properties.FirstName);
+ browser.test.assertEq("add", newContact.properties.LastName);
+ browser.test.assertTrue(
+ newContact.properties.vCard.includes("VERSION:4.0"),
+ "vCard should be version 4.0"
+ );
+ browser.test.assertTrue(
+ normalizeVCard(newContact.properties.vCard).includes(vCard4WhitePixel),
+ `vCard should include the correct Photo property [${normalizeVCard(
+ newContact.properties.vCard
+ )}] vs [${vCard4WhitePixel}]`
+ );
+ // Check internal photoUrl is the correct fileUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${photoName1}$`
+ );
+
+ // Test if we can get the photo through the API.
+
+ let photo = await browser.contacts.getPhoto(contactId1);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(photo instanceof File);
+ browser.test.assertEq("image/png", photo.type);
+ browser.test.assertEq(`${contactId1}.png`, photo.name);
+ browser.test.assertEq(
+ whitePixelData,
+ await getDataUrl(photo),
+ "vCard 4.0 contact with photo from internal fileUrl from photoName should return the correct photo file"
+ );
+ // Re-check internal photoUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${photoName1}$`
+ );
+
+ // Test if we can update the photo through the API by providing a file which
+ // is linked to a local file. Since this vCard had only a photoName set and
+ // its photo stored as a local file, the updated photo should also be stored
+ // as a local file.
+
+ await updateAndVerifyPhoto(
+ parentId1,
+ contactId1,
+ redPixelRealFile,
+ redPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${contactId1}\.png$`
+ );
+
+ // Test if we can update the photo through the API, by providing a pure data
+ // blob (decoupled from a local file, without file.mozFullPath set).
+
+ await updateAndVerifyPhoto(
+ parentId1,
+ contactId1,
+ greenPixelFile,
+ greenPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${contactId1}-1\.png$`
+ );
+
+ // Test if we get the correct photo if it is updated by the user, storing the
+ // photo in its vCard (outside of the API).
+
+ await outsideEvent("updateV4ContactWithBluePixel", contactId1);
+ let [updatedContact1] = await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: parentId1, id: contactId1 },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ]);
+ browser.test.assertEq("external", updatedContact1.properties.FirstName);
+ browser.test.assertEq("edit", updatedContact1.properties.LastName);
+ let updatedPhoto1 = await browser.contacts.getPhoto(contactId1);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(updatedPhoto1 instanceof File);
+ browser.test.assertEq("image/png", updatedPhoto1.type);
+ browser.test.assertEq(`${contactId1}.png`, updatedPhoto1.name);
+ browser.test.assertEq(bluePixelData, await getDataUrl(updatedPhoto1));
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ bluePixelData
+ );
+
+ // -------------------------------------------------------------------------
+ // Test vCard v4 with a photoName and also a photo in its vCard.
+ // -------------------------------------------------------------------------
+
+ let [parentId2, contactId2] = await outsideEvent(
+ "createV4ContactWithBothPhotoProps"
+ );
+ let [newContact2] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId2, id: contactId2 },
+ ]);
+ browser.test.assertEq("external", newContact2.properties.FirstName);
+ browser.test.assertEq("add", newContact2.properties.LastName);
+ browser.test.assertTrue(
+ newContact2.properties.vCard.includes("VERSION:4.0"),
+ "vCard should be version 4.0"
+ );
+ // The card should not include vCard4WhitePixel (which photoName points to),
+ // but the value of vCard4BluePixel stored in the vCard photo property.
+ browser.test.assertTrue(
+ normalizeVCard(newContact2.properties.vCard).includes(vCard4BluePixel),
+ `vCard should include the correct Photo property [${normalizeVCard(
+ newContact2.properties.vCard
+ )}] vs [${vCard4BluePixel}]`
+ );
+ // Check internal photoUrl is the correct dataUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ bluePixelData
+ );
+
+ // Test if we can get the correct photo through the API.
+
+ let photo3 = await browser.contacts.getPhoto(contactId2);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(photo3 instanceof File);
+ browser.test.assertEq("image/png", photo3.type);
+ browser.test.assertEq(`${contactId2}.png`, photo3.name);
+ browser.test.assertEq(
+ bluePixelData,
+ await getDataUrl(photo3),
+ "vCard 4.0 contact with photo from internal dataUrl from vCard (vCard wins over photoName) should return the correct photo file"
+ );
+ // Re-check internal photoUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ bluePixelData
+ );
+
+ // Test if we can update the photo through the API by providing a file which
+ // is linked to a local file. Since this vCard had its photo stored as dataUrl
+ // in the vCard, the updated photo should be stored as a dataUrl as well.
+
+ await updateAndVerifyPhoto(
+ parentId2,
+ contactId2,
+ redPixelRealFile,
+ redPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ redPixelData
+ );
+
+ // Test if we can update the photo through the API, by providing a pure data
+ // blob (decoupled from a local file, without file.mozFullPath set).
+
+ await updateAndVerifyPhoto(
+ parentId2,
+ contactId2,
+ greenPixelFile,
+ greenPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ greenPixelData
+ );
+
+ // -------------------------------------------------------------------------
+ // Test vCard v3 with a photoName set.
+ // -------------------------------------------------------------------------
+
+ let [parentId3, contactId3, photoName4] = await outsideEvent(
+ "createV3ContactWithPhotoName"
+ );
+ let [newContact4] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId3, id: contactId3 },
+ ]);
+ browser.test.assertEq("external", newContact4.properties.FirstName);
+ browser.test.assertEq("add", newContact4.properties.LastName);
+ browser.test.assertTrue(
+ newContact4.properties.vCard.includes("VERSION:3.0"),
+ "vCard should be version 3.0"
+ );
+ browser.test.assertTrue(
+ normalizeVCard(newContact4.properties.vCard).includes(vCard3WhitePixel),
+ `vCard should include the correct Photo property [${normalizeVCard(
+ newContact4.properties.vCard
+ )}] vs [${vCard3WhitePixel}]`
+ );
+ // Check internal photoUrl is the correct fileUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${photoName4}$`
+ );
+ let photo4 = await browser.contacts.getPhoto(contactId3);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(photo4 instanceof File);
+ browser.test.assertEq("image/png", photo4.type);
+ browser.test.assertEq(`${contactId3}.png`, photo4.name);
+ browser.test.assertEq(
+ whitePixelData,
+ await getDataUrl(photo4),
+ "vCard 3.0 contact with photo from internal fileUrl from photoName should return the correct photo file"
+ );
+ // Re-check internal photoUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${photoName4}$`
+ );
+
+ // Test if we can update the photo through the API by providing a file which
+ // is linked to a local file. Since this vCard had only a photoName set and
+ // its photo stored as a local file, the updated photo should also be stored
+ // as a local file.
+
+ await updateAndVerifyPhoto(
+ parentId3,
+ contactId3,
+ redPixelRealFile,
+ redPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${contactId3}\.png$`
+ );
+
+ // Test if we can update the photo through the API, by providing a pure data
+ // blob (decoupled from a local file, without file.mozFullPath set).
+
+ await updateAndVerifyPhoto(
+ parentId3,
+ contactId3,
+ greenPixelFile,
+ greenPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${contactId3}-1\.png$`
+ );
+
+ // Test if we get the correct photo if it is updated by the user, storing the
+ // photo in its vCard (outside of the API).
+
+ await outsideEvent("updateV3ContactWithBluePixel", contactId3);
+ let [updatedContact3] = await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: parentId3, id: contactId3 },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ]);
+ browser.test.assertEq("external", updatedContact3.properties.FirstName);
+ browser.test.assertEq("edit", updatedContact3.properties.LastName);
+ let updatedPhoto3 = await browser.contacts.getPhoto(contactId3);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(updatedPhoto3 instanceof File);
+ browser.test.assertEq("image/png", updatedPhoto3.type);
+ browser.test.assertEq(`${contactId3}.png`, updatedPhoto3.name);
+ browser.test.assertEq(bluePixelData, await getDataUrl(updatedPhoto3));
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ bluePixelData
+ );
+
+ // Cleanup. Delete all created contacts.
+
+ await outsideEvent("deleteContact", contactId1);
+ await checkEvents(["contacts", "onDeleted", parentId1, contactId1]);
+ await outsideEvent("deleteContact", contactId2);
+ await checkEvents(["contacts", "onDeleted", parentId2, contactId2]);
+ await outsideEvent("deleteContact", contactId3);
+ await checkEvents(["contacts", "onDeleted", parentId3, contactId3]);
+ browser.test.notifyPass("addressBooksPhotos");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let parent = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ function findContact(id) {
+ for (let child of parent.childCards) {
+ if (child.UID == id) {
+ return child;
+ }
+ }
+ return null;
+ }
+
+ async function getUniqueWhitePixelFile() {
+ // Copy photo file into the required Photos subfolder of the profile folder.
+ let photoName = `${AddrBookUtils.newUID()}.png`;
+ await IOUtils.copy(
+ do_get_file("images/whitePixel.png").path,
+ PathUtils.join(PathUtils.profileDir, "Photos", photoName)
+ );
+ return photoName;
+ }
+
+ extension.onMessage("getRedPixelFile", async () => {
+ let redPixelFile = await File.createFromNsIFile(
+ do_get_file("images/redPixel.png")
+ );
+ extension.sendMessage(redPixelFile);
+ });
+
+ extension.onMessage("verifyInternalPhotoUrl", (id, expected) => {
+ let contact = findContact(id);
+ let photoUrl = contact.photoURL;
+ if (expected.startsWith("data:")) {
+ Assert.equal(expected, photoUrl, `photoURL should be correct`);
+ } else {
+ let regExp = new RegExp(expected);
+ Assert.ok(
+ regExp.test(photoUrl),
+ `photoURL <${photoUrl}> should match expected regExp <${expected}>`
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("outsideEventsTest", async (action, ...args) => {
+ switch (action) {
+ case "createV4ContactWithPhotoName": {
+ let photoName = await getUniqueWhitePixelFile();
+ let contact = new AddrBookCard();
+ contact.firstName = "external";
+ contact.lastName = "add";
+ contact.primaryEmail = "test@invalid";
+ contact.setProperty("PhotoName", photoName);
+
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID, photoName);
+ return;
+ }
+ case "createV4ContactWithBothPhotoProps": {
+ // This contact has whitePixel as file but bluePixel in the vCard.
+ let photoName = await getUniqueWhitePixelFile();
+ let contact = new AddrBookCard();
+ contact.setProperty("PhotoName", photoName);
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:test@invalid
+ N:add;external;;;
+ UID:fd9aecf9-2453-4ba1-bec6-574a15bb380b
+ PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAA
+ ACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==
+ END:VCARD
+ `
+ );
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID, photoName);
+ return;
+ }
+ case "updateV4ContactWithBluePixel": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:test@invalid
+ N:edit;external;;;
+ PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAA
+ ACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==
+ END:VCARD
+ `
+ );
+ parent.modifyCard(contact);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "createV3ContactWithPhotoName": {
+ let photoName = await getUniqueWhitePixelFile();
+ let contact = new AddrBookCard();
+ contact.setProperty("PhotoName", photoName);
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ EMAIL:test@invalid
+ N:add;external
+ UID:fd9aecf9-2453-4ba1-bec6-574a15bb380c
+ END:VCARD
+ `
+ );
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID, photoName);
+ return;
+ }
+ case "updateV3ContactWithBluePixel": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ EMAIL:test@invalid
+ N:edit;external
+ PHOTO;ENCODING=b;TYPE=PNG:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD
+ ElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==
+ END:VCARD
+ `
+ );
+ parent.modifyCard(contact);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "deleteContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ parent.deleteCards([contact]);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ }
+ throw new Error(
+ `Message "${action}" passed to handler didn't do anything.`
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooksPhotos");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js
new file mode 100644
index 0000000000..a09540dcbe
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let id = "9b9074ff-8fa4-4c58-9c3b-bc9ea2e17db1";
+ let dummy = async (node, searchString, query) => {
+ await browser.test.assertTrue(
+ false,
+ "Should have removed this address book"
+ );
+ };
+ await browser.addressBooks.provider.onSearchRequest.addListener(dummy, {
+ addressBookName: "dummy",
+ isSecure: false,
+ id,
+ });
+ await browser.addressBooks.provider.onSearchRequest.removeListener(dummy);
+ id = "00e1d9af-a846-4ef5-a6ac-15e8926bf6d3";
+ await browser.addressBooks.provider.onSearchRequest.addListener(
+ async (node, searchString, query) => {
+ await browser.test.assertEq(
+ id,
+ node.id,
+ "Addressbook should have the id we requested"
+ );
+ return {
+ results: [
+ {
+ DisplayName: searchString,
+ PrimaryEmail: searchString + "@example.com",
+ },
+ ],
+ isCompleteResult: true,
+ };
+ },
+ {
+ addressBookName: "xpcshell",
+ isSecure: false,
+ id,
+ }
+ );
+ await browser.addressBooks.provider.onSearchRequest.addListener(
+ async (node, searchString, query) => {
+ await browser.test.assertTrue(
+ false,
+ "Should not have created a duplicate address book"
+ );
+ },
+ {
+ addressBookName: "xpcshell",
+ isSecure: false,
+ id,
+ }
+ );
+ },
+ manifest: { permissions: ["addressBooks"] },
+ });
+
+ await extension.startup();
+
+ const dummyUID = "9b9074ff-8fa4-4c58-9c3b-bc9ea2e17db1";
+ let searchBook = MailServices.ab.getDirectoryFromUID(dummyUID);
+ ok(searchBook == null, "Dummy directory was removed by extension");
+
+ const UID = "00e1d9af-a846-4ef5-a6ac-15e8926bf6d3";
+ searchBook = MailServices.ab.getDirectoryFromUID(UID);
+ ok(searchBook != null, "Extension registered an async directory");
+
+ let foundCards = 0;
+ await new Promise(resolve => {
+ searchBook.search(null, "test", {
+ onSearchFoundCard(card) {
+ ok(card != null, "A card was found.");
+ equal(card.directoryUID, UID, "The card comes from the directory.");
+ equal(
+ card.primaryEmail,
+ "test@example.com",
+ "The card has the correct email address."
+ );
+ equal(
+ card.displayName,
+ "test",
+ "The card has the correct display name."
+ );
+ foundCards++;
+ },
+ onSearchFinished(status, isCompleteResult) {
+ ok(Components.isSuccessCode(status), "Search finished successfully.");
+ equal(foundCards, 1, "One card was found.");
+ ok(isCompleteResult, "A full result set was received.");
+ resolve();
+ },
+ });
+ });
+
+ let autoCompleteSearch = Cc[
+ "@mozilla.org/autocomplete/search;1?name=addrbook"
+ ].createInstance(Ci.nsIAutoCompleteSearch);
+ await new Promise(resolve => {
+ autoCompleteSearch.startSearch("test", null, null, {
+ onSearchResult(aSearch, aResult) {
+ equal(aSearch, autoCompleteSearch, "This is our search.");
+ if (aResult.searchResult == Ci.nsIAutoCompleteResult.RESULT_SUCCESS) {
+ equal(aResult.matchCount, 1, "One match was found.");
+ equal(
+ aResult.getValueAt(0),
+ "test <test@example.com>",
+ "The match had the expected value."
+ );
+ resolve();
+ } else {
+ equal(
+ aResult.searchResult,
+ Ci.nsIAutoCompleteResult.RESULT_NOMATCH_ONGOING,
+ "We should be waiting for the extension's results."
+ );
+ }
+ },
+ });
+ });
+
+ await extension.unload();
+ searchBook = MailServices.ab.getDirectoryFromUID(UID);
+ ok(searchBook == null, "Extension directory removed after unload");
+});
+
+registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js
new file mode 100644
index 0000000000..9a6bbd8f4e
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js
@@ -0,0 +1,238 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+add_setup(async () => {
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ registerCleanupFunction(() => {
+ LDAPServer.close();
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+});
+
+add_task(async function test_quickSearch() {
+ async function background() {
+ let book1 = await browser.addressBooks.create({ name: "book1" });
+ let book2 = await browser.addressBooks.create({ name: "book2" });
+
+ let book1contacts = {
+ charlie: await browser.contacts.create(book1, { FirstName: "charlie" }),
+ juliet: await browser.contacts.create(book1, { FirstName: "juliet" }),
+ mike: await browser.contacts.create(book1, { FirstName: "mike" }),
+ oscar: await browser.contacts.create(book1, { FirstName: "oscar" }),
+ papa: await browser.contacts.create(book1, { FirstName: "papa" }),
+ romeo: await browser.contacts.create(book1, { FirstName: "romeo" }),
+ victor: await browser.contacts.create(book1, { FirstName: "victor" }),
+ };
+
+ let book2contacts = {
+ bigBird: await browser.contacts.create(book2, {
+ FirstName: "Big",
+ LastName: "Bird",
+ }),
+ cookieMonster: await browser.contacts.create(book2, {
+ FirstName: "Cookie",
+ LastName: "Monster",
+ }),
+ elmo: await browser.contacts.create(book2, { FirstName: "Elmo" }),
+ grover: await browser.contacts.create(book2, { FirstName: "Grover" }),
+ oscarTheGrouch: await browser.contacts.create(book2, {
+ FirstName: "Oscar",
+ LastName: "The Grouch",
+ }),
+ };
+
+ // A search string without a match in either book.
+ let results = await browser.contacts.quickSearch(book1, "snuffleupagus");
+ browser.test.assertEq(0, results.length);
+
+ // A search string with a match in the book we're searching.
+ results = await browser.contacts.quickSearch(book1, "mike");
+ browser.test.assertEq(1, results.length);
+ browser.test.assertEq(book1contacts.mike, results[0].id);
+
+ // A search string passed via queryInfo
+ results = await browser.contacts.quickSearch(book1, {
+ searchString: "mike",
+ });
+ browser.test.assertEq(1, results.length);
+ browser.test.assertEq(book1contacts.mike, results[0].id);
+
+ // A search string with a match in the book we're not searching.
+ results = await browser.contacts.quickSearch(book1, "elmo");
+ browser.test.assertEq(0, results.length);
+
+ // A search string with a match in both books.
+ results = await browser.contacts.quickSearch(book1, "oscar");
+ browser.test.assertEq(1, results.length);
+ browser.test.assertEq(book1contacts.oscar, results[0].id);
+
+ // A search string with a match in both books. Looking in all books.
+ results = await browser.contacts.quickSearch("oscar");
+ browser.test.assertEq(2, results.length);
+ browser.test.assertEq(book1contacts.oscar, results[0].id);
+ browser.test.assertEq(book2contacts.oscarTheGrouch, results[1].id);
+
+ // No valid search strings.
+ results = await browser.contacts.quickSearch(" ");
+ browser.test.assertEq(0, results.length);
+
+ await browser.addressBooks.delete(book1);
+ await browser.addressBooks.delete(book2);
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: { permissions: ["addressBooks"] },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
+
+add_task(async function test_quickSearch_types() {
+ // If nsIAbLDAPDirectory doesn't exist in our build options, someone has
+ // specified --disable-ldap
+ if (!("nsIAbLDAPDirectory" in Ci)) {
+ return;
+ }
+
+ // Add a card to the personal AB.
+ let personaAB = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.UID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
+ contact.displayName = "personal contact";
+ contact.firstName = "personal";
+ contact.lastName = "contact";
+ contact.primaryEmail = "personal@invalid";
+ contact = personaAB.addCard(contact);
+
+ // Set up the history AB as read-only.
+ let historyAB = MailServices.ab.getDirectory("jsaddrbook://history.sqlite");
+
+ contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.UID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxy";
+ contact.displayName = "history contact";
+ contact.firstName = "history";
+ contact.lastName = "contact";
+ contact.primaryEmail = "history@invalid";
+ contact = historyAB.addCard(contact);
+
+ historyAB.setBoolValue("readOnly", true);
+
+ Assert.ok(historyAB.readOnly);
+
+ // Set up an LDAP address book.
+ LDAPServer.open();
+
+ // Create an LDAP directory
+ MailServices.ab.newAddressBook(
+ "test",
+ `ldap://localhost:${LDAPServer.port}/people??sub?(objectclass=*)`,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ async function background() {
+ function checkCards(cards, expectedNames) {
+ browser.test.assertEq(expectedNames.length, cards.length);
+ let expected = new Set(expectedNames);
+ for (let card of cards) {
+ expected.delete(card.properties.FirstName);
+ }
+ browser.test.assertEq(
+ 0,
+ expected.size,
+ "Should have seen all expected cards"
+ );
+ }
+ // No arguments should get cards from all address books.
+ let results = await browser.contacts.quickSearch("contact");
+ checkCards(results, ["personal", "history", "LDAP"]);
+
+ // An empty argument should get cards from all address books.
+ results = await browser.contacts.quickSearch({ searchString: "contact" });
+ checkCards(results, ["personal", "history", "LDAP"]);
+
+ // Skip remote address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeRemote: false,
+ });
+ checkCards(results, ["personal", "history"]);
+
+ // Skip local address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeLocal: false,
+ });
+ checkCards(results, ["LDAP"]);
+
+ // Skip read-only address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeReadOnly: false,
+ });
+ checkCards(results, ["personal"]);
+
+ // Skip read-write address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeReadWrite: false,
+ });
+ checkCards(results, ["LDAP", "history"]);
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: { permissions: ["addressBooks"] },
+ });
+
+ let startupPromise = extension.startup();
+
+ // This for loop handles returning responses for LDAP. It should run once
+ // for each test that queries the remote address book.
+ for (let i = 0; i < 4; i++) {
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry({
+ dn: "uid=ldap,dc=contact,dc=invalid",
+ attributes: {
+ objectClass: "person",
+ cn: "LDAP contact",
+ givenName: "LDAP",
+ mail: "eurus@contact.invalid",
+ sn: "contact",
+ },
+ });
+ LDAPServer.writeSearchResultDone();
+ }
+
+ await startupPromise;
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js
new file mode 100644
index 0000000000..3b40dc67a2
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_setup(async () => {
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+
+ let historyAB = MailServices.ab.getDirectory("jsaddrbook://history.sqlite");
+
+ let contact1 = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact1.UID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
+ contact1.displayName = "contact number one";
+ contact1.firstName = "contact";
+ contact1.lastName = "one";
+ contact1.primaryEmail = "contact1@invalid";
+ contact1 = historyAB.addCard(contact1);
+
+ let mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = "Mailing";
+ mailList.listNickName = "Mailing";
+ mailList.description = "";
+
+ historyAB.addMailList(mailList);
+ historyAB.setBoolValue("readOnly", true);
+
+ Assert.ok(historyAB.readOnly);
+});
+
+add_task(async function test_addressBooks_readonly() {
+ async function background() {
+ let list = await browser.addressBooks.list();
+
+ // The read only AB should be in the list.
+ let readOnlyAB = list.find(ab => ab.name == "Collected Addresses");
+ browser.test.assertTrue(!!readOnlyAB, "Should have found the address book");
+
+ browser.test.assertTrue(
+ readOnlyAB.readOnly,
+ "Should have marked the address book as read-only"
+ );
+
+ let card = await browser.contacts.get(
+ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ );
+ browser.test.assertTrue(!!card, "Should have found the card");
+
+ browser.test.assertTrue(
+ card.readOnly,
+ "Should have marked the card as read-only"
+ );
+
+ await browser.test.assertRejects(
+ browser.contacts.create(readOnlyAB.id, {
+ email: "test@example.com",
+ }),
+ "Cannot create a contact in a read-only address book",
+ "Should reject creating an address book card"
+ );
+
+ await browser.test.assertRejects(
+ browser.contacts.update(card.id, card.properties),
+ "Cannot modify a contact in a read-only address book",
+ "Should reject modifying an address book card"
+ );
+
+ await browser.test.assertRejects(
+ browser.contacts.delete(card.id),
+ "Cannot delete a contact in a read-only address book",
+ "Should reject deleting an address book card"
+ );
+
+ // Mailing List
+
+ let mailingLists = await browser.mailingLists.list(readOnlyAB.id);
+ let readOnlyML = mailingLists[0];
+ browser.test.assertTrue(!!readOnlyAB, "Should have found the mailing list");
+
+ browser.test.assertTrue(
+ readOnlyML.readOnly,
+ "Should have marked the mailing list as read-only"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.create(readOnlyAB.id, { name: "Test" }),
+ "Cannot create a mailing list in a read-only address book",
+ "Should reject creating a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.update(readOnlyML.id, { name: "newTest" }),
+ "Cannot modify a mailing list in a read-only address book",
+ "Should reject modifying a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.delete(readOnlyML.id),
+ "Cannot delete a mailing list in a read-only address book",
+ "Should reject deleting a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.addMember(readOnlyML.id, card.id),
+ "Cannot add to a mailing list in a read-only address book",
+ "Should reject deleting a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.removeMember(readOnlyML.id, card.id),
+ "Cannot remove from a mailing list in a read-only address book",
+ "Should reject deleting a mailing list"
+ );
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js
new file mode 100644
index 0000000000..7a34c8ce86
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+add_setup(async () => {
+ // If nsIAbLDAPDirectory doesn't exist in our build options, someone has
+ // specified --disable-ldap.
+ if (!("nsIAbLDAPDirectory" in Ci)) {
+ return;
+ }
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ LDAPServer.open();
+
+ // Create an LDAP directory.
+ MailServices.ab.newAddressBook(
+ "test",
+ `ldap://localhost:${LDAPServer.port}/people??sub?(objectclass=*)`,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ registerCleanupFunction(() => {
+ LDAPServer.close();
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+});
+
+add_task(async function test_addressBooks_remote() {
+ async function background() {
+ let list = await browser.addressBooks.list();
+
+ // The remote AB should be in the list.
+ let remoteAB = list.find(ab => ab.name == "test");
+ browser.test.assertTrue(!!remoteAB, "Should have found the address book");
+
+ browser.test.assertTrue(
+ remoteAB.remote,
+ "Should have marked the address book as remote"
+ );
+
+ let cards = await browser.contacts.quickSearch("eurus");
+ browser.test.assertTrue(
+ cards.length,
+ "Should have found at least one card"
+ );
+
+ browser.test.assertTrue(
+ cards[0].remote,
+ "Should have marked the card as remote"
+ );
+
+ // Mailing lists are not supported for LDAP address books.
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let startupPromise = extension.startup();
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry({
+ dn: "uid=eurus,dc=bakerstreet,dc=invalid",
+ attributes: {
+ objectClass: "person",
+ cn: "Eurus Holmes",
+ givenName: "Eurus",
+ mail: "eurus@bakerstreet.invalid",
+ sn: "Holmes",
+ },
+ });
+ LDAPServer.writeSearchResultDone();
+
+ await startupPromise;
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_alias.js b/comm/mail/components/extensions/test/xpcshell/test_ext_alias.js
new file mode 100644
index 0000000000..3fff1e0e08
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_alias.js
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+AddonTestUtils.maybeInit(this);
+const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(
+ "<!DOCTYPE html><html><head><meta charset='utf8'></head><body></body></html>"
+ );
+});
+
+add_task(async function test_alias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let pending = new Set(["contentscript", "webscript"]);
+
+ browser.runtime.onMessage.addListener(message => {
+ if (message == "contentscript") {
+ pending.delete(message);
+ browser.test.succeed("Content script has completed");
+ } else if (message == "webscript") {
+ pending.delete(message);
+ browser.test.succeed("Web accessible script has completed");
+ }
+
+ if (pending.size == 0) {
+ browser.test.notifyPass("ext_alias");
+ }
+ });
+
+ browser.test.assertEq(
+ "object",
+ typeof browser,
+ "Background script has browser object"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof messenger,
+ "Background script has messenger object"
+ );
+ browser.test.assertEq(
+ "alias@xpcshell",
+ messenger.runtime.getManifest().applications.gecko.id, // eslint-disable-line no-undef
+ "Background script can access the manifest"
+ );
+ },
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["content.js"],
+ },
+ ],
+
+ applications: { gecko: { id: "alias@xpcshell" } },
+ web_accessible_resources: ["web.html", "web.js"],
+ },
+ files: {
+ "content.js": `
+ browser.test.assertEq("object", typeof browser, "Content script has browser object");
+ browser.test.assertEq("object", typeof messenger, "Content script has messenger object");
+ browser.test.assertEq(
+ "alias@xpcshell",
+ messenger.runtime.getManifest().applications.gecko.id,
+ "Content script can access manifest"
+ );
+
+ // Unprivileged content in a frame
+ let frame = document.createElement("iframe");
+ frame.src = browser.runtime.getURL("web.html");
+ document.body.appendChild(frame);
+
+ browser.runtime.sendMessage("contentscript");
+ `,
+ "web.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset='utf8'>
+ <script src="web.js"></script>
+ </head>
+ <body>
+ </body>
+ </html>
+ `,
+ "web.js": `
+ browser.test.assertEq("object", typeof browser, "Web accessible script has browser object");
+ browser.test.assertEq("object", typeof messenger, "Web accessible script has messenger object");
+ browser.test.assertEq(
+ "alias@xpcshell",
+ messenger.runtime.getManifest().applications.gecko.id,
+ "Web accessible script can access manifest"
+ );
+
+ browser.runtime.sendMessage("webscript");
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitFinish("ext_alias");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js b/comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js
new file mode 100644
index 0000000000..ef2687af68
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js
@@ -0,0 +1,350 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+var { storeState, getState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const {
+ createAppInfo,
+ createHttpServer,
+ createTempXPIFile,
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+ promiseCompleteAllInstalls,
+ promiseFindAddonUpdates,
+} = AddonTestUtils;
+
+// Prepare test environment to be able to load add-on updates.
+const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+let gProfD = do_get_profile();
+let profileDir = gProfD.clone();
+profileDir.append("extensions");
+const stageDir = profileDir.clone();
+stageDir.append("staged");
+
+let server = createHttpServer({
+ hosts: ["example.com"],
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "102");
+
+async function enforceState(state) {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState(state);
+ await stateChangeObserved;
+}
+
+function check(testType, expectedCache, expectedMail, expectedCalendar) {
+ let extensionId = `browser_action_spaces_${testType}@mochi.test`;
+
+ Assert.equal(
+ getCachedAllowedSpaces().has(extensionId),
+ expectedCache != null,
+ "CachedAllowedSpaces should include the test extension"
+ );
+ if (expectedCache != null) {
+ Assert.deepEqual(
+ getCachedAllowedSpaces().get(extensionId),
+ expectedCache,
+ "CachedAllowedSpaces should be correct"
+ );
+ }
+ Assert.equal(
+ getState().mail.includes(`ext-${extensionId}`),
+ expectedMail,
+ "The mail state should include the action button of the test extension"
+ );
+ Assert.equal(
+ getState().calendar.includes(`ext-${extensionId}`),
+ expectedCalendar,
+ "The calendar state should include the action button of the test extension"
+ );
+}
+
+function addXPI(testType, thisVersion, nextVersion, browser_action) {
+ server.registerFile(
+ `/addons/${testType}_v${thisVersion}.xpi`,
+ createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: testType,
+ version: `${thisVersion}.0`,
+ background: { scripts: ["background.js"] },
+ applications: {
+ gecko: {
+ id: `browser_action_spaces_${testType}@mochi.test`,
+ update_url: nextVersion
+ ? `http://example.com/${testType}_updates_v${nextVersion}.json`
+ : null,
+ },
+ },
+ browser_action,
+ },
+ "background.js": `
+ if (browser.runtime.getManifest().name == "delayed") {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("update postponed by ${thisVersion}");
+ });
+ }
+ browser.test.log(" ===== ready ${testType} ${thisVersion}");
+ browser.test.sendMessage("ready ${thisVersion}");`,
+ })
+ );
+ if (nextVersion) {
+ addUpdateJSON(testType, nextVersion);
+ }
+}
+
+function addUpdateJSON(testType, nextVersion) {
+ let extensionId = `browser_action_spaces_${testType}@mochi.test`;
+
+ AddonTestUtils.registerJSON(
+ server,
+ `/${testType}_updates_v${nextVersion}.json`,
+ {
+ addons: {
+ [extensionId]: {
+ updates: [
+ {
+ version: `${nextVersion}.0`,
+ update_link: `http://example.com/addons/${testType}_v${nextVersion}.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ],
+ },
+ },
+ }
+ );
+}
+
+async function checkForExtensionUpdate(testType, extension) {
+ let update = await promiseFindAddonUpdates(extension.addon);
+ let install = update.updateAvailable;
+ await promiseCompleteAllInstalls([install]);
+
+ if (testType == "normal") {
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_INSTALLED,
+ "Update should have been installed"
+ );
+ } else {
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "Update should have been postponed"
+ );
+ }
+}
+
+async function runTest(testType) {
+ // Simulate starting up the app.
+ await promiseStartupManager();
+
+ // Set a customized state for the spaces we are working with in this test.
+ await enforceState({
+ mail: ["spacer", "search-bar", "spacer"],
+ calendar: ["spacer", "search-bar", "spacer"],
+ });
+
+ // Check conditions before installing the add-on.
+ check(testType, null, false, false);
+
+ // Add the required update JSON to our test server, to be able to update to v2.
+ addUpdateJSON(testType, 2);
+ // Install addon v1 without a browserAction.
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ files: {
+ "background.js": function () {
+ if (browser.runtime.getManifest().name == "delayed") {
+ function handleUpdateAvailable(details) {
+ browser.test.sendMessage("update postponed by 1");
+ }
+ browser.runtime.onUpdateAvailable.addListener(handleUpdateAvailable);
+ }
+ browser.test.sendMessage("ready 1");
+ },
+ },
+ manifest: {
+ background: { scripts: ["background.js"] },
+ version: "1.0",
+ name: testType,
+ applications: {
+ gecko: {
+ id: `browser_action_spaces_${testType}@mochi.test`,
+ update_url: `http://example.com/${testType}_updates_v2.json`,
+ },
+ },
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("ready 1");
+
+ // State should not have changed.
+ check(testType, null, false, false);
+
+ // v2 will add the mail space and the default space.
+ addXPI(testType, 2, 3, { allowed_spaces: ["mail", "default"] });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 1");
+ // Restart to install the update v2.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 2");
+
+ // The button should have been added to the mail space.
+ check(testType, ["mail", "default"], true, false);
+
+ // Remove our extension button from all customized states.
+ await enforceState({
+ mail: ["spacer", "search-bar", "spacer"],
+ calendar: ["spacer", "search-bar", "spacer"],
+ });
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 2");
+
+ // The button should not be re-added to any space after the restart.
+ check(testType, ["mail", "default"], false, false);
+
+ // v3 will add the calendar space.
+ addXPI(testType, 3, 4, {
+ allowed_spaces: ["mail", "calendar", "default"],
+ });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 2");
+ // Restart to install the update v3.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 3");
+
+ // The button should have been added to the calendar space.
+ check(testType, ["mail", "calendar", "default"], false, true);
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 3");
+
+ // Should not have changed.
+ check(testType, ["mail", "calendar", "default"], false, true);
+
+ // v4 will remove the calendar space again.
+ addXPI(testType, 4, 5, { allowed_spaces: ["mail", "default"] });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 3");
+ // Restart to install the update v4.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 4");
+
+ // The calendar space should no longer be known and the button should be removed
+ // from the calendar space.
+ check(testType, ["mail", "default"], false, false);
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 4");
+
+ // Should not have changed.
+ check(testType, ["mail", "default"], false, false);
+
+ // v5 will remove the entire browser_action. Testing the onUpdate code path in
+ // ext-browserAction.
+ addXPI(testType, 5, 6, null);
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 4");
+ // Restart to install the update v5.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 5");
+
+ // There should no longer be a cached entry for any known spaces.
+ check(testType, null, false, false);
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 5");
+
+ // Should not have changed.
+ check(testType, null, false, false);
+
+ // v6 will add the mail space again.
+ addXPI(testType, 6, null, { allowed_spaces: ["mail", "default"] });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 5");
+ // Restart to install the update v6.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 6");
+
+ // The button should have been added to the mail space.
+ check(testType, ["mail", "default"], true, false);
+
+ // Unload the extension. Testing the onUninstall code path in ext-browserAction.
+ await extension.unload();
+
+ // There should no longer be a cached entry for any known spaces.
+ check(testType, null, false, false);
+
+ await promiseShutdownManager();
+}
+
+add_task(async function test_normal_updates() {
+ await runTest("normal");
+});
+
+add_task(async function test_delayed_updates() {
+ await runTest("delayed");
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js b/comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js
new file mode 100644
index 0000000000..d8ccd58da6
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js
@@ -0,0 +1,279 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function test_managers() {
+ let account = createAccount();
+ let folder = await createSubfolder(
+ account.incomingServer.rootFolder,
+ "test1"
+ );
+ await createMessages(folder, 5);
+
+ let files = {
+ "background.js": async () => {
+ let [testAccount] = await browser.accounts.list();
+ let testFolder = testAccount.folders.find(f => f.name == "test1");
+ let {
+ messages: [testMessage],
+ } = await browser.messages.list(testFolder);
+
+ let messageCount = await browser.testapi.testCanGetFolder(testFolder);
+ browser.test.assertEq(5, messageCount);
+
+ let convertedFolder = await browser.testapi.testCanConvertFolder();
+ browser.test.assertEq(testFolder.accountId, convertedFolder.accountId);
+ browser.test.assertEq(testFolder.path, convertedFolder.path);
+
+ let subject = await browser.testapi.testCanGetMessage(testMessage.id);
+ browser.test.assertEq(testMessage.subject, subject);
+
+ let convertedMessage = await browser.testapi.testCanConvertMessage();
+ browser.test.log(JSON.stringify(convertedMessage));
+ browser.test.assertEq(testMessage.id, convertedMessage.id);
+ browser.test.assertEq(testMessage.subject, convertedMessage.subject);
+
+ let messageList = await browser.testapi.testCanStartMessageList();
+ browser.test.assertEq(36, messageList.id.length);
+ browser.test.assertEq(4, messageList.messages.length);
+ browser.test.assertEq(
+ testMessage.subject,
+ messageList.messages[0].subject
+ );
+
+ messageList = await browser.messages.continueList(messageList.id);
+ browser.test.assertEq(null, messageList.id);
+ browser.test.assertEq(1, messageList.messages.length);
+ browser.test.assertTrue(
+ testMessage.subject != messageList.messages[0].subject
+ );
+
+ let [bookUID, contactUID, listUID] = await window.sendMessage("get UIDs");
+ let [foundBook, foundContact, foundList] =
+ await browser.testapi.testCanFindAddressBookItems(
+ bookUID,
+ contactUID,
+ listUID
+ );
+ browser.test.assertEq("new book", foundBook.name);
+ browser.test.assertEq("new contact", foundContact.properties.DisplayName);
+ browser.test.assertEq("new list", foundList.name);
+
+ browser.test.notifyPass("finished");
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ ...files,
+ "schema.json": [
+ {
+ namespace: "testapi",
+ functions: [
+ {
+ name: "testCanGetFolder",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "folder",
+ $ref: "folders.MailFolder",
+ },
+ ],
+ },
+ {
+ name: "testCanConvertFolder",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "testCanGetMessage",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "messageId",
+ type: "integer",
+ },
+ ],
+ },
+ {
+ name: "testCanConvertMessage",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "testCanStartMessageList",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "testCanFindAddressBookItems",
+ type: "function",
+ async: true,
+ parameters: [
+ { name: "bookUID", type: "string" },
+ { name: "contactUID", type: "string" },
+ { name: "listUID", type: "string" },
+ ],
+ },
+ ],
+ },
+ ],
+ "implementation.js": () => {
+ var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+ );
+ var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ this.testapi = class extends ExtensionCommon.ExtensionAPI {
+ getAPI(context) {
+ return {
+ testapi: {
+ async testCanGetFolder({ accountId, path }) {
+ let realFolder = context.extension.folderManager.get(
+ accountId,
+ path
+ );
+ return realFolder.getTotalMessages(false);
+ },
+ async testCanConvertFolder() {
+ let realFolder = MailServices.accounts.allFolders.find(
+ f => f.name == "test1"
+ );
+ return context.extension.folderManager.convert(realFolder);
+ },
+ async testCanGetMessage(messageId) {
+ let realMessage =
+ context.extension.messageManager.get(messageId);
+ return realMessage.subject;
+ },
+ async testCanConvertMessage() {
+ let realFolder = MailServices.accounts.allFolders.find(
+ f => f.name == "test1"
+ );
+ let realMessage = [...realFolder.messages][0];
+ return context.extension.messageManager.convert(realMessage);
+ },
+ async testCanStartMessageList() {
+ let realFolder = MailServices.accounts.allFolders.find(
+ f => f.name == "test1"
+ );
+ return context.extension.messageManager.startMessageList(
+ realFolder.messages
+ );
+ },
+ async testCanFindAddressBookItems(
+ bookUID,
+ contactUID,
+ listUID
+ ) {
+ let foundBook =
+ context.extension.addressBookManager.findAddressBookById(
+ bookUID
+ );
+ let foundContact =
+ context.extension.addressBookManager.findContactById(
+ contactUID
+ );
+ let foundList =
+ context.extension.addressBookManager.findMailingListById(
+ listUID
+ );
+
+ return [
+ await context.extension.addressBookManager.convert(
+ foundBook
+ ),
+ await context.extension.addressBookManager.convert(
+ foundContact
+ ),
+ await context.extension.addressBookManager.convert(
+ foundList
+ ),
+ ];
+ },
+ },
+ };
+ }
+ };
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "messagesRead"],
+ experiment_apis: {
+ testapi: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ paths: [["testapi"]],
+ script: "implementation.js",
+ },
+ },
+ },
+ },
+ });
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "new book",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = "new contact";
+ contact.firstName = "new";
+ contact.lastName = "contact";
+ contact.primaryEmail = "new.contact@invalid";
+ contact = book.addCard(contact);
+
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = "new list";
+ list = book.addMailList(list);
+ list.addCard(contact);
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 4);
+
+ await extension.startup();
+ await extension.awaitMessage("get UIDs");
+ extension.sendMessage(book.UID, contact.UID, list.UID);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+
+ await new Promise(resolve => {
+ let observer = {
+ observe() {
+ Services.obs.removeObserver(observer, "addrbook-directory-deleted");
+ resolve();
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(book.URI);
+ });
+});
+
+registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_folders.js b/comm/mail/components/extensions/test/xpcshell/test_ext_folders.js
new file mode 100644
index 0000000000..39a8d63016
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_folders.js
@@ -0,0 +1,560 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_folders() {
+ let files = {
+ "background.js": async () => {
+ let [accountId, IS_IMAP] = await window.waitForMessage();
+
+ let account = await browser.accounts.get(accountId);
+ browser.test.assertEq(3, account.folders.length);
+
+ // Test create.
+
+ let onCreatedPromise = window.waitForEvent("folders.onCreated");
+ let folder1 = await browser.folders.create(account, "folder1");
+ let [createdFolder] = await onCreatedPromise;
+ for (let folder of [folder1, createdFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder1", folder.name);
+ browser.test.assertEq("/folder1", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ // Check order of the returned folders being correct (new folder not last).
+ browser.test.assertEq(4, account.folders.length);
+ if (IS_IMAP) {
+ browser.test.assertEq("Inbox", account.folders[0].name);
+ browser.test.assertEq("Trash", account.folders[1].name);
+ } else {
+ browser.test.assertEq("Trash", account.folders[0].name);
+ browser.test.assertEq("Outbox", account.folders[1].name);
+ }
+ browser.test.assertEq("folder1", account.folders[2].name);
+ browser.test.assertEq("unused", account.folders[3].name);
+
+ let folder2 = await browser.folders.create(folder1, "folder+2");
+ browser.test.assertEq(accountId, folder2.accountId);
+ browser.test.assertEq("folder+2", folder2.name);
+ browser.test.assertEq("/folder1/folder+2", folder2.path);
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ browser.test.assertEq(1, account.folders[2].subFolders.length);
+ browser.test.assertEq(
+ "/folder1/folder+2",
+ account.folders[2].subFolders[0].path
+ );
+
+ // Test reject on creating already existing folder.
+ await browser.test.assertRejects(
+ browser.folders.create(folder1, "folder+2"),
+ `folders.create() failed, because folder+2 already exists in /folder1`,
+ "browser.folders.create threw exception"
+ );
+
+ // Test rename.
+
+ {
+ let onRenamedPromise = window.waitForEvent("folders.onRenamed");
+ let folder3 = await browser.folders.rename(
+ { accountId, path: "/folder1/folder+2" },
+ "folder3"
+ );
+ let [originalFolder, renamedFolder] = await onRenamedPromise;
+ // Test the original folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder+2", originalFolder.name);
+ browser.test.assertEq("/folder1/folder+2", originalFolder.path);
+ // Test the renamed folder.
+ for (let folder of [folder3, renamedFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder3", folder.name);
+ browser.test.assertEq("/folder1/folder3", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ browser.test.assertEq(1, account.folders[2].subFolders.length);
+ browser.test.assertEq(
+ "/folder1/folder3",
+ account.folders[2].subFolders[0].path
+ );
+
+ // Test reject on renaming absolute root.
+ await browser.test.assertRejects(
+ browser.folders.rename({ accountId, path: "/" }, "UhhOh"),
+ `folders.rename() failed, because it cannot rename the root of the account`,
+ "browser.folders.rename threw exception"
+ );
+
+ // Test reject on renaming to existing folder.
+ await browser.test.assertRejects(
+ browser.folders.rename(
+ { accountId, path: "/folder1/folder3" },
+ "folder3"
+ ),
+ `folders.rename() failed, because folder3 already exists in /folder1`,
+ "browser.folders.rename threw exception"
+ );
+ }
+
+ // Test delete (and onMoved).
+
+ {
+ // The delete request will trigger an onDelete event for IMAP and an
+ // onMoved event for local folders.
+ let deletePromise = window.waitForEvent(
+ `folders.${IS_IMAP ? "onDeleted" : "onMoved"}`
+ );
+ await browser.folders.delete({ accountId, path: "/folder1/folder3" });
+ // The onMoved event returns the original/deleted and the new folder.
+ // The onDeleted event returns just the original/deleted folder.
+ let [originalFolder, folderMovedToTrash] = await deletePromise;
+
+ // Test the originalFolder folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder3", originalFolder.name);
+ browser.test.assertEq("/folder1/folder3", originalFolder.path);
+
+ // Check if it really is in trash folder.
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ let trashFolder = account.folders.find(f => f.name == "Trash");
+ browser.test.assertTrue(trashFolder);
+ browser.test.assertEq("/Trash", trashFolder.path);
+ browser.test.assertEq(1, trashFolder.subFolders.length);
+ browser.test.assertEq(
+ "/Trash/folder3",
+ trashFolder.subFolders[0].path
+ );
+ browser.test.assertEq("/folder1", account.folders[2].path);
+
+ if (!IS_IMAP) {
+ // For non IMAP folders, the delete request has triggered an onMoved
+ // event, check if that has reported moving the folder to trash.
+ browser.test.assertEq(accountId, folderMovedToTrash.accountId);
+ browser.test.assertEq("folder3", folderMovedToTrash.name);
+ browser.test.assertEq("/Trash/folder3", folderMovedToTrash.path);
+
+ // Delete the folder from trash.
+ let onDeletedPromise = window.waitForEvent("folders.onDeleted");
+ await browser.folders.delete({ accountId, path: "/Trash/folder3" });
+ let [deletedFolder] = await onDeletedPromise;
+ browser.test.assertEq(accountId, deletedFolder.accountId);
+ browser.test.assertEq("folder3", deletedFolder.name);
+ browser.test.assertEq("/Trash/folder3", deletedFolder.path);
+ // Check if the folder is gone.
+ let trashSubfolders = await browser.folders.getSubFolders(
+ trashFolder,
+ false
+ );
+ browser.test.assertEq(
+ 0,
+ trashSubfolders.length,
+ "Folder has been deleted from trash."
+ );
+ } else {
+ // The IMAP test server signals success for the delete request, but
+ // keeps the folder. Testing for this broken behavior to get notified
+ // via test fails, if this behaviour changes.
+ await browser.folders.delete({ accountId, path: "/Trash/folder3" });
+ let trashSubfolders = await browser.folders.getSubFolders(
+ trashFolder,
+ false
+ );
+ browser.test.assertEq(
+ "/Trash/folder3",
+ trashSubfolders[0].path,
+ "IMAP test server cannot delete from trash, the folder is still there."
+ );
+ }
+
+ // Test reject on deleting non-existing folder.
+ await browser.test.assertRejects(
+ browser.folders.delete({ accountId, path: "/folder1/folder5" }),
+ `Folder not found: /folder1/folder5`,
+ "browser.folders.delete threw exception"
+ );
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ browser.test.assertEq("/folder1", account.folders[2].path);
+ }
+
+ // Test move.
+
+ {
+ await browser.folders.create(folder1, "folder4");
+ let onMovedPromise = window.waitForEvent("folders.onMoved");
+ let folder4_moved = await browser.folders.move(
+ { accountId, path: "/folder1/folder4" },
+ { accountId, path: "/" }
+ );
+ let [originalFolder, movedFolder] = await onMovedPromise;
+ // Test the original folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder4", originalFolder.name);
+ browser.test.assertEq("/folder1/folder4", originalFolder.path);
+ // Test the moved folder.
+ for (let folder of [folder4_moved, movedFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder4", folder.name);
+ browser.test.assertEq("/folder4", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(5, account.folders.length);
+ browser.test.assertEq("/folder4", account.folders[3].path);
+
+ // Test reject on moving to already existing folder.
+ await browser.test.assertRejects(
+ browser.folders.move(folder4_moved, account),
+ `folders.move() failed, because folder4 already exists in /`,
+ "browser.folders.move threw exception"
+ );
+ }
+
+ // Test copy.
+
+ {
+ let onCopiedPromise = window.waitForEvent("folders.onCopied");
+ let folder4_copied = await browser.folders.copy(
+ { accountId, path: "/folder4" },
+ { accountId, path: "/folder1" }
+ );
+ let [originalFolder, copiedFolder] = await onCopiedPromise;
+ // Test the original folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder4", originalFolder.name);
+ browser.test.assertEq("/folder4", originalFolder.path);
+ // Test the copied folder.
+ for (let folder of [folder4_copied, copiedFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder4", folder.name);
+ browser.test.assertEq("/folder1/folder4", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(5, account.folders.length);
+ browser.test.assertEq(1, account.folders[2].subFolders.length);
+ browser.test.assertEq("/folder4", account.folders[3].path);
+ browser.test.assertEq(
+ "/folder1/folder4",
+ account.folders[2].subFolders[0].path
+ );
+
+ // Test reject on copy to already existing folder.
+ await browser.test.assertRejects(
+ browser.folders.copy(folder4_copied, folder1),
+ `folders.copy() failed, because folder4 already exists in /folder1`,
+ "browser.folders.copy threw exception"
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders", "messagesDelete"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ await createSubfolder(account.incomingServer.rootFolder, "unused");
+
+ // We should now have three folders. For IMAP accounts they are Inbox, Trash,
+ // and unused. Otherwise they are Trash, Unsent Messages and unused.
+
+ await extension.startup();
+ extension.sendMessage(account.key, IS_IMAP);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_without_delete_permission() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+
+ // Test reject on delete without messagesDelete permission.
+ await browser.test.assertRejects(
+ browser.folders.delete({ accountId, path: "/unused" }),
+ `Using folders.delete() requires the "accountsFolders" and the "messagesDelete" permission`,
+ "It rejects for a missing permission."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ await createSubfolder(account.incomingServer.rootFolder, "unused");
+
+ // We should now have three folders. For IMAP accounts they are Inbox,
+ // Trash, and unused. Otherwise they are Trash, Unsent Messages and unused.
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(async function test_getParentFolders_getSubFolders() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let account = await browser.accounts.get(accountId);
+
+ async function createSubFolder(folderOrAccount, name) {
+ let subFolder = await browser.folders.create(folderOrAccount, name);
+ let basePath = folderOrAccount.path || "/";
+ if (!basePath.endsWith("/")) {
+ basePath = basePath + "/";
+ }
+ browser.test.assertEq(accountId, subFolder.accountId);
+ browser.test.assertEq(name, subFolder.name);
+ browser.test.assertEq(`${basePath}${name}`, subFolder.path);
+ return subFolder;
+ }
+
+ // Create a new root folder in the account.
+ let root = await createSubFolder(account, "MyRoot");
+
+ // Build a flat list of newly created nested folders in MyRoot.
+ let flatFolders = [root];
+ for (let i = 0; i < 10; i++) {
+ flatFolders.push(await createSubFolder(flatFolders[i], `level${i}`));
+ }
+
+ // Test getParentFolders().
+
+ // Pop out the last child folder and get its parents.
+ let lastChild = flatFolders.pop();
+ let parentsWithSubDefault = await browser.folders.getParentFolders(
+ lastChild
+ );
+ let parentsWithSubFalse = await browser.folders.getParentFolders(
+ lastChild,
+ false
+ );
+ let parentsWithSubTrue = await browser.folders.getParentFolders(
+ lastChild,
+ true
+ );
+
+ browser.test.assertEq(10, parentsWithSubDefault.length, "Correct depth.");
+ browser.test.assertEq(10, parentsWithSubFalse.length, "Correct depth.");
+ browser.test.assertEq(10, parentsWithSubTrue.length, "Correct depth.");
+
+ // Reverse the flatFolders array, to match the expected return value of
+ // getParentFolders().
+ flatFolders.reverse();
+
+ // Build expected nested subfolder structure.
+ lastChild.subFolders = [];
+ let flatFoldersWithSub = [];
+ for (let i = 0; i < 10; i++) {
+ let f = {};
+ Object.assign(f, flatFolders[i]);
+ if (i == 0) {
+ f.subFolders = [lastChild];
+ } else {
+ f.subFolders = [flatFoldersWithSub[i - 1]];
+ }
+ flatFoldersWithSub.push(f);
+ }
+
+ // Test return values of getParentFolders(). The way the flatFolder array
+ // has been created, its entries do not have subFolder properties.
+ for (let i = 0; i < 10; i++) {
+ window.assertDeepEqual(parentsWithSubFalse[i], flatFolders[i]);
+ window.assertDeepEqual(flatFolders[i], parentsWithSubFalse[i]);
+
+ window.assertDeepEqual(parentsWithSubTrue[i], flatFoldersWithSub[i]);
+ window.assertDeepEqual(flatFoldersWithSub[i], parentsWithSubTrue[i]);
+
+ // Default = false
+ window.assertDeepEqual(parentsWithSubDefault[i], flatFolders[i]);
+ window.assertDeepEqual(flatFolders[i], parentsWithSubDefault[i]);
+ }
+
+ // Test getSubFolders().
+
+ let expectedSubsWithSub = [flatFoldersWithSub[8]];
+ let expectedSubsWithoutSub = [flatFolders[8]];
+
+ // Test excluding subfolders (so only the direct subfolder are reported).
+ let subsWithSubFalse = await browser.folders.getSubFolders(root, false);
+ window.assertDeepEqual(expectedSubsWithoutSub, subsWithSubFalse);
+ window.assertDeepEqual(subsWithSubFalse, expectedSubsWithoutSub);
+
+ // Test including all subfolders.
+ let subsWithSubTrue = await browser.folders.getSubFolders(root, true);
+ window.assertDeepEqual(expectedSubsWithSub, subsWithSubTrue);
+ window.assertDeepEqual(subsWithSubTrue, expectedSubsWithSub);
+
+ // Test default subfolder handling of getSubFolders (= true).
+ let subsWithSubDefault = await browser.folders.getSubFolders(root);
+ window.assertDeepEqual(subsWithSubDefault, subsWithSubTrue);
+ window.assertDeepEqual(subsWithSubTrue, subsWithSubDefault);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ await createSubfolder(account.incomingServer.rootFolder, "unused");
+
+ // We should now have three folders. For IMAP accounts they are Inbox,
+ // Trash, and unused. Otherwise they are Trash, Unsent Messages and unused.
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_getFolderInfo() {
+ let files = {
+ "background.js": async () => {
+ let [accountId, IS_NNTP] = await window.waitForMessage();
+
+ let account = await browser.accounts.get(accountId);
+ browser.test.assertEq(IS_NNTP ? 1 : 3, account.folders.length);
+ let folders = await browser.folders.getSubFolders(account, false);
+ let InfoTestFolder = folders.find(f => f.name == "InfoTest");
+
+ // Verify initial state.
+ let info = await browser.folders.getFolderInfo(InfoTestFolder);
+ window.assertDeepEqual(
+ { totalMessageCount: 12, unreadMessageCount: 12, favorite: false },
+ info
+ );
+
+ // Test flipping favorite to true and marking all messages as read.
+ let onFolderInfoChangedPromise = window.waitForEvent(
+ "folders.onFolderInfoChanged"
+ );
+ await window.sendMessage("markAllAsRead");
+ await window.sendMessage("setFavorite", true);
+ let [mailFolder, mailFolderInfo] = await onFolderInfoChangedPromise;
+ window.assertDeepEqual(
+ { unreadMessageCount: 0, favorite: true },
+ mailFolderInfo
+ );
+ browser.test.assertEq(InfoTestFolder.path, mailFolder.path);
+
+ info = await browser.folders.getFolderInfo(InfoTestFolder);
+ window.assertDeepEqual(
+ { totalMessageCount: 12, unreadMessageCount: 0, favorite: true },
+ info
+ );
+
+ // Test flipping favorite back to false.
+ onFolderInfoChangedPromise = window.waitForEvent(
+ "folders.onFolderInfoChanged"
+ );
+ await window.sendMessage("setFavorite", false);
+ [mailFolder, mailFolderInfo] = await onFolderInfoChangedPromise;
+ window.assertDeepEqual({ favorite: false }, mailFolderInfo);
+ browser.test.assertEq(InfoTestFolder.path, mailFolder.path);
+
+ // Test setting some messages back to unread.
+ onFolderInfoChangedPromise = window.waitForEvent(
+ "folders.onFolderInfoChanged"
+ );
+ await window.sendMessage("markSomeAsUnread", 5);
+ [mailFolder, mailFolderInfo] = await onFolderInfoChangedPromise;
+ window.assertDeepEqual({ unreadMessageCount: 5 }, mailFolderInfo);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders", "messagesDelete"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ let InfoTestFolder = await createSubfolder(
+ account.incomingServer.rootFolder,
+ "InfoTest"
+ );
+ await createMessages(InfoTestFolder, 12);
+
+ extension.onMessage("markAllAsRead", () => {
+ InfoTestFolder.markAllMessagesRead(null);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("markSomeAsUnread", count => {
+ let messages = InfoTestFolder.messages;
+ while (messages.hasMoreElements() && count > 0) {
+ let msg = messages.getNext();
+ msg.markRead(false);
+ count--;
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("setFavorite", value => {
+ if (value) {
+ InfoTestFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ } else {
+ InfoTestFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+ extension.sendMessage();
+ });
+
+ // We should now have three folders. For IMAP accounts they are Inbox, Trash,
+ // and InfoTest. Otherwise they are Trash, Unsent Messages and InfoTest.
+
+ await extension.startup();
+ extension.sendMessage(account.key, IS_NNTP);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js
new file mode 100644
index 0000000000..eac947cda8
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Test events and persistent events for Manifest V3 for onCreated, onRenamed,
+// onMoved, onCopied and onDeleted.
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_folders_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ addIdentity(account, "id1@invalid");
+
+ let files = {
+ "background.js": () => {
+ for (let eventName of [
+ "onCreated",
+ "onDeleted",
+ "onCopied",
+ "onRenamed",
+ "onMoved",
+ ]) {
+ browser.folders[eventName].addListener(async (...args) => {
+ browser.test.log(`${eventName} received: ${JSON.stringify(args)}`);
+ browser.test.sendMessage(`${eventName} received`, args);
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+
+ // Function to start an event page extension (MV3), which can be called whenever
+ // the main test is about to trigger an event. The extension terminates its
+ // background and listens for that single event, verifying it is waking up correctly.
+ async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.folders[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "folders", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "folders", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "folders", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+
+ // Create a test folder before terminating the background script, to make sure
+ // everything is sane.
+
+ rootFolder.createSubfolder("TestFolder", null);
+ await extension.awaitMessage("onCreated received");
+ if (IS_IMAP) {
+ // IMAP creates a default Trash folder on the fly.
+ await extension.awaitMessage("onCreated received");
+ }
+
+ // Create SubFolder1.
+
+ {
+ rootFolder.createSubfolder("SubFolder1", null);
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder1",
+ path: "/SubFolder1",
+ },
+ ],
+ createData,
+ "The onCreated event should return the correct values"
+ );
+ }
+
+ // Create SubFolder2 (used for primed onFolderInfoChanged).
+
+ {
+ let primedChangeData = await event_page_extension(
+ "onFolderInfoChanged",
+ () => {
+ rootFolder.createSubfolder("SubFolder3", null);
+ }
+ );
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3",
+ },
+ ],
+ createData,
+ "The onCreated event should return the correct values"
+ );
+ // Testing for onFolderInfoChanged is difficult, because it may not be for
+ // the last created folder, but for one of the folders created earlier. We
+ // therefore do not check the folder, but only the value.
+ Assert.deepEqual(
+ { totalMessageCount: 0, unreadMessageCount: 0 },
+ primedChangeData[1],
+ "The primed onFolderInfoChanged event should return the correct values"
+ );
+ }
+
+ // Copy.
+
+ {
+ let primedCopyData = await event_page_extension("onCopied", () => {
+ MailServices.copy.copyFolder(
+ rootFolder.getChildNamed("SubFolder3"),
+ rootFolder.getChildNamed("SubFolder1"),
+ false,
+ null,
+ null
+ );
+ });
+ let copyData = await extension.awaitMessage("onCopied received");
+ Assert.deepEqual(
+ primedCopyData,
+ copyData,
+ "The primed onCopied event should return the correct values"
+ );
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3",
+ },
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ ],
+ copyData,
+ "The onCopied event should return the correct values"
+ );
+
+ if (IS_IMAP) {
+ // IMAP fires an additional create event.
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ ],
+ createData,
+ "The onCreated event should return the correct MailFolder values."
+ );
+ }
+ }
+
+ // Move.
+
+ {
+ let primedMoveData = await event_page_extension("onMoved", () => {
+ MailServices.copy.copyFolder(
+ rootFolder.getChildNamed("SubFolder1").getChildNamed("SubFolder3"),
+ rootFolder.getChildNamed("SubFolder3"),
+ true,
+ null,
+ null
+ );
+ });
+
+ let moveData = await extension.awaitMessage("onMoved received");
+ Assert.deepEqual(
+ primedMoveData,
+ moveData,
+ "The primed onMoved event should return the correct values"
+ );
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3/SubFolder3",
+ },
+ ],
+ moveData,
+ "The onMoved event should return the correct values"
+ );
+
+ if (IS_IMAP) {
+ // IMAP fires additional rename and delete events.
+ let renameData = await extension.awaitMessage("onRenamed received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3/SubFolder3",
+ },
+ ],
+ renameData,
+ "The onRenamed event should return the correct MailFolder values."
+ );
+ let deleteData = await extension.awaitMessage("onDeleted received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ ],
+ deleteData,
+ "The onDeleted event should return the correct MailFolder values."
+ );
+ }
+ }
+
+ // Delete.
+
+ {
+ let primedDeleteData = await event_page_extension("onDeleted", () => {
+ let subFolder1 = rootFolder.getChildNamed("SubFolder3");
+ subFolder1.propagateDelete(
+ subFolder1.getChildNamed("SubFolder3"),
+ true
+ );
+ });
+ let deleteData = await extension.awaitMessage("onDeleted received");
+ Assert.deepEqual(
+ primedDeleteData,
+ deleteData,
+ "The primed onDeleted event should return the correct values"
+ );
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3/SubFolder3",
+ },
+ ],
+ deleteData,
+ "The onDeleted event should return the correct values"
+ );
+ }
+
+ // Rename.
+
+ {
+ let primedRenameData = await event_page_extension("onRenamed", () => {
+ rootFolder.getChildNamed("TestFolder").rename("TestFolder2", null);
+ });
+ let renameData = await extension.awaitMessage("onRenamed received");
+ Assert.deepEqual(
+ primedRenameData,
+ renameData,
+ "The primed onRenamed event should return the correct values"
+ );
+ if (IS_IMAP) {
+ // IMAP server sends an additional onDeleted and onCreated.
+ await extension.awaitMessage("onDeleted received");
+ await extension.awaitMessage("onCreated received");
+ }
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "TestFolder",
+ path: "/TestFolder",
+ },
+ {
+ accountId: account.key,
+ name: "TestFolder2",
+ path: "/TestFolder2",
+ },
+ ],
+ renameData,
+ "The onRenamed event should return the correct values"
+ );
+ }
+
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js
new file mode 100644
index 0000000000..0b12f8ca1c
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+add_task(async function test_identities_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account1 = createAccount();
+ addIdentity(account1, "id1@invalid");
+
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ for (let eventName of ["onCreated", "onUpdated", "onDeleted"]) {
+ browser.identities[eventName].addListener((...args) => {
+ // Only send the first event after background wake-up, this should be the
+ // only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ browser_specific_settings: { gecko: { id: "identities@xpcshell.test" } },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "identities.onCreated",
+ "identities.onUpdated",
+ "identities.onDeleted",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("background started");
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Create.
+
+ let id2 = addIdentity(account1, "id2@invalid");
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ "id2",
+ {
+ accountId: "account1",
+ id: "id2",
+ label: "",
+ name: "",
+ email: "id2@invalid",
+ replyTo: "",
+ organization: "",
+ composeHtml: true,
+ signature: "",
+ signatureIsPlainText: true,
+ },
+ ],
+ createData,
+ "The primed onCreated event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Update
+
+ id2.fullName = "Updated Name";
+ let updateData = await extension.awaitMessage("onUpdated received");
+ Assert.deepEqual(
+ ["id2", { name: "Updated Name", accountId: "account1", id: "id2" }],
+ updateData,
+ "The primed onUpdated event should return the correct values"
+ );
+ await extension.awaitMessage("background started");
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Delete
+
+ account1.removeIdentity(id2);
+ let deleteData = await extension.awaitMessage("onDeleted received");
+ Assert.deepEqual(
+ ["id2"],
+ deleteData,
+ "The primed onDeleted event should return the correct values"
+ );
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+
+ cleanUpAccount(account1);
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages.js
new file mode 100644
index 0000000000..24b2cb1484
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages.js
@@ -0,0 +1,730 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+
+let account, rootFolder, subFolders;
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function setup() {
+ account = createAccount();
+ rootFolder = account.incomingServer.rootFolder;
+ subFolders = {
+ test3: await createSubfolder(rootFolder, "test3"),
+ test4: await createSubfolder(rootFolder, "test4"),
+ trash: rootFolder.getChildNamed("Trash"),
+ };
+ await createMessages(subFolders.trash, 99);
+ await createMessages(subFolders.test4, 1);
+ }
+);
+
+add_task(async function non_canonical_permission_description_mapping() {
+ let { msgs } = ExtensionsUI._buildStrings({
+ addon: { name: "FakeExtension" },
+ permissions: {
+ origins: [],
+ permissions: ["accountsRead", "messagesMove"],
+ },
+ });
+ equal(2, msgs.length, "Correct amount of descriptions");
+ equal(
+ "See your mail accounts, their identities and their folders",
+ msgs[0],
+ "Correct description for accountsRead"
+ );
+ equal(
+ "Copy or move your email messages (including moving them to the trash folder)",
+ msgs[1],
+ "Correct description for messagesMove"
+ );
+});
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_pagination() {
+ let files = {
+ "background.js": async () => {
+ // Test a response of 99 messages at 10 messages per page.
+ let [folder] = await window.waitForMessage();
+ let page = await browser.messages.list(folder);
+ browser.test.assertEq(36, page.id.length);
+ browser.test.assertEq(10, page.messages.length);
+
+ let originalPageId = page.id;
+ let numPages = 1;
+ let numMessages = 10;
+ while (page.id) {
+ page = await browser.messages.continueList(page.id);
+ browser.test.assertTrue(page.messages.length > 0);
+ numPages++;
+ numMessages += page.messages.length;
+ if (numMessages < 99) {
+ browser.test.assertEq(originalPageId, page.id);
+ } else {
+ browser.test.assertEq(null, page.id);
+ }
+ }
+ browser.test.assertEq(10, numPages);
+ browser.test.assertEq(99, numMessages);
+
+ browser.test.assertRejects(
+ browser.messages.continueList(originalPageId),
+ /No message list for id .*\. Have you reached the end of a list\?/
+ );
+
+ await window.sendMessage("setPref");
+
+ // Do the same test, but with the default 100 messages per page.
+ page = await browser.messages.list(folder);
+ browser.test.assertEq(null, page.id);
+ browser.test.assertEq(99, page.messages.length);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 10);
+
+ await extension.startup();
+ extension.sendMessage({ accountId: account.key, path: "/Trash" });
+
+ await extension.awaitMessage("setPref");
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_delete_without_permission() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder4 = folders.find(f => f.name == "test4");
+
+ let { messages: folder4Messages } = await browser.messages.list(
+ testFolder4
+ );
+
+ // Try to delete a message.
+ await browser.test.assertThrows(
+ () => browser.messages.delete([folder4Messages[0].id], true),
+ `browser.messages.delete is not a function`,
+ "Should reject deleting without proper permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "messages.delete@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesMove", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_move_and_copy_without_permission() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder4 = folders.find(f => f.name == "test4");
+ let testFolder3 = folders.find(f => f.name == "test3");
+
+ let { messages: folder4Messages } = await browser.messages.list(
+ testFolder4
+ );
+
+ // Try to move a message.
+ await browser.test.assertRejects(
+ browser.messages.move([folder4Messages[0].id], testFolder3),
+ `Using messages.move() requires the "accountsRead" and the "messagesMove" permission`,
+ "Should reject move without proper permission"
+ );
+
+ // Try to copy a message.
+ await browser.test.assertRejects(
+ browser.messages.copy([folder4Messages[0].id], testFolder3),
+ `Using messages.copy() requires the "accountsRead" and the "messagesMove" permission`,
+ "Should reject copy without proper permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "messages.move@mochi.test" },
+ },
+ permissions: ["messagesRead", "accountsRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_tags() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder4 = folders.find(f => f.name == "test4");
+ let { messages: folder4Messages } = await browser.messages.list(
+ testFolder4
+ );
+
+ let tags1 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Later",
+ color: "#993399",
+ ordinal: "",
+ },
+ ],
+ tags1
+ );
+
+ // Test some allowed special chars and that the key is created as lower
+ // case.
+ let goodKeys = [
+ "TestKey",
+ "Test_Key",
+ "Test\\Key",
+ "Test}Key",
+ "Test&Key",
+ "Test!Key",
+ "Test§Key",
+ "Test$Key",
+ "Test=Key",
+ "Test?Key",
+ ];
+ for (let key of goodKeys) {
+ await browser.messages.createTag(key, "Test Tag", "#123456");
+ let goodTags = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Later",
+ color: "#993399",
+ ordinal: "",
+ },
+ {
+ key: key.toLowerCase(),
+ tag: "Test Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ goodTags
+ );
+ await browser.messages.deleteTag(key.toLowerCase());
+ }
+
+ await browser.messages.createTag("custom_tag", "Custom Tag", "#123456");
+ let tags2 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Later",
+ color: "#993399",
+ ordinal: "",
+ },
+ {
+ key: "custom_tag",
+ tag: "Custom Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ tags2
+ );
+
+ await browser.messages.updateTag("$label5", {
+ tag: "Much Later",
+ color: "#225599",
+ });
+ let tags3 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Much Later",
+ color: "#225599",
+ ordinal: "",
+ },
+ {
+ key: "custom_tag",
+ tag: "Custom Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ tags3
+ );
+
+ // Test rejects for createTag().
+ let badKeys = [
+ "Bad Key",
+ "Bad%Key",
+ "Bad/Key",
+ "Bad*Key",
+ 'Bad"Key',
+ "Bad{Key}",
+ "Bad(Key)",
+ "Bad<Key>",
+ ];
+ for (let badKey of badKeys) {
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(badKey, "Important Stuff", "#223344"),
+ /Type error for parameter key/,
+ `Should reject creating an invalid key: ${badKey}`
+ );
+ }
+
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(
+ "GoodKeyBadColor",
+ "Important Stuff",
+ "#223"
+ ),
+ /Type error for parameter color /,
+ "Should reject creating a key using an invalid short color"
+ );
+
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(
+ "GoodKeyBadColor",
+ "Important Stuff",
+ "123223"
+ ),
+ /Type error for parameter color /,
+ "Should reject creating a key using an invalid color without leading #"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.createTag("$label5", "Important Stuff", "#223344"),
+ `Specified key already exists: $label5`,
+ "Should reject creating a key which exists already"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.createTag(
+ "Custom_Tag",
+ "Important Stuff",
+ "#223344"
+ ),
+ `Specified key already exists: custom_tag`,
+ "Should reject creating a key which exists already"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.createTag("GoodKey", "Important", "#223344"),
+ `Specified tag already exists: Important`,
+ "Should reject creating a key using a tag which exists already"
+ );
+
+ // Test rejects for updateTag();
+ await browser.test.assertThrows(
+ () => browser.messages.updateTag("Bad Key", { tag: "Much Later" }),
+ /Type error for parameter key/,
+ "Should reject updating an invalid key"
+ );
+
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.updateTag("GoodKeyBadColor", { color: "123223" }),
+ /Error processing color/,
+ "Should reject updating a key using an invalid color"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.updateTag("$label50", { tag: "Much Later" }),
+ `Specified key does not exist: $label50`,
+ "Should reject updating an unknown key"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.updateTag("$label5", { tag: "Important" }),
+ `Specified tag already exists: Important`,
+ "Should reject updating a key using a tag which exists already"
+ );
+
+ // Test rejects for deleteTag();
+ await browser.test.assertThrows(
+ () => browser.messages.deleteTag("Bad Key"),
+ /Type error for parameter key/,
+ "Should reject deleting an invalid key"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.deleteTag("$label50"),
+ `Specified key does not exist: $label50`,
+ "Should reject deleting an unknown key"
+ );
+
+ // Test tagging messages, deleting tag and re-creating tag.
+ await browser.messages.update(folder4Messages[0].id, {
+ tags: ["custom_tag"],
+ });
+ let message1 = await browser.messages.get(folder4Messages[0].id);
+ window.assertDeepEqual(["custom_tag"], message1.tags);
+
+ await browser.messages.deleteTag("custom_tag");
+ let message2 = await browser.messages.get(folder4Messages[0].id);
+ window.assertDeepEqual([], message2.tags);
+
+ await browser.messages.createTag("custom_tag", "Custom Tag", "#123456");
+ let message3 = await browser.messages.get(folder4Messages[0].id);
+ window.assertDeepEqual(["custom_tag"], message3.tags);
+
+ // Test deleting built-in tag.
+ await browser.messages.deleteTag("$label5");
+ let tags4 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "custom_tag",
+ tag: "Custom Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ tags4
+ );
+
+ // Clean up.
+ await browser.messages.update(folder4Messages[0].id, { tags: [] });
+ await browser.messages.deleteTag("custom_tag");
+ await browser.messages.createTag("$label5", "Later", "#993399");
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead", "messagesTags"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_tags_no_permission() {
+ let files = {
+ "background.js": async () => {
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(
+ "custom_tag",
+ "Important Stuff",
+ "#223344"
+ ),
+ /browser.messages.createTag is not a function/,
+ "Should reject creating tags without messagesTags permission"
+ );
+
+ await browser.test.assertThrows(
+ () => browser.messages.updateTag("$label5", { tag: "Much Later" }),
+ /browser.messages.updateTag is not a function/,
+ "Should reject updating tags without messagesTags permission"
+ );
+
+ await browser.test.assertThrows(
+ () => browser.messages.deleteTag("$label5"),
+ /browser.messages.deleteTag is not a function/,
+ "Should reject deleting tags without messagesTags permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+// The IMAP fakeserver just can't handle this.
+add_task({ skip_if: () => IS_IMAP || IS_NNTP }, async function test_archive() {
+ let account2 = createAccount();
+ account2.addIdentity(MailServices.accounts.createIdentity());
+ let inbox2 = await createSubfolder(
+ account2.incomingServer.rootFolder,
+ "test"
+ );
+ await createMessages(inbox2, 15);
+
+ let month = 10;
+ for (let message of inbox2.messages) {
+ message.date = new Date(2018, month++, 15) * 1000;
+ }
+
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+
+ let accountBefore = await browser.accounts.get(accountId);
+ browser.test.assertEq(3, accountBefore.folders.length);
+ browser.test.assertEq("/test", accountBefore.folders[2].path);
+
+ let messagesBefore = await browser.messages.list(
+ accountBefore.folders[2]
+ );
+ browser.test.assertEq(15, messagesBefore.messages.length);
+ await browser.messages.archive(messagesBefore.messages.map(m => m.id));
+
+ let accountAfter = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, accountAfter.folders.length);
+ browser.test.assertEq("/test", accountAfter.folders[3].path);
+ browser.test.assertEq("/Archives", accountAfter.folders[0].path);
+ browser.test.assertEq(3, accountAfter.folders[0].subFolders.length);
+ browser.test.assertEq(
+ "/Archives/2018",
+ accountAfter.folders[0].subFolders[0].path
+ );
+ browser.test.assertEq(
+ "/Archives/2019",
+ accountAfter.folders[0].subFolders[1].path
+ );
+ browser.test.assertEq(
+ "/Archives/2020",
+ accountAfter.folders[0].subFolders[2].path
+ );
+
+ let messagesAfter = await browser.messages.list(accountAfter.folders[3]);
+ browser.test.assertEq(0, messagesAfter.messages.length);
+
+ let messages2018 = await browser.messages.list(
+ accountAfter.folders[0].subFolders[0]
+ );
+ browser.test.assertEq(2, messages2018.messages.length);
+
+ let messages2019 = await browser.messages.list(
+ accountAfter.folders[0].subFolders[1]
+ );
+ browser.test.assertEq(12, messages2019.messages.length);
+
+ let messages2020 = await browser.messages.list(
+ accountAfter.folders[0].subFolders[2]
+ );
+ browser.test.assertEq(1, messages2020.messages.length);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesMove", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account2.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js
new file mode 100644
index 0000000000..e46e35afe7
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js
@@ -0,0 +1,499 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+add_task(
+ {
+ skip_if: () => IS_IMAP,
+ },
+ async function test_setup() {
+ let _account = createAccount();
+ let _testFolder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ let textAttachment = {
+ body: "textAttachment",
+ filename: "test.txt",
+ contentType: "text/plain",
+ };
+ let binaryAttachment = {
+ body: btoa("binaryAttachment"),
+ filename: "test",
+ contentType: "application/octet-stream",
+ encoding: "base64",
+ };
+
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "0 attachments",
+ });
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "1 text attachment",
+ attachments: [textAttachment],
+ });
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "1 binary attachment",
+ attachments: [binaryAttachment],
+ });
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "2 attachments",
+ attachments: [binaryAttachment, textAttachment],
+ });
+ await createMessageFromFile(
+ _testFolder,
+ do_get_file("messages/nestedMessages.eml").path
+ );
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_IMAP,
+ },
+ async function test_attachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [account] = await browser.accounts.list();
+ let testFolder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(5, messages.length);
+
+ let attachments, attachment, file;
+
+ // "0 attachments" message.
+
+ attachments = await browser.messages.listAttachments(messages[0].id);
+ browser.test.assertEq("0 attachments", messages[0].subject);
+ browser.test.assertEq(0, attachments.length);
+
+ // "1 text attachment" message.
+
+ attachments = await browser.messages.listAttachments(messages[1].id);
+ browser.test.assertEq("1 text attachment", messages[1].subject);
+ browser.test.assertEq(1, attachments.length);
+
+ attachment = attachments[0];
+ browser.test.assertEq("text/plain", attachment.contentType);
+ browser.test.assertEq("test.txt", attachment.name);
+ browser.test.assertEq("1.2", attachment.partName);
+ browser.test.assertEq(14, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[1].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test.txt", file.name);
+ browser.test.assertEq(14, file.size);
+
+ browser.test.assertEq("textAttachment", await file.text());
+
+ let reader = new FileReader();
+ let data = await new Promise(resolve => {
+ reader.onload = e => resolve(e.target.result);
+ reader.readAsDataURL(file);
+ });
+
+ browser.test.assertEq(
+ "data:text/plain;base64,dGV4dEF0dGFjaG1lbnQ=",
+ data
+ );
+
+ // "1 binary attachment" message.
+
+ attachments = await browser.messages.listAttachments(messages[2].id);
+ browser.test.assertEq("1 binary attachment", messages[2].subject);
+ browser.test.assertEq(1, attachments.length);
+
+ attachment = attachments[0];
+ browser.test.assertEq(
+ attachment.contentType,
+ "application/octet-stream"
+ );
+ browser.test.assertEq("test", attachment.name);
+ browser.test.assertEq("1.2", attachment.partName);
+ browser.test.assertEq(16, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[2].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test", file.name);
+ browser.test.assertEq(16, file.size);
+
+ browser.test.assertEq("binaryAttachment", await file.text());
+
+ reader = new FileReader();
+ data = await new Promise(resolve => {
+ reader.onload = e => resolve(e.target.result);
+ reader.readAsDataURL(file);
+ });
+
+ browser.test.assertEq(
+ "data:application/octet-stream;base64,YmluYXJ5QXR0YWNobWVudA==",
+ data
+ );
+
+ // "2 attachments" message.
+
+ attachments = await browser.messages.listAttachments(messages[3].id);
+ browser.test.assertEq("2 attachments", messages[3].subject);
+ browser.test.assertEq(2, attachments.length);
+
+ attachment = attachments[0];
+ browser.test.assertEq(
+ attachment.contentType,
+ "application/octet-stream"
+ );
+ browser.test.assertEq("test", attachment.name);
+ browser.test.assertEq("1.2", attachment.partName);
+ browser.test.assertEq(16, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[3].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test", file.name);
+ browser.test.assertEq(16, file.size);
+
+ browser.test.assertEq("binaryAttachment", await file.text());
+
+ attachment = attachments[1];
+ browser.test.assertEq("text/plain", attachment.contentType);
+ browser.test.assertEq("test.txt", attachment.name);
+ browser.test.assertEq("1.3", attachment.partName);
+ browser.test.assertEq(14, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[3].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test.txt", file.name);
+ browser.test.assertEq(14, file.size);
+
+ browser.test.assertEq("textAttachment", await file.text());
+
+ await browser.test.assertRejects(
+ browser.messages.listAttachments(0),
+ /^Message not found: \d+\.$/,
+ "Bad message ID should throw"
+ );
+ await browser.test.assertRejects(
+ browser.messages.getAttachmentFile(0, "1.2"),
+ /^Message not found: \d+\.$/,
+ "Bad message ID should throw"
+ );
+ browser.test.assertThrows(
+ () => browser.messages.getAttachmentFile(messages[3].id, "silly"),
+ /^Type error for parameter partName .* for messages\.getAttachmentFile\.$/,
+ "Bad part name should throw"
+ );
+ await browser.test.assertRejects(
+ browser.messages.getAttachmentFile(messages[3].id, "1.42"),
+ /Part 1.42 not found in message \d+\./,
+ "Non-existent part should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_IMAP,
+ },
+ async function test_messages_as_attachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [account] = await browser.accounts.list();
+ let testFolder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(5, messages.length);
+ let message = messages[4];
+
+ function validateMessage(msg, expectedValues) {
+ for (let expectedValueName in expectedValues) {
+ let value = msg[expectedValueName];
+ let expected = expectedValues[expectedValueName];
+ if (Array.isArray(expected)) {
+ browser.test.assertTrue(
+ Array.isArray(value),
+ `Value for ${expectedValueName} should be an Array.`
+ );
+ browser.test.assertEq(
+ expected.length,
+ value.length,
+ `Value for ${expectedValueName} should have the correct Array size.`
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(
+ expected[i],
+ value[i],
+ `Value for ${expectedValueName}[${i}] should be correct.`
+ );
+ }
+ } else if (expected instanceof Date) {
+ browser.test.assertTrue(
+ value instanceof Date,
+ `Value for ${expectedValueName} should be a Date.`
+ );
+ browser.test.assertEq(
+ expected.getTime(),
+ value.getTime(),
+ `Date value for ${expectedValueName} should be correct.`
+ );
+ } else {
+ browser.test.assertEq(
+ expected,
+ value,
+ `Value for ${expectedValueName} should be correct.`
+ );
+ }
+ }
+ }
+
+ // Request attachments.
+ let attachments = await browser.messages.listAttachments(message.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("1.2", attachments[0].partName);
+ browser.test.assertEq("1.3", attachments[1].partName);
+
+ browser.test.assertEq("message1.eml", attachments[0].name);
+ browser.test.assertEq("yellowPixel.png", attachments[1].name);
+
+ // Validate the returned MessageHeader for attached message1.eml.
+ let subMessage = attachments[0].message;
+ browser.test.assertTrue(
+ subMessage.id != message.id,
+ `Id of attached SubMessage (${subMessage.id}) should be different from the id of the outer message (${message.id})`
+ );
+ validateMessage(subMessage, {
+ date: new Date(958606367000),
+ author: "Superman <clark.kent@dailyplanet.com>",
+ recipients: ["Jimmy <jimmy.olsen@dailyplanet.com>"],
+ ccList: [],
+ bccList: [],
+ subject: "Test message 1",
+ new: false,
+ headersOnly: false,
+ flagged: false,
+ junk: false,
+ junkScore: 0,
+ headerMessageId: "sample-attached.eml@mime.sample",
+ size: 0,
+ tags: [],
+ external: true,
+ });
+
+ // Get attachments of sub-message messag1.eml.
+ let subAttachments = await browser.messages.listAttachments(
+ subMessage.id
+ );
+ browser.test.assertEq(4, subAttachments.length);
+ browser.test.assertEq("1.2.1.2", subAttachments[0].partName);
+ browser.test.assertEq("1.2.1.3", subAttachments[1].partName);
+ browser.test.assertEq("1.2.1.4", subAttachments[2].partName);
+ browser.test.assertEq("1.2.1.5", subAttachments[3].partName);
+
+ browser.test.assertEq("whitePixel.png", subAttachments[0].name);
+ browser.test.assertEq("greenPixel.png", subAttachments[1].name);
+ browser.test.assertEq("redPixel.png", subAttachments[2].name);
+ browser.test.assertEq("message2.eml", subAttachments[3].name);
+
+ // Validate the returned MessageHeader for sub-message message2.eml
+ // attached to sub-message message1.eml.
+ let subSubMessage = subAttachments[3].message;
+ browser.test.assertTrue(
+ ![message.id, subMessage.id].includes(subSubMessage.id),
+ `Id of attached SubSubMessage (${subSubMessage.id}) should be different from the id of the outer message (${message.id}) and from the SubMessage (${subMessage.id})`
+ );
+ validateMessage(subSubMessage, {
+ date: new Date(958519967000),
+ author: "Jimmy <jimmy.olsen@dailyplanet.com>",
+ recipients: ["Superman <clark.kent@dailyplanet.com>"],
+ ccList: [],
+ bccList: [],
+ subject: "Test message 2",
+ new: false,
+ headersOnly: false,
+ flagged: false,
+ junk: false,
+ junkScore: 0,
+ headerMessageId: "sample-nested-attached.eml@mime.sample",
+ size: 0,
+ tags: [],
+ external: true,
+ });
+
+ // Test getAttachmentFile().
+ // Note: This function has x-ray vision into sub-messages and can get
+ // any part inside the message, even if - technically - the attachments
+ // belong to subMessages. There is no difference between requesting
+ // part 1.2.1.2 from the main message or from message1.eml (part 1.2).
+ // X-ray vision from a sub-message back into a parent is not allowed.
+ let platform = await browser.runtime.getPlatformInfo();
+ let fileTests = [
+ {
+ partName: "1.2",
+ name: "message1.eml",
+ size:
+ platform.os != "win" &&
+ (account.type == "none" || account.type == "nntp")
+ ? 2517
+ : 2601,
+ text: "Message-ID: <sample-attached.eml@mime.sample>",
+ },
+ {
+ partName: "1.2.1.2",
+ name: "whitePixel.png",
+ size: 69,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC",
+ },
+ {
+ partName: "1.2.1.3",
+ name: "greenPixel.png",
+ size: 119,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+C76AoAAhUBJel4xsMAAAAASUVORK5CYII=",
+ },
+ {
+ partName: "1.2.1.4",
+ name: "redPixel.png",
+ size: 119,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+hgkAYAAbcApOp/9LEAAAAASUVORK5CYII=",
+ },
+ {
+ partName: "1.2.1.5",
+ name: "message2.eml",
+ size:
+ platform.os != "win" &&
+ (account.type == "none" || account.type == "nntp")
+ ? 838
+ : 867,
+ text: "Message-ID: <sample-nested-attached.eml@mime.sample>",
+ },
+ {
+ partName: "1.2.1.5.1.2",
+ name: "whitePixel.png",
+ size: 69,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC",
+ },
+ {
+ partName: "1.3",
+ name: "yellowPixel.png",
+ size: 119,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY/j/iQEABOUB8pypNlQAAAAASUVORK5CYII=",
+ },
+ ];
+ let testMessages = [
+ {
+ id: message.id,
+ expectedFileCounts: 7,
+ },
+ {
+ id: subMessage.id,
+ subPart: "1.2.",
+ expectedFileCounts: 5,
+ },
+ {
+ id: subSubMessage.id,
+ subPart: "1.2.1.5.",
+ expectedFileCounts: 1,
+ },
+ ];
+ for (let msg of testMessages) {
+ let fileCounts = 0;
+ for (let test of fileTests) {
+ if (msg.subPart && !test.partName.startsWith(msg.subPart)) {
+ await browser.test.assertRejects(
+ browser.messages.getAttachmentFile(msg.id, test.partName),
+ `Part ${test.partName} not found in message ${msg.id}.`,
+ "Sub-message should not be able to get parts from parent message"
+ );
+ continue;
+ }
+ fileCounts++;
+
+ let file = await browser.messages.getAttachmentFile(
+ msg.id,
+ test.partName
+ );
+
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq(test.name, file.name);
+ browser.test.assertEq(test.size, file.size);
+
+ if (test.text) {
+ browser.test.assertTrue(
+ (await file.text()).startsWith(test.text)
+ );
+ }
+
+ if (test.data) {
+ let reader = new FileReader();
+ let data = await new Promise(resolve => {
+ reader.onload = e => resolve(e.target.result);
+ reader.readAsDataURL(file);
+ });
+ browser.test.assertEq(
+ test.data,
+ data.replaceAll("\r\n", "\n").trim()
+ );
+ }
+ }
+ browser.test.assertEq(
+ msg.expectedFileCounts,
+ fileCounts,
+ "Should have requested to correct amount of attachment files."
+ );
+ }
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js
new file mode 100644
index 0000000000..2872a2141f
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js
@@ -0,0 +1,1073 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const OPENPGP_TEST_DIR = do_get_file("../../../../test/browser/openpgp");
+const OPENPGP_KEY_PATH = PathUtils.join(
+ OPENPGP_TEST_DIR.path,
+ "data",
+ "keys",
+ "alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+);
+
+/**
+ * Test the messages.getRaw and messages.getFull functions. Since each message
+ * is unique and there are minor differences between the account
+ * implementations, we don't compare exactly with a reference message.
+ */
+add_task(async function test_plain_mv2() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+ await createMessages(_folder, 1);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let [message] = messages;
+
+ // Expected message content:
+ // -------------------------
+ // From andy@anway.invalid
+ // Content-Type: text/plain; charset=ISO-8859-1; format=flowed
+ // Subject: Big Meeting Today
+ // From: "Andy Anway" <andy@anway.invalid>
+ // To: "Bob Bell" <bob@bell.invalid>
+ // Message-Id: <0@made.up.invalid>
+ // Date: Wed, 06 Nov 2019 22:37:40 +1300
+ //
+ // Hello Bob Bell!
+ //
+
+ browser.test.assertEq("Big Meeting Today", message.subject);
+ browser.test.assertEq(
+ '"Andy Anway" <andy@anway.invalid>',
+ message.author
+ );
+
+ // The msgHdr of NNTP messages have no recipients.
+ if (account.type != "nntp") {
+ browser.test.assertEq(
+ "Bob Bell <bob@bell.invalid>",
+ message.recipients[0]
+ );
+ }
+
+ let strMessage_1 = await browser.messages.getRaw(message.id);
+ browser.test.assertEq("string", typeof strMessage_1);
+ let strMessage_2 = await browser.messages.getRaw(message.id, {
+ data_format: "BinaryString",
+ });
+ browser.test.assertEq("string", typeof strMessage_2);
+ let fileMessage_3 = await browser.messages.getRaw(message.id, {
+ data_format: "File",
+ });
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(fileMessage_3 instanceof File);
+ // Since we do not have utf-8 chars in the test message, the returned BinaryString is
+ // identical to the return value of File.text().
+ let strMessage_3 = await fileMessage_3.text();
+
+ for (let strMessage of [strMessage_1, strMessage_2, strMessage_3]) {
+ // Fold Windows line-endings \r\n to \n.
+ strMessage = strMessage.replace(/\r/g, "");
+ browser.test.assertTrue(
+ strMessage.includes("Subject: Big Meeting Today\n")
+ );
+ browser.test.assertTrue(
+ strMessage.includes('From: "Andy Anway" <andy@anway.invalid>\n')
+ );
+ browser.test.assertTrue(
+ strMessage.includes('To: "Bob Bell" <bob@bell.invalid>\n')
+ );
+ browser.test.assertTrue(strMessage.includes("Hello Bob Bell!"));
+ }
+
+ // {
+ // "contentType": "message/rfc822",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"],
+ // "subject": ["Big Meeting Today"],
+ // "from": ["\"Andy Anway\" <andy@anway.invalid>"],
+ // "to": ["\"Bob Bell\" <bob@bell.invalid>"],
+ // "message-id": ["<0@made.up.invalid>"],
+ // "date": ["Wed, 06 Nov 2019 22:37:40 +1300"]
+ // },
+ // "partName": "",
+ // "size": 17,
+ // "parts": [
+ // {
+ // "body": "Hello Bob Bell!\n\n",
+ // "contentType": "text/plain",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"]
+ // },
+ // "partName": "1",
+ // "size": 17
+ // }
+ // ]
+ // }
+
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq(
+ "Big Meeting Today",
+ fullMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ '"Andy Anway" <andy@anway.invalid>',
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ '"Bob Bell" <bob@bell.invalid>',
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+ browser.test.assertEq("object", typeof fullMessage.parts[0]);
+ browser.test.assertEq(
+ "Hello Bob Bell!",
+ fullMessage.parts[0].body.trimRight()
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: { permissions: ["accountsRead", "messagesRead"] },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
+
+add_task(async function test_plain_mv3() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+ await createMessages(_folder, 1);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let [message] = messages;
+
+ // Expected message content:
+ // -------------------------
+ // From chris@clarke.invalid
+ // Content-Type: text/plain; charset=ISO-8859-1; format=flowed
+ // Subject: Small Party Tomorrow
+ // From: "Chris Clarke" <chris@clarke.invalid>
+ // To: "David Davol" <david@davol.invalid>
+ // Message-Id: <1@made.up.invalid>
+ // Date: Tue, 01 Feb 2000 01:00:00 +0100
+ //
+ // Hello David Davol!
+ //
+
+ browser.test.assertEq("Small Party Tomorrow", message.subject);
+ browser.test.assertEq(
+ '"Chris Clarke" <chris@clarke.invalid>',
+ message.author
+ );
+
+ // The msgHdr of NNTP messages have no recipients.
+ if (account.type != "nntp") {
+ browser.test.assertEq(
+ "David Davol <david@davol.invalid>",
+ message.recipients[0]
+ );
+ }
+
+ let fileMessage_1 = await browser.messages.getRaw(message.id);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(fileMessage_1 instanceof File);
+ // Since we do not have utf-8 chars in the test message, the returned
+ // BinaryString is identical to the return value of File.text().
+ let strMessage_1 = await fileMessage_1.text();
+
+ let strMessage_2 = await browser.messages.getRaw(message.id, {
+ data_format: "BinaryString",
+ });
+ browser.test.assertEq("string", typeof strMessage_2);
+
+ let fileMessage_3 = await browser.messages.getRaw(message.id, {
+ data_format: "File",
+ });
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(fileMessage_3 instanceof File);
+ let strMessage_3 = await fileMessage_3.text();
+
+ for (let strMessage of [strMessage_1, strMessage_2, strMessage_3]) {
+ // Fold Windows line-endings \r\n to \n.
+ strMessage = strMessage.replace(/\r/g, "");
+ browser.test.assertTrue(
+ strMessage.includes("Subject: Small Party Tomorrow\n")
+ );
+ browser.test.assertTrue(
+ strMessage.includes('From: "Chris Clarke" <chris@clarke.invalid>\n')
+ );
+ browser.test.assertTrue(
+ strMessage.includes('To: "David Davol" <david@davol.invalid>\n')
+ );
+ browser.test.assertTrue(strMessage.includes("Hello David Davol!"));
+ }
+
+ // {
+ // "contentType": "message/rfc822",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"],
+ // "subject": ["Small Party Tomorrow"],
+ // "from": ["\"Chris Clarke\" <chris@clarke.invalid>"],
+ // "to": ["\"David Davol\" <David Davol>"],
+ // "message-id": ["<1@made.up.invalid>"],
+ // "date": ["Tue, 01 Feb 2000 01:00:00 +0100"]
+ // },
+ // "partName": "",
+ // "size": 20,
+ // "parts": [
+ // {
+ // "body": "David Davol!\n\n",
+ // "contentType": "text/plain",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"]
+ // },
+ // "partName": "1",
+ // "size": 20
+ // }
+ // ]
+ // }
+
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq(
+ "Small Party Tomorrow",
+ fullMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ '"Chris Clarke" <chris@clarke.invalid>',
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ '"David Davol" <david@davol.invalid>',
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+ browser.test.assertEq("object", typeof fullMessage.parts[0]);
+ browser.test.assertEq(
+ "Hello David Davol!",
+ fullMessage.parts[0].body.trimRight()
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ manifest_version: 3,
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
+
+/**
+ * Test that mime parsers for all message types retrieve the correctly decoded
+ * headers and bodies. Bodies should no not be returned, if it is an attachment.
+ * Sizes are not checked for.
+ */
+add_task(async function test_encoding() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ // Main body with disposition inline, base64 encoded,
+ // subject is UTF-8 encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample01.eml").path
+ );
+ // A multipart/mixed mime message, to header is iso-8859-1 encoded word,
+ // body is quoted printable with iso-8859-1, attachments with different names
+ // and filenames.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample02.eml").path
+ );
+ // Message with attachment only, From header is iso-8859-1 encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample03.eml").path
+ );
+ // Message with koi8-r + base64 encoded body, subject is koi8-r encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample04.eml").path
+ );
+ // Message with windows-1251 + base64 encoded body, subject is windows-1251
+ // encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample05.eml").path
+ );
+ // Message without plain/text content-type.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample06.eml").path
+ );
+ // A multipart/alternative message without plain/text content-type.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample07.eml").path
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ let expectedData = {
+ "01.eml@mime.sample": {
+ msgHeaders: {
+ subject: "αλφάβητο",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["αλφάβητο"],
+ date: ["Thu, 27 May 2021 21:23:35 +0100"],
+ "message-id": ["<01.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ["text/plain; charset=utf-8;"],
+ "content-transfer-encoding": ["base64"],
+ "content-disposition": ["inline"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "Άλφα\n",
+ headers: {
+ "content-type": ["text/plain; charset=utf-8;"],
+ },
+ },
+ ],
+ },
+ },
+ "02.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Test message from Microsoft Outlook 00",
+ author: '"Doug Sauder" <doug@example.com>',
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ['"Doug Sauder" <doug@example.com>'],
+ to: ["Heinz Müller <mueller@example.com>"],
+ subject: ["Test message from Microsoft Outlook 00"],
+ date: ["Wed, 17 May 2000 19:32:47 -0400"],
+ "message-id": ["<02.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": [
+ 'multipart/mixed; boundary="----=_NextPart_000_0002_01BFC036.AE309650"',
+ ],
+ "x-priority": ["3 (Normal)"],
+ "x-msmail-priority": ["Normal"],
+ "x-mailer": [
+ "Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)",
+ ],
+ importance: ["Normal"],
+ "x-mimeole": ["Produced By Microsoft MimeOLE V5.00.2314.1300"],
+ },
+ parts: [
+ {
+ contentType: "multipart/mixed",
+ partName: "1",
+ size: 0,
+ headers: {
+ "content-type": [
+ 'multipart/mixed; boundary="----=_NextPart_000_0002_01BFC036.AE309650"',
+ ],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1.1",
+ size: 0,
+ body: `\nDie Hasen und die Frösche \n \n`,
+ headers: {
+ "content-type": ['text/plain; charset="iso-8859-1"'],
+ },
+ },
+ {
+ contentType: "image/png",
+ partName: "1.2",
+ size: 0,
+ name: "blueball2.png",
+ headers: {
+ "content-type": ['image/png; name="blueball1.png"'],
+ },
+ },
+ {
+ contentType: "image/png",
+ partName: "1.3",
+ size: 0,
+ name: "greenball.png",
+ headers: {
+ "content-type": ['image/png; name="greenball.png"'],
+ },
+ },
+ {
+ contentType: "image/png",
+ partName: "1.4",
+ size: 0,
+ name: "redball.png",
+ headers: {
+ "content-type": ["image/png"],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ "03.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Test message from Microsoft Outlook 00",
+ author: "Heinz Müller <mueller@example.com>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Heinz Müller <mueller@example.com>"],
+ to: ['"Joe Blow" <jblow@example.com>'],
+ subject: ["Test message from Microsoft Outlook 00"],
+ date: ["Wed, 17 May 2000 19:35:05 -0400"],
+ "message-id": ["<03.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ['image/png; name="doubelspace ball.png"'],
+ "content-transfer-encoding": ["base64"],
+ "content-disposition": [
+ 'attachment; filename="doubelspace ball.png"',
+ ],
+ "x-priority": ["3 (Normal)"],
+ "x-msmail-priority": ["Normal"],
+ "x-mailer": [
+ "Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)",
+ ],
+ importance: ["Normal"],
+ "x-mimeole": ["Produced By Microsoft MimeOLE V5.00.2314.1300"],
+ },
+ parts: [
+ {
+ contentType: "image/png",
+ name: "doubelspace ball.png",
+ partName: "1",
+ size: 0,
+ headers: {
+ "content-type": ['image/png; name="doubelspace ball.png"'],
+ },
+ },
+ ],
+ },
+ },
+ "04.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Алфавит",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["Алфавит"],
+ date: ["Sun, 27 May 2001 21:23:35 +0100"],
+ "message-id": ["<04.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ["text/plain; charset=koi8-r;"],
+ "content-transfer-encoding": ["base64"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "Вопрос\n",
+ headers: {
+ "content-type": ["text/plain; charset=koi8-r;"],
+ },
+ },
+ ],
+ },
+ },
+ "05.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Алфавит",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["Алфавит"],
+ date: ["Sun, 27 May 2001 21:23:35 +0100"],
+ "message-id": ["<05.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ["text/plain; charset=windows-1251;"],
+ "content-transfer-encoding": ["base64"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "Вопрос\n",
+ headers: {
+ "content-type": ["text/plain; charset=windows-1251;"],
+ },
+ },
+ ],
+ },
+ },
+ "06.eml@mime.sample": {
+ msgHeaders: {
+ subject: "I have no content type",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["I have no content type"],
+ date: ["Sun, 27 May 2001 21:23:35 +0100"],
+ "message-id": ["<06.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "No content type\n",
+ headers: {
+ "content-type": ["text/plain"],
+ },
+ },
+ ],
+ },
+ },
+ "07.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Default content-types",
+ author: "Doug Sauder <dwsauder@example.com>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Doug Sauder <dwsauder@example.com>"],
+ to: ["Heinz <mueller@example.com>"],
+ subject: ["Default content-types"],
+ date: ["Fri, 19 May 2000 00:29:55 -0400"],
+ "message-id": ["<07.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": [
+ 'multipart/alternative; boundary="=====================_714967308==_.ALT"',
+ ],
+ },
+ parts: [
+ {
+ contentType: "multipart/alternative",
+ partName: "1",
+ size: 0,
+ headers: {
+ "content-type": [
+ 'multipart/alternative; boundary="=====================_714967308==_.ALT"',
+ ],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1.1",
+ size: 0,
+ body: "Die Hasen\n",
+ headers: {
+ "content-type": ["text/plain"],
+ },
+ },
+ {
+ contentType: "text/html",
+ partName: "1.2",
+ size: 0,
+ body: "<html><body><b>Die Hasen</b></body></html>\n",
+ headers: {
+ "content-type": ["text/html"],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ function checkMsgHeaders(expected, actual) {
+ // Check if all expected properties are there.
+ for (let property of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected.hasOwnProperty(property),
+ actual.hasOwnProperty(property),
+ `expected property ${property} is present`
+ );
+ // Check property content.
+ browser.test.assertEq(
+ expected[property],
+ actual[property],
+ `property ${property} is correct`
+ );
+ }
+ }
+
+ function checkMsgParts(expected, actual) {
+ // Check if all expected properties are there.
+ for (let property of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected.hasOwnProperty(property),
+ actual.hasOwnProperty(property),
+ `expected property ${property} is present`
+ );
+ if (
+ ["parts", "headers", "size"].includes(property) ||
+ (["body"].includes(property) && expected[property] == "")
+ ) {
+ continue;
+ }
+ // Check property content.
+ browser.test.assertEq(
+ JSON.stringify(expected[property].replaceAll("\r\n", "\n")),
+ JSON.stringify(actual[property].replaceAll("\r\n", "\n")),
+ `property ${property} is correct`
+ );
+ }
+
+ // Check for unexpected properties.
+ for (let property of Object.keys(actual)) {
+ browser.test.assertEq(
+ expected.hasOwnProperty(property),
+ actual.hasOwnProperty(property),
+ `property ${property} is expected`
+ );
+ }
+
+ // Check if all expected headers are there.
+ if (expected.headers) {
+ for (let header of Object.keys(expected.headers)) {
+ browser.test.assertEq(
+ expected.headers.hasOwnProperty(header),
+ actual.headers.hasOwnProperty(header),
+ `expected header ${header} is present`
+ );
+ // Check header content.
+ // Note: jsmime does not eat TABs after a CLRF.
+ browser.test.assertEq(
+ expected.headers[header].toString().replaceAll("\t", " "),
+ actual.headers[header].toString().replaceAll("\t", " "),
+ `header ${header} is correct`
+ );
+ }
+ // Check for unexpected headers.
+ for (let header of Object.keys(actual.headers)) {
+ browser.test.assertEq(
+ expected.headers.hasOwnProperty(header),
+ actual.headers.hasOwnProperty(header),
+ `header ${header} is expected`
+ );
+ }
+ }
+
+ // Check sub-parts.
+ browser.test.assertEq(
+ Array.isArray(expected.parts),
+ Array.isArray(actual.parts),
+ `has sub-parts`
+ );
+ if (Array.isArray(expected.parts)) {
+ browser.test.assertEq(
+ expected.parts.length,
+ actual.parts.length,
+ "number of parts"
+ );
+ for (let i in expected.parts) {
+ checkMsgParts(expected.parts[i], actual.parts[i]);
+ }
+ }
+ }
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(7, messages.length);
+
+ for (let message of messages) {
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.assertEq("object", typeof fullMessage);
+
+ let expected = expectedData[message.headerMessageId];
+ checkMsgHeaders(expected.msgHeaders, message);
+ checkMsgParts(expected.msgParts, fullMessage);
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: { permissions: ["accountsRead", "messagesRead"] },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_openpgp() {
+ let _account = createAccount();
+ let _identity = addIdentity(_account);
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ // Load an encrypted message.
+
+ let messagePath = PathUtils.join(
+ OPENPGP_TEST_DIR.path,
+ "data",
+ "eml",
+ "unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml"
+ );
+ await createMessageFromFile(_folder, messagePath);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [account] = await browser.accounts.list();
+ let folder = account.folders.find(f => f.name == "test1");
+
+ // Read the message, without the key set up. The headers should be
+ // readable, but not the message itself.
+
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let [message] = messages;
+ browser.test.assertEq("...", message.subject);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ message.author
+ );
+ browser.test.assertEq("alice@openpgp.example", message.recipients[0]);
+
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq("...", fullMessage.headers.subject[0]);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ "alice@openpgp.example",
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+
+ let part = fullMessage.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("multipart/encrypted", part.contentType);
+ browser.test.assertEq(undefined, part.parts);
+
+ // Now set up the key and read the message again. It should all be
+ // there this time.
+
+ await window.sendMessage("load key");
+
+ ({ messages } = await browser.messages.list(folder));
+ browser.test.assertEq(1, messages.length);
+ [message] = messages;
+ browser.test.assertEq("...", message.subject);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ message.author
+ );
+ browser.test.assertEq("alice@openpgp.example", message.recipients[0]);
+
+ fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq("...", fullMessage.headers.subject[0]);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ "alice@openpgp.example",
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+
+ part = fullMessage.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("multipart/encrypted", part.contentType);
+ browser.test.assertTrue(Array.isArray(part.parts));
+ browser.test.assertEq(1, part.parts.length);
+
+ part = part.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("multipart/fake-container", part.contentType);
+ browser.test.assertTrue(Array.isArray(part.parts));
+ browser.test.assertEq(1, part.parts.length);
+
+ part = part.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("text/plain", part.contentType);
+ browser.test.assertEq(
+ "Sundays are nothing without callaloo.",
+ part.body.trimRight()
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("load key");
+ info(`Adding key from ${OPENPGP_KEY_PATH}`);
+ await OpenPGPTestUtils.initOpenPGP();
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ null,
+ new FileUtils.File(OPENPGP_KEY_PATH)
+ );
+ _identity.setUnicharAttribute("openpgp_key_id", id);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+ }
+);
+
+add_task(async function test_attached_message_with_missing_headers() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/attachedMessageWithMissingHeaders.eml").path
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let msg = messages[0];
+ let attachments = await browser.messages.listAttachments(msg.id);
+ browser.test.assertEq(
+ attachments.length,
+ 1,
+ "Should have found the correct number of attachments"
+ );
+
+ let attachedMessage = attachments[0].message;
+ browser.test.assertTrue(
+ !!attachedMessage,
+ "Should have found an attached message"
+ );
+ browser.test.assertEq(
+ attachedMessage.date.getTime(),
+ 0,
+ "The date should be correct"
+ );
+ browser.test.assertEq(
+ attachedMessage.subject,
+ "",
+ "The subject should be empty"
+ );
+ browser.test.assertEq(
+ attachedMessage.author,
+ "",
+ "The author should be empty"
+ );
+ browser.test.assertEq(
+ attachedMessage.headerMessageId,
+ "sample-attached.eml@mime.sample",
+ "The headerMessageId should be correct"
+ );
+ window.assertDeepEqual(
+ attachedMessage.recipients,
+ [],
+ "The recipients should be correct"
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js
new file mode 100644
index 0000000000..dac01fa514
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var subFolders;
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function setup() {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ subFolders = {
+ test1: await createSubfolder(rootFolder, "test1"),
+ test2: await createSubfolder(rootFolder, "test2"),
+ test3: await createSubfolder(rootFolder, "test3"),
+ attachment: await createSubfolder(rootFolder, "attachment"),
+ };
+ await createMessages(subFolders.test1, 5);
+ let textAttachment = {
+ body: "textAttachment",
+ filename: "test.txt",
+ contentType: "text/plain",
+ };
+ await createMessages(subFolders.attachment, {
+ count: 1,
+ subject: "Msg with text attachment",
+ attachments: [textAttachment],
+ });
+ }
+);
+
+// In this test we'll move and copy some messages around between
+// folders. Every operation should result in the message's id property
+// changing to a never-seen-before value.
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_identifiers() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [{ folders }] = await browser.accounts.list();
+ let testFolder1 = folders.find(f => f.name == "test1");
+ let testFolder2 = folders.find(f => f.name == "test2");
+ let testFolder3 = folders.find(f => f.name == "test3");
+
+ let { messages } = await browser.messages.list(testFolder1);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ "message count in testFolder1"
+ );
+ browser.test.assertEq(1, messages[0].id);
+ browser.test.assertEq(2, messages[1].id);
+ browser.test.assertEq(3, messages[2].id);
+ browser.test.assertEq(4, messages[3].id);
+ browser.test.assertEq(5, messages[4].id);
+
+ let subjects = messages.map(m => m.subject);
+
+ // Move two messages. We could do this in one operation, but to be
+ // sure of the order, do it in separate operations.
+
+ await browser.messages.move([1], testFolder2);
+ await browser.messages.move([3], testFolder2);
+
+ ({ messages } = await browser.messages.list(testFolder1));
+ browser.test.assertEq(
+ 3,
+ messages.length,
+ "message count in testFolder1"
+ );
+ browser.test.assertEq(2, messages[0].id);
+ browser.test.assertEq(4, messages[1].id);
+ browser.test.assertEq(5, messages[2].id);
+ browser.test.assertEq(subjects[1], messages[0].subject);
+ browser.test.assertEq(subjects[3], messages[1].subject);
+ browser.test.assertEq(subjects[4], messages[2].subject);
+
+ ({ messages } = await browser.messages.list(testFolder2));
+ browser.test.assertEq(
+ 2,
+ messages.length,
+ "message count in testFolder2"
+ );
+ browser.test.assertEq(6, messages[0].id, "new id created");
+ browser.test.assertEq(7, messages[1].id, "new id created");
+ browser.test.assertEq(subjects[0], messages[0].subject);
+ browser.test.assertEq(subjects[2], messages[1].subject);
+
+ // Copy one message.
+
+ await browser.messages.copy([6], testFolder3);
+
+ ({ messages } = await browser.messages.list(testFolder2));
+ browser.test.assertEq(
+ 2,
+ messages.length,
+ "message count in testFolder2"
+ );
+ browser.test.assertEq(6, messages[0].id);
+ browser.test.assertEq(7, messages[1].id);
+ browser.test.assertEq(subjects[0], messages[0].subject);
+ browser.test.assertEq(subjects[2], messages[1].subject);
+
+ ({ messages } = await browser.messages.list(testFolder3));
+ browser.test.assertEq(
+ 1,
+ messages.length,
+ "message count in testFolder3"
+ );
+ browser.test.assertEq(8, messages[0].id, "new id created");
+ browser.test.assertEq(subjects[0], messages[0].subject);
+
+ // Move the copied message back to the previous folder. There should
+ // now be two copies there, each with their own ID.
+
+ await browser.messages.move([8], testFolder2);
+
+ ({ messages } = await browser.messages.list(testFolder2));
+ browser.test.assertEq(
+ 3,
+ messages.length,
+ "message count in testFolder2"
+ );
+ browser.test.assertEq(6, messages[0].id);
+ browser.test.assertEq(7, messages[1].id);
+ browser.test.assertEq(
+ 9,
+ messages[2].id,
+ "new id created, not a duplicate"
+ );
+ browser.test.assertEq(subjects[0], messages[0].subject);
+ browser.test.assertEq(subjects[2], messages[1].subject);
+ browser.test.assertEq(
+ subjects[0],
+ messages[2].subject,
+ "same message as another in this folder"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesMove", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+// In this test we'll remove an attachment from a message and its id property
+// should not change. (Bug 1645595). Test does not work with IMAP test server,
+// which has issues with attachments.
+add_task(
+ {
+ skip_if: () => IS_NNTP || IS_IMAP,
+ },
+ async function test_attachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let id;
+
+ browser.test.onMessage.addListener(async () => {
+ // This listener gets called once the attachment has been removed.
+ // Make sure we still get the message and it no longer has the
+ // attachment.
+ let modifiedMessage = await browser.messages.getFull(id);
+ browser.test.assertEq(
+ "Msg with text attachment",
+ modifiedMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ "text/x-moz-deleted",
+ modifiedMessage.parts[0].parts[1].contentType
+ );
+ browser.test.assertEq(
+ "Deleted: test.txt",
+ modifiedMessage.parts[0].parts[1].name
+ );
+ browser.test.notifyPass("finished");
+ });
+
+ let [{ folders }] = await browser.accounts.list();
+ let testFolder = folders.find(f => f.name == "attachment");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(1, messages.length);
+ id = messages[0].id;
+
+ let originalMessage = await browser.messages.getFull(id);
+ browser.test.assertEq(
+ "Msg with text attachment",
+ originalMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ "text/plain",
+ originalMessage.parts[0].parts[1].contentType
+ );
+ browser.test.assertEq(
+ "test.txt",
+ originalMessage.parts[0].parts[1].name
+ );
+ browser.test.sendMessage("removeAttachment", id);
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ let observer = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "attachment-delete-msgkey-changed") {
+ extension.sendMessage();
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "attachment-delete-msgkey-changed");
+
+ extension.onMessage("removeAttachment", () => {
+ let msgHdr = subFolders.attachment.messages.getNext();
+ let msgUri = msgHdr.folder.getUriForMsg(msgHdr);
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ messenger.detachAttachment(
+ "text/plain",
+ `${msgUri}?part=1.2&filename=test.txt`,
+ "test.txt",
+ msgUri,
+ false /* do not save */,
+ true /* do not ask */
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js
new file mode 100644
index 0000000000..c3bef58835
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+add_task(async function test_import() {
+ let _account = createAccount();
+ await createSubfolder(_account.incomingServer.rootFolder, "test1");
+ await createSubfolder(_account.incomingServer.rootFolder, "test2");
+ await createSubfolder(_account.incomingServer.rootFolder, "test3");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ async function do_import(expected, file, folder, options) {
+ let msg = await browser.messages.import(file, folder, options);
+ browser.test.assertEq(
+ "alternative.eml@mime.sample",
+ msg.headerMessageId,
+ "should find the correct message after import"
+ );
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(
+ 1,
+ messages.length,
+ "should find the imported message in the destination folder"
+ );
+ for (let [propName, value] of Object.entries(expected)) {
+ window.assertDeepEqual(
+ value,
+ messages[0][propName],
+ `Property ${propName} should be correct`
+ );
+ }
+ }
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+ let [account] = accounts;
+ let folder1 = account.folders.find(f => f.name == "test1");
+ let folder2 = account.folders.find(f => f.name == "test2");
+ let folder3 = account.folders.find(f => f.name == "test3");
+ browser.test.assertTrue(folder1, "Test folder should exist");
+ browser.test.assertTrue(folder2, "Test folder should exist");
+ browser.test.assertTrue(folder3, "Test folder should exist");
+
+ let [emlFileContent] = await window.sendMessage(
+ "getFileContent",
+ "messages/alternative.eml"
+ );
+ let file = new File([emlFileContent], "test.eml");
+
+ if (account.type == "nntp" || account.type == "imap") {
+ // nsIMsgCopyService.copyFileMessage() not implemented for NNTP.
+ // offline/online behavior of IMAP nsIMsgCopyService.copyFileMessage()
+ // is too erratic to be supported ATM.
+ await browser.test.assertRejects(
+ browser.messages.import(file, folder1),
+ `browser.messenger.import() is not supported for ${account.type} accounts`,
+ "Should throw for unsupported accounts"
+ );
+ } else {
+ await do_import(
+ {
+ new: false,
+ read: false,
+ flagged: false,
+ },
+ file,
+ folder1
+ );
+ await do_import(
+ {
+ new: true,
+ read: true,
+ flagged: true,
+ tags: ["$label1"],
+ },
+ file,
+ folder2,
+ {
+ new: true,
+ read: true,
+ flagged: true,
+ tags: ["$label1"],
+ }
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "messagesImport"],
+ },
+ });
+
+ extension.onMessage("getFileContent", async path => {
+ let raw = await IOUtils.read(do_get_file(path).path);
+ extension.sendMessage(MailStringUtils.uint8ArrayToByteString(raw));
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js
new file mode 100644
index 0000000000..81011374e3
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js
@@ -0,0 +1,656 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+Services.prefs.setBoolPref(
+ "mail.server.server1.autosync_offline_stores",
+ false
+);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Function to start an event page extension (MV3), which can be called whenever
+// the main test is about to trigger an event. The extension terminates its
+// background and listens for that single event, verifying it is waking up correctly.
+async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.messages[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "event_page_extension@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesRead", "messagesMove"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "messages", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+}
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_move_copy_delete() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = {
+ test1: await createSubfolder(rootFolder, "test1"),
+ test2: await createSubfolder(rootFolder, "test2"),
+ test3: await createSubfolder(rootFolder, "test3"),
+ trash: rootFolder.getChildNamed("Trash"),
+ };
+ await createMessages(subFolders.trash, 4);
+ // 4 messages must be created before this line or test_move_copy_delete will break.
+ await createMessages(subFolders.test1, 5);
+
+ let files = {
+ "background.js": async () => {
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ async function checkMessagesInFolder(expectedKeys, folder) {
+ let expectedSubjects = expectedKeys.map(k => messages[k].subject);
+
+ let { messages: actualMessages } = await browser.messages.list(
+ folder
+ );
+ browser.test.log("expect: " + expectedSubjects.sort());
+ browser.test.log(
+ "actual: " + actualMessages.map(m => m.subject).sort()
+ );
+
+ browser.test.assertEq(
+ expectedSubjects.sort().toString(),
+ actualMessages
+ .map(m => m.subject)
+ .sort()
+ .toString(),
+ "Messages on server should be correct"
+ );
+ for (let m of actualMessages) {
+ browser.test.assertTrue(
+ expectedSubjects.includes(m.subject),
+ `${m.subject} at ${m.id}`
+ );
+ messages[m.subject.split(" ")[0]].id = m.id;
+ }
+
+ // Return the messages for convenience.
+ return actualMessages;
+ }
+
+ function newMovePromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = 0;
+ let seenSrcMsgs = [];
+ let seenDstMsgs = [];
+ const listener = (srcMsgs, dstMsgs) => {
+ seenEvents++;
+ seenSrcMsgs.push(...srcMsgs.messages);
+ seenDstMsgs.push(...dstMsgs.messages);
+ if (seenEvents == numberOfEventsToCollapse) {
+ browser.messages.onMoved.removeListener(listener);
+ resolve({ srcMsgs: seenSrcMsgs, dstMsgs: seenDstMsgs });
+ }
+ };
+ browser.messages.onMoved.addListener(listener);
+ });
+ }
+
+ function newCopyPromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = 0;
+ let seenSrcMsgs = [];
+ let seenDstMsgs = [];
+ const listener = (srcMsgs, dstMsgs) => {
+ seenEvents++;
+ seenSrcMsgs.push(...srcMsgs.messages);
+ seenDstMsgs.push(...dstMsgs.messages);
+ if (seenEvents == numberOfEventsToCollapse) {
+ browser.messages.onCopied.removeListener(listener);
+ resolve({ srcMsgs: seenSrcMsgs, dstMsgs: seenDstMsgs });
+ }
+ };
+ browser.messages.onCopied.addListener(listener);
+ });
+ }
+
+ function newDeletePromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = 0;
+ let seenMsgs = [];
+ const listener = msgs => {
+ seenEvents++;
+ seenMsgs.push(...msgs.messages);
+ if (seenEvents == numberOfEventsToCollapse) {
+ browser.messages.onDeleted.removeListener(listener);
+ resolve(seenMsgs);
+ }
+ };
+ browser.messages.onDeleted.addListener(listener);
+ });
+ }
+
+ async function checkEventInformation(
+ infoPromise,
+ expected,
+ messages,
+ dstFolder
+ ) {
+ let eventInfo = await infoPromise;
+ browser.test.assertEq(eventInfo.srcMsgs.length, expected.length);
+ browser.test.assertEq(eventInfo.dstMsgs.length, expected.length);
+ for (let msg of expected) {
+ let idx = eventInfo.srcMsgs.findIndex(
+ e => e.id == messages[msg].id
+ );
+ browser.test.assertEq(
+ eventInfo.srcMsgs[idx].subject,
+ messages[msg].subject
+ );
+ browser.test.assertEq(
+ eventInfo.dstMsgs[idx].subject,
+ messages[msg].subject
+ );
+ browser.test.assertEq(
+ eventInfo.dstMsgs[idx].folder.path,
+ dstFolder.path
+ );
+ }
+ }
+
+ let [accountId] = await window.sendMessage("getAccount");
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder1 = folders.find(f => f.name == "test1");
+ let testFolder2 = folders.find(f => f.name == "test2");
+ let testFolder3 = folders.find(f => f.name == "test3");
+ let trashFolder = folders.find(f => f.name == "Trash");
+
+ let { messages: folder1Messages } = await browser.messages.list(
+ testFolder1
+ );
+
+ // Since the ID of a message changes when it is moved, track by subject.
+ let messages = {};
+ for (let m of folder1Messages) {
+ messages[m.subject.split(" ")[0]] = { id: m.id, subject: m.subject };
+ }
+
+ // To help with debugging, output the IDs of our five messages.
+ browser.test.log(JSON.stringify(messages)); // Red:1, Green:2, Blue:3, My:4, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move one message to another folder.");
+ let movePromise = newMovePromise();
+ let primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.Red.id], testFolder2)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Red"],
+ messages,
+ testFolder2
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+
+ await checkMessagesInFolder(
+ ["Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder(["Red"], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:6, Green:2, Blue:3, My:4, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> And back again.");
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.Red.id], testFolder1)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Red"],
+ messages,
+ testFolder1
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:2, Blue:3, My:4, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move two messages to another folder.");
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move(
+ [messages.Green.id, messages.My.id],
+ testFolder2
+ )
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green", "My"],
+ messages,
+ testFolder2
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+
+ await checkMessagesInFolder(["Red", "Blue", "Happy"], testFolder1);
+ await checkMessagesInFolder(["Green", "My"], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:8, Blue:3, My:9, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move one back again: " + messages.My.id);
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.My.id], testFolder1)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(movePromise, ["My"], messages, testFolder1);
+
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder(["Green"], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:8, Blue:3, My:10, Happy:5
+
+ browser.test.log("");
+ browser.test.log(
+ " --> Move messages from different folders to a third folder."
+ );
+ // We collapse the two events (one for each source folder).
+ movePromise = newMovePromise(2);
+ await browser.messages.move(
+ [messages.Green.id, messages.My.id],
+ testFolder3
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green", "My"],
+ messages,
+ testFolder3
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ await checkMessagesInFolder(["Red", "Blue", "Happy"], testFolder1);
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder(["Green", "My"], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:11, Blue:3, My:12, Happy:5
+
+ browser.test.log("");
+ browser.test.log(
+ " --> The following tests should not trigger move events."
+ );
+ let listenerCalls = 0;
+ const listenerFunc = () => {
+ listenerCalls++;
+ };
+ browser.messages.onMoved.addListener(listenerFunc);
+
+ // Move a message to the folder it's already in.
+ await browser.messages.move([messages.Green.id], testFolder3);
+ await checkMessagesInFolder(["Green", "My"], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:11, Blue:3, My:12, Happy:5
+
+ // Move no messages.
+ await browser.messages.move([], testFolder3);
+ await checkMessagesInFolder(["Red", "Blue", "Happy"], testFolder1);
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder(["Green", "My"], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:11, Blue:3, My:12, Happy:5
+
+ // Move a non-existent message.
+ await browser.test.assertRejects(
+ browser.messages.move([9999], testFolder1),
+ /Error moving message/,
+ "something should happen"
+ );
+
+ // Move to a non-existent folder.
+ await browser.test.assertRejects(
+ browser.messages.move([messages.Red.id], {
+ accountId,
+ path: "/missing",
+ }),
+ /Error moving message/,
+ "something should happen"
+ );
+
+ // Check that no move event was triggered.
+ browser.messages.onMoved.removeListener(listenerFunc);
+ browser.test.assertEq(0, listenerCalls);
+
+ browser.test.log("");
+ browser.test.log(
+ " --> Put everything back where it was at the start of the test."
+ );
+ movePromise = newMovePromise();
+ await browser.messages.move(
+ [messages.My.id, messages.Green.id],
+ testFolder1
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green", "My"],
+ messages,
+ testFolder1
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder([], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:13, Blue:3, My:14, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Copy one message to another folder.");
+ let copyPromise = newCopyPromise();
+ let primedCopyInfo = await capturePrimedEvent("onCopied", () =>
+ browser.messages.copy([messages.Happy.id], testFolder2)
+ );
+ window.assertDeepEqual(
+ await copyPromise,
+ {
+ srcMsgs: primedCopyInfo[0].messages,
+ dstMsgs: primedCopyInfo[1].messages,
+ },
+ "The primed and non-primed onCopied events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ copyPromise,
+ ["Happy"],
+ messages,
+ testFolder2
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ let { messages: folder2Messages } = await browser.messages.list(
+ testFolder2
+ );
+ browser.test.assertEq(1, folder2Messages.length);
+ browser.test.assertEq(
+ messages.Happy.subject,
+ folder2Messages[0].subject
+ );
+ browser.test.assertTrue(folder2Messages[0].id != messages.Happy.id);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:13, Blue:3, My:14, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Delete the copied message.");
+ let deletePromise = newDeletePromise();
+ let primedDeleteLog = await capturePrimedEvent("onDeleted", () =>
+ browser.messages.delete([folder2Messages[0].id], true)
+ );
+ // Check if the delete information is correct.
+ let deleteLog = await deletePromise;
+ window.assertDeepEqual(
+ [
+ {
+ id: null,
+ messages: deleteLog,
+ },
+ ],
+ primedDeleteLog,
+ "The primed and non-primed onDeleted events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(1, deleteLog.length);
+ browser.test.assertEq(folder2Messages[0].id, deleteLog[0].id);
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ // Check if the message was deleted.
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder([], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:13, Blue:3, My:14, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move a message to the trash.");
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.Green.id], trashFolder)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green"],
+ messages,
+ trashFolder
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder([], testFolder3);
+
+ let { messages: trashFolderMessages } = await browser.messages.list(
+ trashFolder
+ );
+ browser.test.assertTrue(
+ trashFolderMessages.find(m => m.subject == messages.Green.subject)
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [
+ "accountsRead",
+ "messagesMove",
+ "messagesRead",
+ "messagesDelete",
+ ],
+ browser_specific_settings: {
+ gecko: { id: "messages.move@mochi.test" },
+ },
+ },
+ });
+
+ extension.onMessage("forceServerUpdate", async foldername => {
+ if (IS_IMAP) {
+ let folder = rootFolder
+ .getChildNamed(foldername)
+ .QueryInterface(Ci.nsIMsgImapMailFolder);
+
+ let listener = new PromiseTestUtils.PromiseUrlListener();
+ folder.updateFolderWithListener(null, listener);
+ await listener.promise;
+
+ // ...and download for offline use.
+ let promiseUrlListener = new PromiseTestUtils.PromiseUrlListener();
+ folder.downloadAllForOffline(promiseUrlListener, null);
+ await promiseUrlListener.promise;
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution in the main test, after the event page extension is
+ // ready to capture the event with deactivated background.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("getAccount", () => {
+ extension.sendMessage(account.key);
+ });
+
+ // The sync between the IMAP Service and the fake IMAP Server is partially
+ // broken: It is not possible to re-move messages cleanly. The move commands
+ // are send to the server about 500ms after the local operation and the server
+ // will update the local state wrongly.
+ // In this test we enforce a server update after each operation. If this is
+ // still causing intermittent fails, enable the offline mode for this test.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1797764#c24
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js
new file mode 100644
index 0000000000..5c8e62872d
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Function to start an event page extension (MV3), which can be called whenever
+// the main test is about to trigger an event. The extension terminates its
+// background and listens for that single event, verifying it is waking up correctly.
+async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.messages[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "event_page_extension@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesRead", "messagesMove"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "messages", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+}
+
+add_task(async function () {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let inbox = await createSubfolder(account.incomingServer.rootFolder, "test1");
+
+ let files = {
+ "background.js": async () => {
+ browser.messages.onNewMailReceived.addListener((folder, messageList) => {
+ window.assertDeepEqual(
+ { accountId: "account1", name: "test1", path: "/test1" },
+ folder
+ );
+ browser.test.sendMessage("onNewMailReceived event received", [
+ folder,
+ messageList,
+ ]);
+ });
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+
+ // Create a new message.
+
+ await createMessages(inbox, 1);
+ inbox.hasNewMessages = true;
+ inbox.setNumNewMessages(1);
+ inbox.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NewMail;
+
+ let inboxMessages = [...inbox.messages];
+ let newMessages = await extension.awaitMessage(
+ "onNewMailReceived event received"
+ );
+ equal(newMessages[1].messages.length, 1);
+ equal(newMessages[1].messages[0].subject, inboxMessages[0].subject);
+
+ // Create 2 more new messages.
+
+ let primedOnNewMailReceivedEventData = await event_page_extension(
+ "onNewMailReceived",
+ async () => {
+ await createMessages(inbox, 2);
+ inbox.hasNewMessages = true;
+ inbox.setNumNewMessages(2);
+ inbox.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NewMail;
+ }
+ );
+
+ inboxMessages = [...inbox.messages];
+ newMessages = await extension.awaitMessage(
+ "onNewMailReceived event received"
+ );
+ Assert.deepEqual(
+ primedOnNewMailReceivedEventData,
+ newMessages,
+ "The primed and non-primed onNewMailReceived events should return the same values"
+ );
+ equal(newMessages[1].messages.length, 2);
+ equal(newMessages[1].messages[0].subject, inboxMessages[1].subject);
+ equal(newMessages[1].messages[1].subject, inboxMessages[2].subject);
+
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js
new file mode 100644
index 0000000000..9d9e5d8595
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js
@@ -0,0 +1,333 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function test_query() {
+ let account = createAccount();
+
+ let textAttachment = {
+ body: "textAttachment",
+ filename: "test.txt",
+ contentType: "text/plain",
+ };
+
+ let subFolders = {
+ test1: await createSubfolder(account.incomingServer.rootFolder, "test1"),
+ test2: await createSubfolder(account.incomingServer.rootFolder, "test2"),
+ };
+ await createMessages(subFolders.test1, { count: 9, age_incr: { days: 2 } });
+
+ let messages = [...subFolders.test1.messages];
+ // NB: Here, the messages are zero-indexed. In the test they're one-indexed.
+ subFolders.test1.markMessagesRead([messages[0]], true);
+ subFolders.test1.markMessagesFlagged([messages[1]], true);
+ subFolders.test1.markMessagesFlagged([messages[6]], true);
+
+ subFolders.test1.addKeywordsToMessages(messages.slice(0, 1), "notATag");
+ subFolders.test1.addKeywordsToMessages(messages.slice(2, 4), "$label2");
+ subFolders.test1.addKeywordsToMessages(messages.slice(3, 6), "$label3");
+
+ addIdentity(account, messages[5].author.replace(/.*<(.*)>/, "$1"));
+ // No recipient support for NNTP.
+ if (account.incomingServer.type != "nntp") {
+ addIdentity(account, messages[2].recipients.replace(/.*<(.*)>/, "$1"));
+ }
+
+ await createMessages(subFolders.test2, { count: 7, age_incr: { days: 2 } });
+ // Email with multipart/alternative.
+ await createMessageFromFile(
+ subFolders.test2,
+ do_get_file("messages/alternative.eml").path
+ );
+
+ await createMessages(subFolders.test2, {
+ count: 1,
+ subject: "1 text attachment",
+ attachments: [textAttachment],
+ });
+
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let _account = await browser.accounts.get(accountId);
+ let accountType = _account.type;
+
+ let messages1 = await browser.messages.list({
+ accountId,
+ path: "/test1",
+ });
+ browser.test.assertEq(9, messages1.messages.length);
+ let messages2 = await browser.messages.list({
+ accountId,
+ path: "/test2",
+ });
+ browser.test.assertEq(9, messages2.messages.length);
+
+ // Check all messages are returned.
+ let { messages: allMessages } = await browser.messages.query({});
+ browser.test.assertEq(18, allMessages.length);
+
+ let folder1 = { accountId, path: "/test1" };
+ let folder2 = { accountId, path: "/test2" };
+ let rootFolder = { accountId, path: "/" };
+
+ // Query messages from test1. No messages from test2 should be returned.
+ // We'll use these messages as a reference for further tests.
+ let { messages: referenceMessages } = await browser.messages.query({
+ folder: folder1,
+ });
+ browser.test.assertEq(9, referenceMessages.length);
+ browser.test.assertTrue(
+ referenceMessages.every(m => m.folder.path == "/test1")
+ );
+
+ // Test includeSubFolders: Default (False).
+ let { messages: searchRecursiveDefault } = await browser.messages.query({
+ folder: rootFolder,
+ });
+ browser.test.assertEq(
+ 0,
+ searchRecursiveDefault.length,
+ "includeSubFolders: Default"
+ );
+
+ // Test includeSubFolders: True.
+ let { messages: searchRecursiveTrue } = await browser.messages.query({
+ folder: rootFolder,
+ includeSubFolders: true,
+ });
+ browser.test.assertEq(
+ 18,
+ searchRecursiveTrue.length,
+ "includeSubFolders: True"
+ );
+
+ // Test includeSubFolders: False.
+ let { messages: searchRecursiveFalse } = await browser.messages.query({
+ folder: rootFolder,
+ includeSubFolders: false,
+ });
+ browser.test.assertEq(
+ 0,
+ searchRecursiveFalse.length,
+ "includeSubFolders: False"
+ );
+
+ // Test attachment query: False.
+ let { messages: searchAttachmentFalse } = await browser.messages.query({
+ attachment: false,
+ includeSubFolders: true,
+ });
+ browser.test.assertEq(
+ 17,
+ searchAttachmentFalse.length,
+ "attachment: False"
+ );
+
+ // Test attachment query: True.
+ let { messages: searchAttachmentTrue } = await browser.messages.query({
+ attachment: true,
+ includeSubFolders: true,
+ });
+ browser.test.assertEq(1, searchAttachmentTrue.length, "attachment: True");
+
+ // Dump the reference messages to the console for easier debugging.
+ browser.test.log("Reference messages:");
+ for (let m of referenceMessages) {
+ let date = m.date.toISOString().substring(0, 10);
+ let author = m.author.replace(/"(.*)".*/, "$1").padEnd(16, " ");
+ // No recipient support for NNTP.
+ let recipients =
+ accountType == "nntp"
+ ? ""
+ : m.recipients[0].replace(/(.*) <.*>/, "$1").padEnd(16, " ");
+ browser.test.log(
+ `[${m.id}] ${date} From: ${author} To: ${recipients} Subject: ${m.subject}`
+ );
+ }
+
+ let subtest = async function (queryInfo, ...expectedMessageIndices) {
+ if (!queryInfo.folder) {
+ queryInfo.folder = folder1;
+ }
+ browser.test.log("Testing " + JSON.stringify(queryInfo));
+ let { messages: actualMessages } = await browser.messages.query(
+ queryInfo
+ );
+
+ browser.test.assertEq(
+ expectedMessageIndices.length,
+ actualMessages.length,
+ "Correct number of messages"
+ );
+ for (let index of expectedMessageIndices) {
+ // browser.test.log(`Looking for message ${index}`);
+ if (!actualMessages.some(am => am.id == index)) {
+ browser.test.fail(`Message ${index} was not returned`);
+ browser.test.log(
+ "These messages were returned: " + actualMessages.map(am => am.id)
+ );
+ }
+ }
+ };
+
+ // Date range query. The messages are 0 days old, 2 days old, 4 days old, etc..
+ let today = new Date();
+ let date1 = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() - 5
+ );
+ let date2 = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() - 11
+ );
+ await subtest({ fromDate: today });
+ await subtest({ fromDate: date1 }, 1, 2, 3);
+ await subtest({ fromDate: date2 }, 1, 2, 3, 4, 5, 6);
+ await subtest({ toDate: date1 }, 4, 5, 6, 7, 8, 9);
+ await subtest({ toDate: date2 }, 7, 8, 9);
+ await subtest({ fromDate: date1, toDate: date2 });
+ await subtest({ fromDate: date2, toDate: date1 }, 4, 5, 6);
+
+ // Unread query. Only message 1 has been read.
+ await subtest({ unread: false }, 1);
+ await subtest({ unread: true }, 2, 3, 4, 5, 6, 7, 8, 9);
+
+ // Flagged query. Messages 2 and 7 are flagged.
+ await subtest({ flagged: true }, 2, 7);
+ await subtest({ flagged: false }, 1, 3, 4, 5, 6, 8, 9);
+
+ // Subject query.
+ let keyword = referenceMessages[1].subject.split(" ")[1];
+ await subtest({ subject: keyword }, 2);
+ await subtest({ fullText: keyword }, 2);
+
+ // Author query.
+ keyword = referenceMessages[2].author.replace('"', "").split(" ")[0];
+ await subtest({ author: keyword }, 3);
+ await subtest({ fullText: keyword }, 3);
+
+ // Recipients query.
+ // No recipient support for NNTP.
+ if (accountType != "nntp") {
+ keyword = referenceMessages[7].recipients[0].split(" ")[0];
+ await subtest({ recipients: keyword }, 8);
+ await subtest({ fullText: keyword }, 8);
+ await subtest({ body: keyword }, 8);
+ }
+
+ // From Me and To Me. These use the identities added to account.
+ await subtest({ fromMe: true }, 6);
+ // No recipient support for NNTP.
+ if (accountType != "nntp") {
+ await subtest({ toMe: true }, 3);
+ }
+
+ // Tags query.
+ await subtest({ tags: { mode: "any", tags: { notATag: true } } });
+ await subtest({ tags: { mode: "any", tags: { $label2: true } } }, 3, 4);
+ await subtest(
+ { tags: { mode: "any", tags: { $label3: true } } },
+ 4,
+ 5,
+ 6
+ );
+ await subtest(
+ { tags: { mode: "any", tags: { $label2: true, $label3: true } } },
+ 3,
+ 4,
+ 5,
+ 6
+ );
+ await subtest({
+ tags: { mode: "all", tags: { $label1: true, $label2: true } },
+ });
+ await subtest(
+ { tags: { mode: "all", tags: { $label2: true, $label3: true } } },
+ 4
+ );
+ await subtest(
+ { tags: { mode: "any", tags: { $label2: false, $label3: false } } },
+ 1,
+ 2,
+ 7,
+ 8,
+ 9
+ );
+ await subtest(
+ { tags: { mode: "all", tags: { $label2: false, $label3: false } } },
+ 1,
+ 2,
+ 3,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9
+ );
+
+ // headerMessageId query
+ await subtest({ headerMessageId: "0@made.up.invalid" }, 1);
+ await subtest({ headerMessageId: "7@made.up.invalid" }, 8);
+ await subtest({ headerMessageId: "8@made.up.invalid" }, 9);
+ await subtest({ headerMessageId: "unknown@made.up.invalid" });
+
+ // attachment query
+ await subtest({ folder: folder2, attachment: true }, 18);
+
+ // text in nested html part of multipart/alternative
+ await subtest({ folder: folder2, body: "I am HTML!" }, 17);
+
+ // No recipient support for NNTP.
+ if (accountType != "nntp") {
+ // advanced search on recipients
+ await subtest({ folder: folder2, recipients: "karl; heinz" }, 17);
+ await subtest(
+ { folder: folder2, recipients: "<friedrich@example.COM>; HEINZ" },
+ 17
+ );
+ await subtest(
+ {
+ folder: folder2,
+ recipients: "karl <friedrich@example.COM>; HEINZ",
+ },
+ 17
+ );
+ await subtest({
+ folder: folder2,
+ recipients: "Heinz <friedrich@example.COM>; Karl",
+ });
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+registerCleanupFunction(() => {
+ // Make sure any open address book database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js
new file mode 100644
index 0000000000..4771f3ee17
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js
@@ -0,0 +1,415 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Function to start an event page extension (MV3), which can be called whenever
+// the main test is about to trigger an event. The extension terminates its
+// background and listens for that single event, verifying it is waking up correctly.
+async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.messages[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "event_page_extension@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesRead", "messagesMove"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "messages", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+}
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_update() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let testFolder0 = await createSubfolder(rootFolder, "test0");
+ await createMessages(testFolder0, 1);
+ testFolder0.addKeywordsToMessages(
+ [[...testFolder0.messages][0]],
+ "testkeyword"
+ );
+
+ let files = {
+ "background.js": async () => {
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ function newUpdatePromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = {};
+ const listener = (msg, props) => {
+ if (!seenEvents.hasOwnProperty(msg.id)) {
+ seenEvents[msg.id] = {
+ counts: 0,
+ props: {},
+ };
+ }
+
+ seenEvents[msg.id].counts++;
+ for (let prop of Object.keys(props)) {
+ seenEvents[msg.id].props[prop] = props[prop];
+ }
+
+ if (seenEvents[msg.id].counts == numberOfEventsToCollapse) {
+ browser.messages.onUpdated.removeListener(listener);
+ resolve({ msg, props: seenEvents[msg.id].props });
+ }
+ };
+ browser.messages.onUpdated.addListener(listener);
+ });
+ }
+ let tags = await browser.messages.listTags();
+ let [data] = await window.sendMessage("getFolder");
+ let messageList = await browser.messages.list(data.folder);
+ browser.test.assertEq(1, messageList.messages.length);
+ let message = messageList.messages[0];
+ browser.test.assertFalse(message.flagged);
+ browser.test.assertFalse(message.read);
+ browser.test.assertFalse(message.junk);
+ browser.test.assertEq(0, message.junkScore);
+ browser.test.assertEq(0, message.tags.length);
+ browser.test.assertEq(data.size, message.size);
+ browser.test.assertEq("0@made.up.invalid", message.headerMessageId);
+
+ // Test that setting flagged works.
+ let updatePromise = newUpdatePromise();
+ let primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { flagged: true })
+ );
+ let updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ flagged: true }, updateInfo.props);
+ await window.sendMessage("flagged");
+
+ // Test that setting read works.
+ updatePromise = newUpdatePromise();
+ primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { read: true })
+ );
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ read: true }, updateInfo.props);
+ await window.sendMessage("read");
+
+ // Test that setting junk works.
+ updatePromise = newUpdatePromise();
+ primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { junk: true })
+ );
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ junk: true }, updateInfo.props);
+ await window.sendMessage("junk");
+
+ // Test that setting one tag works.
+ updatePromise = newUpdatePromise();
+ primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { tags: [tags[0].key] })
+ );
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ tags: [tags[0].key] }, updateInfo.props);
+ await window.sendMessage("tags1");
+
+ // Test that setting two tags works. We get 3 events: one removing tags0,
+ // one adding tags1 and one adding tags2. updatePromise is waiting for
+ // the third one before resolving.
+ updatePromise = newUpdatePromise(3);
+ await browser.messages.update(message.id, {
+ tags: [tags[1].key, tags[2].key],
+ });
+ updateInfo = await updatePromise;
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual(
+ { tags: [tags[1].key, tags[2].key] },
+ updateInfo.props
+ );
+ await window.sendMessage("tags2");
+
+ // Test that unspecified properties aren't changed.
+ let listenerCalls = 0;
+ const listenerFunc = (msg, props) => {
+ listenerCalls++;
+ };
+ browser.messages.onUpdated.addListener(listenerFunc);
+ await browser.messages.update(message.id, {});
+ await window.sendMessage("empty");
+ // Check if the no-op update call triggered a listener.
+ await new Promise(resolve => setTimeout(resolve));
+ browser.messages.onUpdated.removeListener(listenerFunc);
+ browser.test.assertEq(
+ 0,
+ listenerCalls,
+ "Not expecting listener callbacks on no-op updates."
+ );
+
+ message = await browser.messages.get(message.id);
+ browser.test.assertTrue(message.flagged);
+ browser.test.assertTrue(message.read);
+ browser.test.assertTrue(message.junk);
+ browser.test.assertEq(100, message.junkScore);
+ browser.test.assertEq(2, message.tags.length);
+ browser.test.assertEq(tags[1].key, message.tags[0]);
+ browser.test.assertEq(tags[2].key, message.tags[1]);
+ browser.test.assertEq("0@made.up.invalid", message.headerMessageId);
+
+ // Test that clearing properties works.
+ updatePromise = newUpdatePromise(5);
+ await browser.messages.update(message.id, {
+ flagged: false,
+ read: false,
+ junk: false,
+ tags: [],
+ });
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ {
+ flagged: false,
+ read: false,
+ junk: false,
+ tags: [],
+ },
+ updateInfo.props
+ );
+ await window.sendMessage("clear");
+
+ message = await browser.messages.get(message.id);
+ browser.test.assertFalse(message.flagged);
+ browser.test.assertFalse(message.read);
+ browser.test.assertFalse(message.external);
+ browser.test.assertFalse(message.junk);
+ browser.test.assertEq(0, message.junkScore);
+ browser.test.assertEq(0, message.tags.length);
+ browser.test.assertEq("0@made.up.invalid", message.headerMessageId);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "messages.update@mochi.test" },
+ },
+ },
+ });
+
+ let message = [...testFolder0.messages][0];
+ ok(!message.isFlagged);
+ ok(!message.isRead);
+ equal(message.getStringProperty("keywords"), "testkeyword");
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution in the main test, after the event page extension is
+ // ready to capture the event with deactivated background.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("flagged", async () => {
+ await TestUtils.waitForCondition(() => message.isFlagged);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("read", async () => {
+ await TestUtils.waitForCondition(() => message.isRead);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("junk", async () => {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("junkscore") == 100
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("tags1", async () => {
+ if (IS_IMAP) {
+ // Only IMAP sets the junk/nonjunk keyword.
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") == "testkeyword junk $label1"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("keywords") == "testkeyword $label1"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("tags2", async () => {
+ if (IS_IMAP) {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword junk $label2 $label3"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword $label2 $label3"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("empty", async () => {
+ await TestUtils.waitForCondition(() => message.isFlagged);
+ await TestUtils.waitForCondition(() => message.isRead);
+ if (IS_IMAP) {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword junk $label2 $label3"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword $label2 $label3"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("clear", async () => {
+ await TestUtils.waitForCondition(() => !message.isFlagged);
+ await TestUtils.waitForCondition(() => !message.isRead);
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("junkscore") == 0
+ );
+ if (IS_IMAP) {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("keywords") == "testkeyword nonjunk"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("keywords") == "testkeyword"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getFolder", async () => {
+ extension.sendMessage({
+ folder: { accountId: account.key, path: "/test0" },
+ size: message.messageSize,
+ });
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini
new file mode 100644
index 0000000000..88659a20ad
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini
@@ -0,0 +1,7 @@
+[default]
+dupe-manifest = true
+head = head.js head-imap.js
+support-files = data/utils.js
+tags = imap webextensions
+
+[include:xpcshell.ini]
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini
new file mode 100644
index 0000000000..19d50044cd
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini
@@ -0,0 +1,23 @@
+[default]
+dupe-manifest = true
+head = head.js
+support-files = data/utils.js
+tags = local webextensions
+
+[include:xpcshell.ini]
+[test_ext_accounts.js]
+[test_ext_accounts_mv3_event_pages.js]
+[test_ext_identities_mv3_event_pages.js]
+[test_ext_addressBook.js]
+support-files = images/**
+tags = addrbook
+[test_ext_addressBook_readonly.js]
+tags = addrbook
+[test_ext_addressBook_remote.js]
+tags = addrbook
+[test_ext_addressBook_provider.js]
+tags = addrbook
+[test_ext_addressBook_quickSearch.js]
+tags = addrbook
+[test_ext_alias.js]
+[test_ext_browserAction_unifiedtoolbar_restart.js]
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini
new file mode 100644
index 0000000000..66e23e03bd
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini
@@ -0,0 +1,7 @@
+[default]
+dupe-manifest = true
+head = head.js head-nntp.js
+support-files = data/utils.js
+tags = nntp webextensions
+
+[include:xpcshell.ini]
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..666a67d5da
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell.ini
@@ -0,0 +1,17 @@
+[test_ext_experiments.js]
+tags = addrbook
+[test_ext_folders.js] # NNTP disabled (no support for folder operations).
+[test_ext_folders_mv3_event_pages.js] # NNTP disabled (no support for folder operations).
+[test_ext_messages.js] # NNTP disabled (no support for Trash folder).
+[test_ext_messages_attachments.js] # IMAP disabled (doesn't work with test server).
+support-files = messages/**
+[test_ext_messages_get.js] # NNTP disabled for PGP tests.
+support-files = messages/**
+[test_ext_messages_id.js] # NNTP disabled (message move not supported).
+[test_ext_messages_import.js]
+support-files = messages/**
+[test_ext_messages_move_copy_delete.js] # NNTP disabled (no support for Trash folder).
+[test_ext_messages_onNewMailReceived.js]
+[test_ext_messages_query.js]
+support-files = messages/alternative.eml
+[test_ext_messages_update.js] # NNTP disabled (no support for Trash folder).
diff --git a/comm/mail/components/im/IMIncomingServer.sys.mjs b/comm/mail/components/im/IMIncomingServer.sys.mjs
new file mode 100644
index 0000000000..aea800cec7
--- /dev/null
+++ b/comm/mail/components/im/IMIncomingServer.sys.mjs
@@ -0,0 +1,359 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+export function IMIncomingServer() {}
+
+IMIncomingServer.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+ _imAccount: null,
+ get imAccount() {
+ if (this._imAccount) {
+ return this._imAccount;
+ }
+
+ let id = this.getCharValue("imAccount");
+ if (!id) {
+ return null;
+ }
+ IMServices.core.init();
+ return (this._imAccount = IMServices.accounts.getAccountById(id));
+ },
+ set imAccount(aImAccount) {
+ this._imAccount = aImAccount;
+ this.setCharValue("imAccount", aImAccount.id);
+ },
+ _prefBranch: null,
+ valid: true,
+ hidden: false,
+ get offlineSupportLevel() {
+ return 0;
+ },
+ get supportsDiskSpace() {
+ return false;
+ },
+ _key: "",
+ get key() {
+ return this._key;
+ },
+ set key(aKey) {
+ this._key = aKey;
+ this._prefBranch = Services.prefs.getBranch("mail.server." + aKey + ".");
+ },
+ equals(aServer) {
+ return "wrappedJSObject" in aServer && aServer.wrappedJSObject == this;
+ },
+
+ clearAllValues() {
+ IMServices.accounts.deleteAccount(this.imAccount.id);
+ for (let prefName of this._prefBranch.getChildList("")) {
+ this._prefBranch.clearUserPref(prefName);
+ }
+ delete this._prefBranch;
+ delete this._imAccount;
+ },
+
+ // Returns the directory where the account would have its data stored.
+ // There are currently conversation logs only.
+ // It may not exist yet.
+ // This is used in account removal dialog and should return the same path
+ // that the removeFiles() function deletes.
+ get localPath() {
+ let logPath = IMServices.logs.getLogFolderPathForAccount(this.imAccount);
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(logPath);
+ return file;
+ },
+
+ // Removes files created by this account.
+ removeFiles() {
+ IMServices.logs.deleteLogFolderForAccount(this.imAccount);
+ },
+
+ // called by nsMsgAccountManager while deleting an account:
+ forgetSessionPassword() {},
+
+ forgetPassword() {
+ // Password is cleared in imAccount.remove()
+ // TODO: this may need to be implemented here as a separate function
+ // once IM accounts support changing username/hostname.
+ },
+
+ // Shown in the "Remove Account" confirm prompt.
+ get prettyName() {
+ let protocol = this.imAccount.protocol.name || this.imAccount.protocol.id;
+ return protocol + " - " + this.imAccount.name;
+ },
+
+ // XXX Flo: I don't think constructedPrettyName is visible in the UI
+ get constructedPrettyName() {
+ return "constructedPrettyName FIXME";
+ },
+
+ port: -1,
+ accountManagerChrome: "am-im.xhtml",
+
+ // FIXME need a new imIIncomingService iface + classinfo for these 3 properties :(
+ get password() {
+ return this.imAccount.password;
+ },
+ set password(aPassword) {
+ this.imAccount.password = aPassword;
+ },
+ get alias() {
+ return this.imAccount.alias;
+ },
+ set alias(aAlias) {
+ this.imAccount.alias = aAlias;
+ },
+ get autojoin() {
+ try {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoJoin";
+ return Services.prefs.getStringPref(prefName);
+ } catch (e) {
+ return "";
+ }
+ },
+ set autojoin(aAutoJoin) {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoJoin";
+ Services.prefs.setStringPref(prefName, aAutoJoin);
+ },
+ get autologin() {
+ try {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoLogin";
+ return Services.prefs.getBoolPref(prefName);
+ } catch (e) {
+ return false;
+ }
+ },
+ set autologin(aAutoLogin) {
+ let prefName = "messenger.account." + this.imAccount.id + ".autoLogin";
+ Services.prefs.setBoolPref(prefName, aAutoLogin);
+ },
+
+ // This is used for user-visible advanced preferences.
+ setUnicharValue(aPrefName, aValue) {
+ if (aPrefName == "autojoin") {
+ this.autojoin = aValue;
+ } else if (aPrefName == "alias") {
+ this.alias = aValue;
+ } else if (aPrefName == "password") {
+ this.password = aValue;
+ } else {
+ this.imAccount.setString(aPrefName, aValue);
+ }
+ },
+ getUnicharValue(aPrefName) {
+ if (aPrefName == "autojoin") {
+ return this.autojoin;
+ }
+ if (aPrefName == "alias") {
+ return this.alias;
+ }
+ if (aPrefName == "password") {
+ return this.password;
+ }
+
+ try {
+ let prefName =
+ "messenger.account." + this.imAccount.id + ".options." + aPrefName;
+ return Services.prefs.getStringPref(prefName);
+ } catch (x) {
+ return this._getDefault(aPrefName);
+ }
+ },
+ setBoolValue(aPrefName, aValue) {
+ if (aPrefName == "autologin") {
+ this.autologin = aValue;
+ }
+ this.imAccount.setBool(aPrefName, aValue);
+ },
+ getBoolValue(aPrefName) {
+ if (aPrefName == "autologin") {
+ return this.autologin;
+ }
+ try {
+ let prefName =
+ "messenger.account." + this.imAccount.id + ".options." + aPrefName;
+ return Services.prefs.getBoolPref(prefName);
+ } catch (x) {
+ return this._getDefault(aPrefName);
+ }
+ },
+ setIntValue(aPrefName, aValue) {
+ this.imAccount.setInt(aPrefName, aValue);
+ },
+ getIntValue(aPrefName) {
+ try {
+ let prefName =
+ "messenger.account." + this.imAccount.id + ".options." + aPrefName;
+ return Services.prefs.getIntPref(prefName);
+ } catch (x) {
+ return this._getDefault(aPrefName);
+ }
+ },
+ _defaultOptionValues: null,
+ _getDefault(aPrefName) {
+ if (aPrefName == "otrVerifyNudge") {
+ return Services.prefs.getBoolPref("chat.otr.default.verifyNudge");
+ }
+ if (aPrefName == "otrRequireEncryption") {
+ return Services.prefs.getBoolPref("chat.otr.default.requireEncryption");
+ }
+ if (aPrefName == "otrAllowMsgLog") {
+ return Services.prefs.getBoolPref("chat.otr.default.allowMsgLog");
+ }
+ if (this._defaultOptionValues) {
+ return this._defaultOptionValues[aPrefName];
+ }
+
+ this._defaultOptionValues = {};
+ for (let opt of this.imAccount.protocol.getOptions()) {
+ let type = opt.type;
+ if (type == Ci.prplIPref.typeBool) {
+ this._defaultOptionValues[opt.name] = opt.getBool();
+ } else if (type == Ci.prplIPref.typeInt) {
+ this._defaultOptionValues[opt.name] = opt.getInt();
+ } else if (type == Ci.prplIPref.typeString) {
+ this._defaultOptionValues[opt.name] = opt.getString();
+ } else if (type == Ci.prplIPref.typeList) {
+ this._defaultOptionValues[opt.name] = opt.getListDefault();
+ }
+ }
+ return this._defaultOptionValues[aPrefName];
+ },
+
+ // the "Char" type will be used only for "imAccount" and internally.
+ setCharValue(aPrefName, aValue) {
+ this._prefBranch.setCharPref(aPrefName, aValue);
+ },
+ getCharValue(aPrefName) {
+ try {
+ return this._prefBranch.getCharPref(aPrefName);
+ } catch (x) {
+ return "";
+ }
+ },
+
+ get type() {
+ return this._prefBranch.getCharPref("type");
+ },
+ set type(aType) {
+ this._prefBranch.setCharPref("type", aType);
+ },
+
+ get username() {
+ return this._prefBranch.getCharPref("userName");
+ },
+ set username(aUsername) {
+ if (!aUsername) {
+ // nsMsgAccountManager::GetIncomingServer expects the pref to
+ // be named userName but some early test versions with IM had
+ // the pref named username.
+ return;
+ }
+ this._prefBranch.setCharPref("userName", aUsername);
+ },
+
+ get hostName() {
+ return this._prefBranch.getCharPref("hostname");
+ },
+ set hostName(aHostName) {
+ this._prefBranch.setCharPref("hostname", aHostName);
+ },
+
+ writeToFolderCache() {},
+ closeCachedConnections() {},
+
+ // Shutdown the server instance so at least disconnect from the server.
+ shutdown() {
+ // Ensure this account has not been destroyed already.
+ if (this.imAccount.prplAccount) {
+ this.imAccount.disconnect();
+ }
+ },
+
+ setFilterList() {},
+
+ get canBeDefaultServer() {
+ return false;
+ },
+
+ // AccountManager.js verifies that spamSettings is non-null before
+ // using the initialize method, but we can't just use a null value
+ // because that would crash nsMsgPurgeService::PerformPurge which
+ // only verifies the nsresult return value of the spamSettings
+ // getter before accessing the level property.
+ get spamSettings() {
+ return {
+ level: 0,
+ initialize(aServer) {},
+ QueryInterface: ChromeUtils.generateQI(["nsISpamSettings"]),
+ };
+ },
+
+ // nsMsgDBFolder.cpp crashes in HandleAutoCompactEvent if this doesn't exist:
+ msgStore: {
+ supportsCompaction: false,
+ },
+
+ get serverURI() {
+ return "im://" + this.imAccount.protocol.id + "/" + this.imAccount.name;
+ },
+ _rootFolder: null,
+ get rootMsgFolder() {
+ return this.rootFolder;
+ },
+ get rootFolder() {
+ if (this._rootFolder) {
+ return this._rootFolder;
+ }
+
+ return (this._rootFolder = {
+ isServer: true,
+ server: this,
+ get URI() {
+ return this.server.serverURI;
+ },
+ get prettyName() {
+ return this.server.prettyName;
+ }, // used in the account manager tree
+ get name() {
+ return this.server.prettyName + " name";
+ }, // never displayed?
+ // used in the folder pane tree, if we don't hide the IM accounts:
+ get abbreviatedName() {
+ return this.server.prettyName + "abbreviatedName";
+ },
+ AddFolderListener() {},
+ RemoveFolderListener() {},
+ descendants: [],
+ getFlag: () => false,
+ getFolderWithFlags: aFlags => null,
+ getFoldersWithFlags: aFlags => [],
+ get subFolders() {
+ return [];
+ },
+ getStringProperty: aPropertyName => "",
+ getNumUnread: aDeep => 0,
+ Shutdown() {},
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgFolder"]),
+ });
+ },
+
+ get sortOrder() {
+ return 300000000;
+ },
+
+ get protocolInfo() {
+ return Cc["@mozilla.org/messenger/protocol/info;1?type=im"].getService(
+ Ci.nsIMsgProtocolInfo
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgIncomingServer"]),
+};
diff --git a/comm/mail/components/im/IMProtocolInfo.sys.mjs b/comm/mail/components/im/IMProtocolInfo.sys.mjs
new file mode 100644
index 0000000000..975a3a4a0a
--- /dev/null
+++ b/comm/mail/components/im/IMProtocolInfo.sys.mjs
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export function IMProtocolInfo() {}
+
+IMProtocolInfo.prototype = {
+ defaultLocalPath: null,
+ get serverIID() {
+ return null;
+ },
+ get requiresUsername() {
+ return true;
+ },
+ get preflightPrettyNameWithEmailAddress() {
+ return false;
+ },
+ get canDelete() {
+ return true;
+ },
+ // Even though IM accounts can login at startup, canLoginAtStartUp
+ // should be false as it's used to decide if new messages should be
+ // fetched at startup and that concept of message doesn't apply to
+ // IM accounts.
+ get canLoginAtStartUp() {
+ return false;
+ },
+ get canDuplicate() {
+ return false;
+ },
+ getDefaultServerPort: () => 0,
+ get canGetMessages() {
+ return false;
+ },
+ get canGetIncomingMessages() {
+ return false;
+ },
+ get defaultDoBiff() {
+ return false;
+ },
+ get showComposeMsgLink() {
+ return false;
+ },
+ get foldersCreatedAsync() {
+ return false;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgProtocolInfo"]),
+};
diff --git a/comm/mail/components/im/all-im.js b/comm/mail/components/im/all-im.js
new file mode 100644
index 0000000000..a2ca249f08
--- /dev/null
+++ b/comm/mail/components/im/all-im.js
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+pref("messenger.options.messagesStyle.theme", "mail");
+pref("messenger.options.emoticonsTheme", "messenger-emoticons");
+pref("messenger.options.getAttentionOnNewMessages", false);
+pref("messenger.conversations.textbox.autoResize", true);
+pref("messenger.conversations.doubleClickToReply", true);
+pref("messenger.conversations.showNicks", true);
+pref("purple.debug.loglevel", 3);
+
+// Limit the number of gloda IM results
+pref("mailnews.database.global.search.im.limit", 1000);
diff --git a/comm/mail/components/im/components.conf b/comm/mail/components/im/components.conf
new file mode 100644
index 0000000000..2d379db965
--- /dev/null
+++ b/comm/mail/components/im/components.conf
@@ -0,0 +1,20 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{13118758-dad2-418c-a03d-1acbfed0cd01}',
+ 'contract_ids': ['@mozilla.org/messenger/protocol/info;1?type=im'],
+ 'esModule': 'resource:///modules/IMProtocolInfo.sys.mjs',
+ 'constructor': 'IMProtocolInfo',
+ },
+ {
+ 'cid': '{9dd7f36b-5960-4f0a-8789-f5f516bd083d}',
+ 'contract_ids': ['@mozilla.org/messenger/server;1?type=im'],
+ 'esModule': 'resource:///modules/IMIncomingServer.sys.mjs',
+ 'constructor': 'IMIncomingServer',
+ },
+]
diff --git a/comm/mail/components/im/content/.eslintrc.js b/comm/mail/components/im/content/.eslintrc.js
new file mode 100644
index 0000000000..c862f88e3e
--- /dev/null
+++ b/comm/mail/components/im/content/.eslintrc.js
@@ -0,0 +1,22 @@
+"use strict";
+
+module.exports = {
+ overrides: [
+ {
+ files: ["imconversation.xml"],
+ globals: {
+ AppConstants: true,
+ chatHandler: true,
+ gChatTab: true,
+ Services: true,
+
+ // chat/modules/imStatusUtils.jsm
+ Status: true,
+
+ // chat/modules/imTextboxUtils.jsm
+ MessageFormat: true,
+ TextboxSize: true,
+ },
+ },
+ ],
+};
diff --git a/comm/mail/components/im/content/addbuddy.js b/comm/mail/components/im/content/addbuddy.js
new file mode 100644
index 0000000000..f5b3eb7deb
--- /dev/null
+++ b/comm/mail/components/im/content/addbuddy.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var addBuddy = {
+ onload() {
+ let accountList = document.getElementById("accountlist");
+ for (let acc of IMServices.accounts.getAccounts()) {
+ if (!acc.connected) {
+ continue;
+ }
+ let proto = acc.protocol;
+ let item = accountList.appendItem(acc.name, acc.id, proto.name);
+ item.setAttribute("image", ChatIcons.getProtocolIconURI(proto));
+ item.setAttribute("class", "menuitem-iconic");
+ }
+ if (!accountList.itemCount) {
+ document
+ .getElementById("addBuddyDialog")
+ .querySelector("dialog")
+ .cancelDialog();
+ throw new Error("No connected account!");
+ }
+ accountList.selectedIndex = 0;
+ },
+
+ oninput() {
+ document.querySelector("dialog").getButton("accept").disabled =
+ !addBuddy.getValue("name");
+ },
+
+ getValue(aId) {
+ return document.getElementById(aId).value;
+ },
+
+ create() {
+ let account = IMServices.accounts.getAccountById(
+ this.getValue("accountlist")
+ );
+ let group = Services.strings
+ .createBundle("chrome://messenger/locale/chat.properties")
+ .GetStringFromName("defaultGroup");
+ account.addBuddy(IMServices.tags.createTag(group), this.getValue("name"));
+ },
+};
+
+document.addEventListener("dialogaccept", addBuddy.create.bind(addBuddy));
+
+window.addEventListener("load", event => {
+ addBuddy.onload();
+});
diff --git a/comm/mail/components/im/content/addbuddy.xhtml b/comm/mail/components/im/content/addbuddy.xhtml
new file mode 100644
index 0000000000..5c4fbfbf94
--- /dev/null
+++ b/comm/mail/components/im/content/addbuddy.xhtml
@@ -0,0 +1,59 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/imMenulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+
+<!DOCTYPE html SYSTEM "chrome://messenger/locale/addbuddy.dtd">
+
+<html
+ id="addBuddyDialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title>&addBuddyWindow.title;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/addbuddy.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept,cancel" buttondisabledaccept="true">
+ <hbox>
+ <vbox id="nameBox">
+ <hbox align="center" flex="1">
+ <label value="&name.label;" control="name" />
+ </hbox>
+ <hbox align="center" flex="1">
+ <label value="&account.label;" control="accountlist" />
+ </hbox>
+ </vbox>
+ <vbox id="accountBox">
+ <html:input
+ id="name"
+ type="text"
+ class="input-inline"
+ oninput="addBuddy.oninput()"
+ />
+ <menulist id="accountlist" />
+ </vbox>
+ </hbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/mail/components/im/content/am-im.js b/comm/mail/components/im/content/am-im.js
new file mode 100644
index 0000000000..494e0aa1fd
--- /dev/null
+++ b/comm/mail/components/im/content/am-im.js
@@ -0,0 +1,291 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// chat/content/imAccountOptionsHelper.js
+/* globals accountOptionsHelper */
+
+const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs",
+ OTR: "resource:///modules/OTR.sys.mjs",
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+var autoJoinPref = "autoJoin";
+
+function onPreInit(aAccount, aAccountValue) {
+ account.init(aAccount.incomingServer.wrappedJSObject.imAccount);
+}
+
+function onBeforeUnload() {
+ if (account.encryptionObserver) {
+ Services.obs.removeObserver(
+ account.encryptionObserver,
+ "account-sessions-changed"
+ );
+ Services.obs.removeObserver(
+ account.encryptionObserver,
+ "account-encryption-status-changed"
+ );
+ }
+}
+
+var account = {
+ async init(aAccount) {
+ let title = document.querySelector(".dialogheader .dialogheader-title");
+ let defaultTitle = title.getAttribute("defaultTitle");
+ let titleValue;
+
+ if (aAccount.name) {
+ titleValue = defaultTitle + " - <" + aAccount.name + ">";
+ } else {
+ titleValue = defaultTitle;
+ }
+
+ title.setAttribute("value", titleValue);
+ document.title = titleValue;
+
+ this.account = aAccount;
+ this.proto = this.account.protocol;
+ document.getElementById("accountName").value = this.account.name;
+ document.getElementById("protocolName").value =
+ this.proto.name || this.proto.id;
+ document.getElementById("protocolIcon").src = ChatIcons.getProtocolIconURI(
+ this.proto,
+ 48
+ );
+
+ let password = document.getElementById("server.password");
+ let passwordBox = document.getElementById("passwordBox");
+ if (this.proto.noPassword) {
+ passwordBox.hidden = true;
+ password.removeAttribute("wsm_persist");
+ } else {
+ passwordBox.hidden = false;
+ try {
+ // Should we force layout here to ensure password.value works?
+ // Will throw if we don't have a protocol plugin for the account.
+ password.value = this.account.password;
+ password.setAttribute("wsm_persist", "true");
+ } catch (e) {
+ passwordBox.hidden = true;
+ password.removeAttribute("wsm_persist");
+ }
+ }
+
+ document.getElementById("server.alias").value = this.account.alias;
+
+ if (ChatEncryption.canConfigureEncryption(this.account.protocol)) {
+ document.getElementById("imTabEncryption").hidden = false;
+ document.querySelector(".otr-settings").hidden = !OTRUI.enabled;
+ document.getElementById("server.otrAllowMsgLog").value =
+ this.account.otrAllowMsgLog;
+ if (OTRUI.enabled) {
+ document.getElementById("server.otrVerifyNudge").value =
+ this.account.otrVerifyNudge;
+ document.getElementById("server.otrRequireEncryption").value =
+ this.account.otrRequireEncryption;
+
+ let fpa = this.account.normalizedName;
+ let fpp = this.account.protocol.normalizedName;
+ let fp = OTR.privateKeyFingerprint(fpa, fpp);
+ if (!fp) {
+ fp = await document.l10n.formatValue("otr-not-yet-available");
+ }
+ document.getElementById("otrFingerprint").value = fp;
+ }
+ document.querySelector(".chat-encryption-settings").hidden =
+ !this.account.protocol.canEncrypt;
+ if (this.account.protocol.canEncrypt) {
+ document.l10n.setAttributes(
+ document.getElementById("chat-encryption-description"),
+ "chat-encryption-description",
+ {
+ protocol: this.proto.name,
+ }
+ );
+ this.buildEncryptionStatus();
+ this.buildAccountSessionsList();
+ this.encryptionObserver = {
+ observe: (subject, topic) => {
+ if (
+ topic === "account-sessions-changed" &&
+ subject.id === this.account.id
+ ) {
+ this.buildAccountSessionsList();
+ } else if (
+ topic === "account-encryption-status-changed" &&
+ subject.id === this.account.id
+ ) {
+ this.buildEncryptionStatus();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ Services.obs.addObserver(
+ this.encryptionObserver,
+ "account-sessions-changed",
+ true
+ );
+ Services.obs.addObserver(
+ this.encryptionObserver,
+ "account-encryption-status-changed",
+ true
+ );
+ }
+ }
+
+ let protoId = this.proto.id;
+ let canAutoJoin =
+ protoId == "prpl-irc" ||
+ protoId == "prpl-jabber" ||
+ protoId == "prpl-gtalk";
+ document.getElementById("autojoinBox").hidden = !canAutoJoin;
+ let autojoin = document.getElementById("server.autojoin");
+ if (canAutoJoin) {
+ autojoin.setAttribute("wsm_persist", "true");
+ } else {
+ autojoin.removeAttribute("wsm_persist");
+ }
+
+ this.prefs = Services.prefs.getBranch(
+ "messenger.account." + this.account.id + ".options."
+ );
+ this.populateProtoSpecificBox();
+ },
+
+ encryptionObserver: null,
+ buildEncryptionStatus() {
+ const encryptionStatus = document.querySelector(".chat-encryption-status");
+ if (this.account.encryptionStatus.length) {
+ encryptionStatus.replaceChildren(
+ ...this.account.encryptionStatus.map(status => {
+ const item = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "li"
+ );
+ item.textContent = status;
+ return item;
+ })
+ );
+ } else {
+ const placeholder = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "li"
+ );
+ document.l10n.setAttributes(placeholder, "chat-encryption-placeholder");
+ encryptionStatus.replaceChildren(placeholder);
+ }
+ },
+ buildAccountSessionsList() {
+ const sessions = this.account.getSessions();
+ document.querySelector(".chat-encryption-sessions-container").hidden =
+ sessions.length === 0;
+ const sessionList = document.querySelector(".chat-encryption-sessions");
+ sessionList.replaceChildren(
+ ...sessions.map(session => {
+ const button = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "button"
+ );
+ document.l10n.setAttributes(
+ button,
+ "chat-encryption-session-" + (session.trusted ? "trusted" : "verify")
+ );
+ button.disabled = session.trusted;
+ if (!button.disabled) {
+ button.addEventListener("click", async () => {
+ try {
+ const sessionInfo = await session.verify();
+ parent.gSubDialog.open(
+ "chrome://messenger/content/chat/verify.xhtml",
+ { features: "resizable=no" },
+ sessionInfo
+ );
+ } catch (error) {
+ // Verification was probably aborted by the other side.
+ this.account.prplAccount.wrappedJSObject.WARN(error);
+ }
+ });
+ }
+ const sessionLabel = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "span"
+ );
+ sessionLabel.textContent = session.id;
+ const row = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "li"
+ );
+ row.append(sessionLabel, button);
+ row.classList.toggle("chat-current-session", session.currentSession);
+ return row;
+ })
+ );
+ },
+
+ populateProtoSpecificBox() {
+ let attributes = {};
+ attributes[Ci.prplIPref.typeBool] = [
+ { name: "wsm_persist", value: "true" },
+ { name: "preftype", value: "bool" },
+ { name: "genericattr", value: "true" },
+ ];
+ attributes[Ci.prplIPref.typeInt] = [
+ { name: "wsm_persist", value: "true" },
+ { name: "preftype", value: "int" },
+ { name: "genericattr", value: "true" },
+ ];
+ attributes[Ci.prplIPref.typeString] = attributes[Ci.prplIPref.typeList] = [
+ { name: "wsm_persist", value: "true" },
+ { name: "preftype", value: "wstring" },
+ { name: "genericattr", value: "true" },
+ ];
+ let haveOptions = accountOptionsHelper.addOptions(
+ "server.",
+ this.proto.getOptions(),
+ attributes
+ );
+ let advanced = document.getElementById("advanced");
+ if (advanced.hidden && haveOptions) {
+ advanced.hidden = false;
+ // Force textbox XBL binding attachment by forcing layout,
+ // otherwise setFormElementValue from AccountManager.js sets
+ // properties that don't exist when restoring values.
+ document.getElementById("protoSpecific").getBoundingClientRect();
+ } else if (!haveOptions) {
+ advanced.hidden = true;
+ }
+ let inputElements = document.querySelectorAll(
+ "#protoSpecific :is(checkbox, input, menulist)"
+ );
+ // Because the elements are added after the document loaded we have to
+ // notify the parent document that there are prefs to save.
+ for (let input of inputElements) {
+ if (input.localName == "input" || input.localName == "textarea") {
+ input.addEventListener("change", event => {
+ document.dispatchEvent(new CustomEvent("prefchange"));
+ });
+ } else {
+ input.addEventListener("command", event => {
+ document.dispatchEvent(new CustomEvent("prefchange"));
+ });
+ }
+ }
+ },
+
+ viewFingerprintKeys() {
+ let otrAccount = { account: this.account };
+ parent.gSubDialog.open(
+ "chrome://chat/content/otr-finger.xhtml",
+ undefined,
+ otrAccount
+ );
+ },
+};
diff --git a/comm/mail/components/im/content/am-im.xhtml b/comm/mail/components/im/content/am-im.xhtml
new file mode 100644
index 0000000000..5455309da8
--- /dev/null
+++ b/comm/mail/components/im/content/am-im.xhtml
@@ -0,0 +1,235 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+
+<!DOCTYPE window [ <!ENTITY % imDTD SYSTEM "chrome://messenger/locale/am-im.dtd">
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%imDTD; %brandDTD; ]>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ id="account"
+ title="&accountWindow.title;"
+ buttons="accept,cancel"
+ onload="parent.onPanelLoaded('am-im.xhtml');"
+ onbeforeunload="onBeforeUnload();"
+>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://chat/content/imAccountOptionsHelper.js" />
+ <script src="chrome://messenger/content/am-im.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/preferences/am-im.ftl" />
+ <html:link rel="localization" href="messenger/otr/am-im-otr.ftl" />
+ </linkset>
+
+ <vbox flex="1" style="overflow: auto; padding: 0"
+ ><vbox id="containerBox" flex="1">
+ <hbox class="dialogheader">
+ <label
+ class="dialogheader-title"
+ defaultTitle="&accountWindow.title;"
+ />
+ </hbox>
+
+ <hbox align="center">
+ <html:img id="protocolIcon" alt="" />
+ <vbox flex="1">
+ <label id="accountName" crop="end" class="header" />
+ <label id="protocolName" class="tip-caption" />
+ </vbox>
+ </hbox>
+
+ <tabbox id="imTabbox" flex="1">
+ <tabs>
+ <tab id="imTabGeneral" label="&account.general;" />
+ <tab
+ id="imTabEncryption"
+ data-l10n-id="account-encryption"
+ hidden="true"
+ />
+ </tabs>
+ <tabpanels flex="1">
+ <tabpanel orient="vertical">
+ <label class="header" data-l10n-id="account-settings-title" />
+ <hbox id="passwordBox" align="baseline" class="input-container">
+ <label
+ value="&account.password;"
+ control="server.password"
+ class="label-inline"
+ />
+ <html:input
+ id="server.password"
+ type="password"
+ preftype="wstring"
+ genericattr="true"
+ class="input-inline"
+ />
+ </hbox>
+ <hbox id="aliasBox" align="baseline" class="input-container">
+ <label
+ value="&account.alias;"
+ control="server.alias"
+ class="label-inline"
+ />
+ <html:input
+ id="server.alias"
+ type="text"
+ preftype="wstring"
+ wsm_persist="true"
+ genericattr="true"
+ class="input-inline"
+ />
+ </hbox>
+ <vbox id="autologinBox">
+ <checkbox
+ id="server.autologin"
+ data-l10n-id="chat-autologin"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ </vbox>
+ <separator class="thin" />
+
+ <vbox id="autojoinBox" hidden="true">
+ <label class="header" data-l10n-id="account-channel-title" />
+ <hbox class="input-container">
+ <label
+ class="label-inline"
+ value="&account.autojoin;"
+ control="server.autojoin"
+ />
+ <html:input
+ id="server.autojoin"
+ type="text"
+ preftype="wstring"
+ genericattr="true"
+ class="input-inline"
+ />
+ </hbox>
+ <separator class="thin" />
+ </vbox>
+ <vbox id="advanced">
+ <label class="header">&account.advanced;</label>
+ <html:div
+ id="protoSpecific"
+ class="grid-block-two-column-fr grid-items-baseline"
+ >
+ </html:div>
+ </vbox>
+ </tabpanel>
+
+ <tabpanel orient="vertical">
+ <html:div>
+ <html:h1 data-l10n-id="chat-encryption-generic" />
+ <separator class="thin" />
+
+ <vbox>
+ <checkbox
+ id="server.otrAllowMsgLog"
+ data-l10n-id="chat-encryption-log"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ </vbox>
+ </html:div>
+ <separator />
+ <html:div class="chat-encryption-settings">
+ <html:h1 data-l10n-id="chat-encryption-label" />
+ <description id="chat-encryption-description" />
+
+ <separator class="thin" />
+
+ <label class="header" data-l10n-id="chat-encryption-status" />
+ <html:div class="indent">
+ <html:ul class="chat-encryption-status">
+ <html:li data-l10n-id="chat-encryption-placeholder" />
+ </html:ul>
+ </html:div>
+
+ <html:div class="chat-encryption-sessions-container">
+ <separator class="thin" />
+ <label class="header" data-l10n-id="chat-encryption-sessions" />
+ <description
+ data-l10n-id="chat-encryption-sessions-description"
+ />
+ <html:div class="indent">
+ <html:ul class="chat-encryption-sessions"></html:ul>
+ </html:div>
+ </html:div>
+ <separator />
+ </html:div>
+ <html:div class="otr-settings">
+ <html:h1 data-l10n-id="account-otr-label" />
+ <description data-l10n-id="account-otr-description2" />
+
+ <separator />
+
+ <vbox>
+ <label class="header" data-l10n-id="otr-settings-title" />
+ <checkbox
+ id="server.otrRequireEncryption"
+ data-l10n-id="otr-require-encryption"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ <html:p
+ id="otrRequireEncryptionInfo"
+ class="option-description"
+ data-l10n-id="otr-require-encryption-info"
+ ></html:p>
+ <checkbox
+ id="server.otrVerifyNudge"
+ data-l10n-id="otr-verify-nudge"
+ crop="end"
+ wsm_persist="true"
+ preftype="bool"
+ genericattr="true"
+ />
+ </vbox>
+
+ <separator />
+
+ <vbox>
+ <label class="header" data-l10n-id="otr-encryption-title" />
+ <label data-l10n-id="otr-encryption-caption" />
+ <separator class="thin" />
+ <hbox align="center">
+ <label data-l10n-id="otr-fingerprint-label" />
+ <hbox class="input-container" flex="1">
+ <html:input
+ id="otrFingerprint"
+ type="text"
+ class="input-inline"
+ readonly="readonly"
+ />
+ </hbox>
+ </hbox>
+ <separator class="thin" />
+ <hbox pack="end">
+ <button
+ id="viewFingerprintButton"
+ data-l10n-id="view-fingerprint-button"
+ oncommand="account.viewFingerprintKeys();"
+ />
+ </hbox>
+ </vbox>
+ </html:div>
+ </tabpanel>
+ </tabpanels>
+ </tabbox> </vbox
+ ></vbox>
+</window>
diff --git a/comm/mail/components/im/content/chat-contact.js b/comm/mail/components/im/content/chat-contact.js
new file mode 100644
index 0000000000..d3e9baf974
--- /dev/null
+++ b/comm/mail/components/im/content/chat-contact.js
@@ -0,0 +1,282 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozXULElement, MozElements, Status, chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+ );
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ /**
+ * The MozChatContactRichlistitem widget displays contact information about user under
+ * chat-groups, online contacts and offline contacts: i.e. icon and username.
+ * On double clicking the element, it gets moved into the conversations.
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatContactRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ ".box-line": "selected",
+ ".contactDisplayName": "value=displayname",
+ ".contactDisplayNameInput": "value=displayname",
+ ".contactStatusText": "value=statusTextWithDash",
+ };
+ }
+
+ static get markup() {
+ return `
+ <vbox class="box-line"></vbox>
+ <stack class="prplBuddyIcon">
+ <html:img class="protoIcon" alt="" />
+ <html:img class="smallStatusIcon" />
+ </stack>
+ <hbox flex="1" class="contact-hbox">
+ <stack>
+ <label crop="end"
+ class="contactDisplayName blistDisplayName">
+ </label>
+ <html:input type="text"
+ class="contactDisplayNameInput"
+ hidden="hidden"/>
+ </stack>
+ <label crop="end"
+ style="flex: 100000 100000;"
+ class="contactStatusText">
+ </label>
+ <button class="startChatBubble"
+ tooltiptext="&openConversationButton.tooltip;">
+ </button>
+ </hbox>
+ `;
+ }
+
+ static get entities() {
+ return ["chrome://messenger/locale/chat.dtd"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-contact-richlistitem");
+
+ this.addEventListener("blur", event => {
+ if (!this.hasAttribute("aliasing")) {
+ return;
+ }
+
+ if (Services.focus.activeWindow == document.defaultView) {
+ this.finishAliasing(true);
+ }
+ });
+
+ this.addEventListener("mousedown", event => {
+ if (
+ !this.hasAttribute("aliasing") &&
+ this.canOpenConversation() &&
+ event.target.classList.contains("startChatBubble")
+ ) {
+ this.openConversation();
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("click", event => {
+ if (
+ !this.hasAttribute("aliasing") &&
+ this.canOpenConversation() &&
+ event.detail == 2
+ ) {
+ this.openConversation();
+ }
+ });
+
+ this.parentNode.addEventListener("mousedown", event => {
+ event.preventDefault();
+ });
+
+ // @implements {nsIObserver}
+ this.observer = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ observe: function (subject, topic, data) {
+ if (
+ topic == "contact-preferred-buddy-changed" ||
+ topic == "contact-display-name-changed" ||
+ topic == "contact-status-changed"
+ ) {
+ this.update();
+ }
+ if (
+ topic == "contact-availability-changed" ||
+ topic == "contact-display-name-changed"
+ ) {
+ this.group.updateContactPosition(subject);
+ }
+ }.bind(this),
+ };
+
+ this.appendChild(this.constructor.fragment);
+
+ this.initializeAttributeInheritance();
+ }
+
+ get displayName() {
+ return this.contact.displayName;
+ }
+
+ update() {
+ this.setAttribute("displayname", this.contact.displayName);
+
+ let statusText = this.contact.statusText;
+ if (statusText) {
+ statusText = " - " + statusText;
+ }
+ this.setAttribute("statusTextWithDash", statusText);
+ let statusType = this.contact.statusType;
+
+ let statusIcon = this.querySelector(".smallStatusIcon");
+ let statusName = Status.toAttribute(statusType);
+ statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName));
+ statusIcon.setAttribute("alt", Status.toLabel(statusType));
+
+ if (this.contact.canSendMessage) {
+ this.setAttribute("cansend", "true");
+ } else {
+ this.removeAttribute("cansend");
+ }
+
+ let protoIcon = this.querySelector(".protoIcon");
+ protoIcon.setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(this.contact.preferredBuddy.protocol)
+ );
+ ChatIcons.setProtocolIconOpacity(protoIcon, statusName);
+ }
+
+ build(contact) {
+ this.contact = contact;
+ this.contact.addObserver(this.observer);
+ this.update();
+ }
+
+ destroy() {
+ this.contact.removeObserver(this.observer);
+ delete this.contact;
+ this.remove();
+ }
+
+ startAliasing() {
+ if (this.hasAttribute("aliasing")) {
+ return; // prevent re-entry.
+ }
+
+ this.setAttribute("aliasing", "true");
+ let input = this.querySelector(".contactDisplayNameInput");
+ let label = this.querySelector(".contactDisplayName");
+ input.removeAttribute("hidden");
+ label.setAttribute("hidden", "true");
+ input.focus();
+
+ this._inputBlurListener = function (event) {
+ this.finishAliasing(true);
+ }.bind(this);
+ input.addEventListener("blur", this._inputBlurListener);
+
+ // Some keys (home/end for example) can make the selected item
+ // of the richlistbox change without producing a blur event on
+ // our textbox. Make sure we watch richlistbox selection changes.
+ this._parentSelectListener = function (event) {
+ if (event.target == this.parentNode) {
+ this.finishAliasing(true);
+ }
+ }.bind(this);
+ this.parentNode.addEventListener("select", this._parentSelectListener);
+ }
+
+ finishAliasing(save) {
+ // Cache the parentNode because when we change the contact alias, we
+ // trigger a re-order (and a removeContact call), which sets
+ // this.parentNode to undefined.
+ let listbox = this.parentNode;
+ let input = this.querySelector(".contactDisplayNameInput");
+ let label = this.querySelector(".contactDisplayName");
+ input.setAttribute("hidden", "hidden");
+ label.removeAttribute("hidden");
+ if (save) {
+ this.contact.alias = input.value;
+ }
+ this.removeAttribute("aliasing");
+ listbox.removeEventListener("select", this._parentSelectListener);
+ input.removeEventListener("blur", this._inputBlurListener);
+ delete this._parentSelectListener;
+ listbox.focus();
+ }
+
+ deleteContact() {
+ this.contact.remove();
+ }
+
+ canOpenConversation() {
+ return this.contact.canSendMessage;
+ }
+
+ openConversation() {
+ let prplConv = this.contact.createConversation();
+ let uiConv = IMServices.conversations.getUIConversation(prplConv);
+ chatHandler.focusConversation(uiConv);
+ }
+
+ keyPress(event) {
+ switch (event.keyCode) {
+ // If Enter or Return is pressed, open a new conversation
+ case event.DOM_VK_RETURN:
+ if (this.hasAttribute("aliasing")) {
+ this.finishAliasing(true);
+ } else if (this.canOpenConversation()) {
+ this.openConversation();
+ }
+ break;
+
+ case event.DOM_VK_F2:
+ if (!this.hasAttribute("aliasing")) {
+ this.startAliasing();
+ }
+ break;
+
+ case event.DOM_VK_ESCAPE:
+ if (this.hasAttribute("aliasing")) {
+ this.finishAliasing(false);
+ }
+ break;
+ }
+ }
+ disconnectedCallback() {
+ if (this.contact) {
+ this.contact.removeObserver(this.observer);
+ delete this.contact;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatContactRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define(
+ "chat-contact-richlistitem",
+ MozChatContactRichlistitem,
+ {
+ extends: "richlistitem",
+ }
+ );
+}
diff --git a/comm/mail/components/im/content/chat-conversation-info.js b/comm/mail/components/im/content/chat-conversation-info.js
new file mode 100644
index 0000000000..a8004a4c3f
--- /dev/null
+++ b/comm/mail/components/im/content/chat-conversation-info.js
@@ -0,0 +1,353 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozElements MozXULElement chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ ChromeUtils.defineESModuleGetters(this, {
+ OTR: "resource:///modules/OTR.sys.mjs",
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+ });
+
+ /**
+ * The MozChatConversationInfo widget displays information about a chat:
+ * e.g. the channel name and topic of an IRC channel, or nick, user image and
+ * status of a conversation partner.
+ * It is typically shown at the top right of the chat UI.
+ *
+ * @augments {MozXULElement}
+ */
+ class MozChatConversationInfo extends MozXULElement {
+ static get inheritedAttributes() {
+ return { ".displayName": "value=displayName" };
+ }
+
+ static get markup() {
+ return `
+ <linkset>
+ <html:link rel="localization" href="messenger/otr/chat.ftl"/>
+ </linkset>
+
+ <html:div class="displayUserAccount">
+ <stack>
+ <html:img class="userIcon" alt="" />
+ <html:img class="statusTypeIcon" alt="" />
+ </stack>
+ <html:div class="nameAndStatusGrid">
+ <description class="displayName" crop="end"></description>
+ <html:img class="protoIcon" alt="" />
+ <html:hr />
+ <description class="statusMessage" crop="end"></description>
+ <!-- FIXME: A keyboard user cannot focus the hidden input, nor
+ - click the above description box in order to reveal it. -->
+ <html:input class="statusMessageInput input-inline"
+ hidden="hidden"/>
+ </html:div>
+ </html:div>
+ <hbox class="encryption-container themeable-brighttext"
+ align="center"
+ hidden="true">
+ <label class="encryption-label"
+ crop="end"
+ data-l10n-id="state-label"
+ flex="1"/>
+ <toolbarbutton id="chatEncryptionButton"
+ mode="dialog"
+ class="encryption-button"
+ type="menu"
+ wantdropmarker="true"
+ label="Insecure"
+ data-l10n-id="start-tooltip">
+ <menupopup class="encryption-menu-popup">
+ <menuitem class="otr-start" data-l10n-id="start-label"
+ oncommand='this.closest("chat-conversation-info").onOtrStartClicked();'/>
+ <menuitem class="otr-end" data-l10n-id="end-label"
+ oncommand='this.closest("chat-conversation-info").onOtrEndClicked();'/>
+ <menuitem class="otr-auth" data-l10n-id="auth-label"
+ oncommand='this.closest("chat-conversation-info").onOtrAuthClicked();'/>
+ <menuitem class="protocol-encrypt" data-l10n-id="start-label"/>
+ </menupopup>
+ </toolbarbutton>
+ </hbox>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+ this.setAttribute("orient", "vertical");
+
+ this.appendChild(this.constructor.fragment);
+
+ this.topicEditable = false;
+ this.editingTopic = false;
+ this.noTopic = false;
+
+ this.topic.addEventListener("click", this.startEditTopic.bind(this));
+
+ this.querySelector(".protocol-encrypt").addEventListener("click", () =>
+ this.initializeEncryption()
+ );
+
+ let encryptionButton = this.querySelector(".encryption-button");
+ encryptionButton.addEventListener(
+ "command",
+ this.encryptionButtonClicked
+ );
+ if (Services.prefs.getBoolPref("chat.otr.enable")) {
+ OTRUI.setNotificationBox(chatHandler.msgNotificationBar);
+ }
+ this.initializeAttributeInheritance();
+ }
+
+ get topic() {
+ return this.querySelector(".statusMessage");
+ }
+
+ get topicInput() {
+ return this.querySelector(".statusMessageInput");
+ }
+
+ finishEditTopic(save) {
+ if (!this.editingTopic) {
+ return;
+ }
+
+ let panel = this.getSelectedPanel();
+ let topic = this.topic;
+ let topicInput = this.topicInput;
+ topic.removeAttribute("hidden");
+ topicInput.hidden = true;
+ if (save) {
+ // apply the new topic only if it is different from the current one
+ if (topicInput.value != topicInput.getAttribute("value")) {
+ panel._conv.topic = topicInput.value;
+ }
+ }
+ this.editingTopic = false;
+
+ topicInput.removeEventListener("keypress", this._topicKeyPress, true);
+ delete this._topicKeyPress;
+ topicInput.removeEventListener("blur", this._topicBlur);
+ delete this._topicBlur;
+
+ // After hiding the input, the focus is on an element that can't receive
+ // keyboard events, so move it to somewhere else.
+ // FIXME: jumping focus should be removed once editing the topic input
+ // becomes accessible to keyboard users.
+ panel.editor.focus();
+ }
+
+ topicKeyPress(event) {
+ switch (event.keyCode) {
+ case event.DOM_VK_RETURN:
+ this.finishEditTopic(true);
+ break;
+
+ case event.DOM_VK_ESCAPE:
+ this.finishEditTopic(false);
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ }
+
+ topicBlur(event) {
+ if (event.target == this.topicInput) {
+ this.finishEditTopic(true);
+ }
+ }
+
+ startEditTopic() {
+ let topic = this.topic;
+ let topicInput = this.topicInput;
+ if (!this.topicEditable || this.editingTopic) {
+ return;
+ }
+
+ this.editingTopic = true;
+
+ topicInput.hidden = false;
+ topic.setAttribute("hidden", "true");
+ this._topicKeyPress = this.topicKeyPress.bind(this);
+ topicInput.addEventListener("keypress", this._topicKeyPress);
+ this._topicBlur = this.topicBlur.bind(this);
+ topicInput.addEventListener("blur", this._topicBlur);
+ topicInput.getBoundingClientRect();
+ if (this.noTopic) {
+ topicInput.value = "";
+ } else {
+ topicInput.value = topic.value;
+ }
+ topicInput.select();
+ }
+
+ encryptionButtonClicked(aEvent) {
+ aEvent.preventDefault();
+ let encryptionMenu = this.querySelector(".encryption-menu-popup");
+ encryptionMenu.openPopup(encryptionMenu.parentNode, "after_start");
+ }
+
+ onOtrStartClicked() {
+ // check if start-menu-command is disabled, if yes exit
+ let convBinding = this.getSelectedPanel();
+ let uiConv = convBinding._conv;
+ let conv = uiConv.target;
+ let context = OTR.getContext(conv);
+ let bundleId =
+ "alert-" +
+ (context.msgstate === OTR.getMessageState().OTRL_MSGSTATE_ENCRYPTED
+ ? "refresh"
+ : "start");
+ OTRUI.sendSystemAlert(uiConv, conv, bundleId);
+ OTR.sendQueryMsg(conv);
+ }
+
+ onOtrEndClicked() {
+ let convBinding = this.getSelectedPanel();
+ let uiConv = convBinding._conv;
+ let conv = uiConv.target;
+ OTR.disconnect(conv, false);
+ let bundleId = "alert-gone-insecure";
+ OTRUI.sendSystemAlert(uiConv, conv, bundleId);
+ }
+
+ onOtrAuthClicked() {
+ let convBinding = this.getSelectedPanel();
+ let uiConv = convBinding._conv;
+ let conv = uiConv.target;
+ OTRUI.openAuth(window, conv.normalizedName, "start", uiConv);
+ }
+
+ initializeEncryption() {
+ const convBinding = this.getSelectedPanel();
+ const uiConv = convBinding._conv;
+ uiConv.initializeEncryption();
+ }
+
+ getSelectedPanel() {
+ for (let element of document.getElementById("conversationsBox")
+ .children) {
+ if (!element.hidden) {
+ return element;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Sets the shown protocol icon.
+ *
+ * @param {prplIProtocol} protocol - The protocol to show.
+ */
+ setProtocol(protocol) {
+ this.querySelector(".protoIcon").setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(protocol)
+ );
+ }
+
+ /**
+ * Sets the shown user icon.
+ *
+ * @param {string|null} iconURI - The image uri to show, or "" to use the
+ * fallback, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIcon(iconURI, useFallback) {
+ ChatIcons.setUserIconSrc(
+ this.querySelector(".userIcon"),
+ iconURI,
+ useFallback
+ );
+ }
+
+ /**
+ * Sets the shown status icon.
+ *
+ * @param {string} statusName - The name of the status.
+ */
+ setStatusIcon(statusName) {
+ let statusIcon = this.querySelector(".statusTypeIcon");
+ if (statusName === null) {
+ statusIcon.hidden = true;
+ statusIcon.removeAttribute("src");
+ } else {
+ statusIcon.hidden = false;
+ let src = ChatIcons.getStatusIconURI(statusName);
+ if (src) {
+ statusIcon.setAttribute("src", src);
+ } else {
+ /* Unexpected missing icon. */
+ statusIcon.removeAttribute("src");
+ }
+ }
+ }
+
+ /**
+ * Sets the text for the status of a user, or the topic of a chat.
+ *
+ * @param {string} text - The text to display.
+ * @param {boolean} [noTopic=false] - Whether to stylize the status to
+ * indicate the status is some fallback text.
+ */
+ setStatusText(text, noTopic = false) {
+ let statusEl = this.topic;
+
+ statusEl.setAttribute("value", text);
+ statusEl.setAttribute("tooltiptext", text);
+ statusEl.toggleAttribute("noTopic", noTopic);
+ }
+
+ /**
+ * Sets the element to display a user status. The user icon needs to be set
+ * separately with setUserIcon.
+ *
+ * @param {string} statusName - The internal name for the status.
+ * @param {string} statusText - The text to display as the status.
+ */
+ setStatus(statusName, statusText) {
+ this.setStatusIcon(statusName);
+ this.setStatusText(statusText);
+ this.topicEditable = false;
+ }
+
+ /**
+ * Sets the element to display a chat status.
+ *
+ * @param {string} topicText - The topic text for the chat, or some fallback
+ * text used if the chat has no topic.
+ * @param {boolean} noTopic - Whether the chat has no topic.
+ * @param {boolean} topicEditable - Whether the topic can be set by the
+ * user.
+ */
+ setAsChat(topicText, noTopic, topicEditable) {
+ this.noTopic = noTopic;
+ this.topicEditable = topicEditable;
+ this.setStatusText(topicText, noTopic);
+ this.setStatusIcon("chat");
+ }
+
+ /**
+ * Empty the element's display.
+ */
+ clear() {
+ this.querySelector(".protoIcon").removeAttribute("src");
+ this.setStatusText("");
+ this.setStatusIcon(null);
+ this.setUserIcon("", false);
+ this.topicEditable = false;
+ }
+ }
+ customElements.define("chat-conversation-info", MozChatConversationInfo);
+}
diff --git a/comm/mail/components/im/content/chat-conversation.js b/comm/mail/components/im/content/chat-conversation.js
new file mode 100644
index 0000000000..9d0068ac6f
--- /dev/null
+++ b/comm/mail/components/im/content/chat-conversation.js
@@ -0,0 +1,1760 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozElements, MozXULElement, chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+ );
+ const { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+ );
+ const { TextboxSize } = ChromeUtils.importESModule(
+ "resource:///modules/imTextboxUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+ );
+
+ /**
+ * The MozChatConversation widget displays the entire chat conversation
+ * including status notifications
+ *
+ * @augments {MozXULElement}
+ */
+ class MozChatConversation extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ browser: "autoscrollpopup",
+ };
+ }
+
+ constructor() {
+ super();
+
+ ChromeUtils.defineESModuleGetters(this, {
+ ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs",
+ });
+
+ this.observer = {
+ // @see {nsIObserver}
+ observe: (subject, topic, data) => {
+ if (topic == "conversation-loaded") {
+ if (subject != this.convBrowser) {
+ return;
+ }
+
+ this.convBrowser.progressBar = this.progressBar;
+
+ // Display all queued messages. Use a timeout so that message text
+ // modifiers can be added with observers for this notification.
+ if (!this.loaded) {
+ setTimeout(this._showFirstMessages.bind(this), 0);
+ }
+
+ Services.obs.removeObserver(this.observer, "conversation-loaded");
+
+ // Report the active chat message theme via telemetry. This is not
+ // inside the conv browser itself, since the browser is also used
+ // for the theme preview in the settings.
+ Services.telemetry.scalarSet(
+ "tb.chat.active_message_theme",
+ `${this.convBrowser.theme.name}:${this.convBrowser.theme.variant}`
+ );
+
+ return;
+ }
+
+ switch (topic) {
+ case "new-text":
+ if (this.loaded && this.addMsg(subject)) {
+ // This will mark the conv as read, but also update the conv title
+ // with the new unread count etc.
+ this.tab.update();
+ }
+ break;
+
+ case "update-text":
+ if (this.loaded) {
+ this.updateMsg(subject);
+ }
+ break;
+
+ case "remove-text":
+ if (this.loaded) {
+ this.removeMsg(data);
+ }
+ break;
+
+ case "status-text-changed":
+ this._statusText = data || "";
+ this.displayStatusText();
+ break;
+
+ case "replying-to-prompt":
+ this.addPrompt(data);
+ break;
+
+ case "target-prpl-conversation-changed":
+ case "update-conv-title":
+ if (this.tab && this.conv) {
+ this.tab.setAttribute("label", this.conv.title);
+ }
+ break;
+
+ // Update the status too.
+ case "update-buddy-status":
+ case "update-buddy-icon":
+ case "update-conv-icon":
+ case "update-conv-chatleft":
+ if (this.tab && this._isConversationSelected) {
+ this.updateConvStatus();
+ }
+ break;
+
+ case "update-typing":
+ if (this.tab && this._isConversationSelected) {
+ this._currentTypingName = data;
+ this.updateConvStatus();
+ }
+ break;
+
+ case "chat-buddy-add":
+ if (!this._isConversationSelected) {
+ break;
+ }
+ for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) {
+ this.insertBuddy(this.createBuddy(nick));
+ }
+ this.updateParticipantCount();
+ break;
+
+ case "chat-buddy-remove":
+ if (!this._isConversationSelected) {
+ for (let nick of subject.QueryInterface(
+ Ci.nsISimpleEnumerator
+ )) {
+ let name = nick.toString();
+ if (this._isBuddyActive(name)) {
+ delete this._activeBuddies[name];
+ }
+ }
+ break;
+ }
+ for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) {
+ this.removeBuddy(nick.toString());
+ }
+ this.updateParticipantCount();
+ break;
+
+ case "chat-buddy-update":
+ this.updateBuddy(subject, data);
+ break;
+
+ case "chat-update-topic":
+ if (this._isConversationSelected) {
+ this.updateTopic();
+ }
+ break;
+ case "update-conv-encryption":
+ if (this._isConversationSelected) {
+ this.ChatEncryption.updateEncryptionButton(document, this.conv);
+ }
+ break;
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.loaded = false;
+ this._readCount = 0;
+ this._statusText = "";
+ this._pendingValueChangedCall = false;
+ this._nickEscape = /[[\]{}()*+?.\\^$|]/g;
+ this._currentTypingName = "";
+
+ // This value represents the difference between the deck's height and the
+ // textbox's content height (borders, margins, paddings).
+ // Differ according to the Operating System native theme.
+ this._TEXTBOX_VERTICAL_OVERHEAD = 0;
+
+ // Ratio textbox height / conversation height.
+ // 0.1 means that the textbox's height is 10% of the conversation's height.
+ this._TEXTBOX_RATIO = 0.1;
+
+ this.setAttribute("orient", "vertical");
+ this.setAttribute("flex", "1");
+ this.classList.add("convBox");
+
+ this.convTop = document.createXULElement("vbox");
+ this.convTop.setAttribute("flex", "1");
+ this.convTop.classList.add("conv-top");
+
+ this.notification = document.createXULElement("vbox");
+
+ this.convBrowser = document.createXULElement("browser", {
+ is: "conversation-browser",
+ });
+ this.convBrowser.setAttribute("flex", "1");
+ this.convBrowser.setAttribute("type", "content");
+ this.convBrowser.setAttribute("messagemanagergroup", "browsers");
+
+ this.progressBar = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "progress"
+ );
+ this.progressBar.setAttribute("hidden", "hidden");
+
+ this.findbar = document.createXULElement("findbar");
+ this.findbar.setAttribute("reversed", "true");
+
+ this.convTop.appendChild(this.notification);
+ this.convTop.appendChild(this.convBrowser);
+ this.convTop.appendChild(this.progressBar);
+ this.convTop.appendChild(this.findbar);
+
+ this.splitter = document.createXULElement("splitter");
+ this.splitter.setAttribute("orient", "vertical");
+ this.splitter.classList.add("splitter");
+
+ this.convStatusContainer = document.createXULElement("hbox");
+ this.convStatusContainer.setAttribute("hidden", "true");
+ this.convStatusContainer.classList.add("conv-status-container");
+
+ this.convStatus = document.createXULElement("description");
+ this.convStatus.classList.add("plain");
+ this.convStatus.classList.add("conv-status");
+ this.convStatus.setAttribute("crop", "end");
+
+ this.convStatusContainer.appendChild(this.convStatus);
+
+ this.convBottom = document.createXULElement("stack");
+ this.convBottom.classList.add("conv-bottom");
+
+ this.inputBox = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "textarea"
+ );
+ this.inputBox.classList.add("conv-textbox");
+
+ this.charCounter = document.createXULElement("description");
+ this.charCounter.classList.add("conv-counter");
+ this.convBottom.appendChild(this.inputBox);
+ this.convBottom.appendChild(this.charCounter);
+
+ this.appendChild(this.convTop);
+ this.appendChild(this.splitter);
+ this.appendChild(this.convStatusContainer);
+ this.appendChild(this.convBottom);
+
+ this.inputBox.addEventListener("keypress", this.inputKeyPress.bind(this));
+ this.inputBox.addEventListener(
+ "input",
+ this.inputValueChanged.bind(this)
+ );
+ this.inputBox.addEventListener(
+ "overflow",
+ this.inputExpand.bind(this),
+ true
+ );
+ this.inputBox.addEventListener(
+ "underflow",
+ this._onTextboxUnderflow,
+ true
+ );
+
+ new MutationObserver(
+ function (aMutations) {
+ for (let mutation of aMutations) {
+ if (mutation.oldValue == "dragging") {
+ this._onSplitterChange();
+ break;
+ }
+ }
+ }.bind(this)
+ ).observe(this.splitter, {
+ attributes: true,
+ attributeOldValue: true,
+ attributeFilter: ["state"],
+ });
+
+ this.convBrowser.addEventListener(
+ "keypress",
+ this.browserKeyPress.bind(this)
+ );
+ this.convBrowser.addEventListener(
+ "dblclick",
+ this.browserDblClick.bind(this)
+ );
+ Services.obs.addObserver(this.observer, "conversation-loaded");
+
+ // @implements {nsIObserver}
+ this.prefObserver = (subject, topic, data) => {
+ if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
+ this.inputBox.setAttribute("spellcheck", "true");
+ this.spellchecker.enabled = true;
+ } else {
+ this.inputBox.removeAttribute("spellcheck");
+ this.spellchecker.enabled = false;
+ }
+ };
+ Services.prefs.addObserver("mail.spellcheck.inline", this.prefObserver);
+
+ this.initializeAttributeInheritance();
+ }
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ this.notification.prepend(element);
+ });
+ }
+ return this._notificationBox;
+ }
+
+ destroy() {
+ if (this._conv) {
+ this._forgetConv();
+ }
+
+ Services.prefs.removeObserver(
+ "mail.spellcheck.inline",
+ this.prefObserver
+ );
+ }
+
+ _forgetConv(shouldClose) {
+ this._conv.removeObserver(this.observer);
+ delete this._conv;
+ this.convBrowser.destroy();
+ this.findbar.destroy();
+ }
+
+ close() {
+ this._forgetConv(true);
+ }
+
+ _showFirstMessages() {
+ this.loaded = true;
+ let messages = this._conv.getMessages();
+ this._readCount = messages.length - this._conv.unreadMessageCount;
+ if (this._readCount) {
+ this._writingContextMessages = true;
+ }
+ messages.forEach(this.addMsg.bind(this));
+ delete this._writingContextMessages;
+
+ if (this.tab && this.tab.selected && document.hasFocus()) {
+ // This will mark the conv as read, but also update the conv title
+ // with the new unread count etc.
+ this.tab.update();
+ }
+ }
+
+ displayStatusText() {
+ this.convStatus.value = this._statusText;
+ if (this._statusText) {
+ this.convStatusContainer.removeAttribute("hidden");
+ } else {
+ this.convStatusContainer.setAttribute("hidden", "true");
+ }
+ }
+
+ addMsg(aMsg) {
+ if (!this.loaded) {
+ throw new Error("Calling addMsg before the browser is ready?");
+ }
+
+ var conv = aMsg.conversation;
+ if (!conv) {
+ // The conversation has already been destroyed,
+ // probably because the window was closed.
+ // Return without doing anything.
+ return false;
+ }
+
+ // Ugly hack... :(
+ if (!aMsg.system && conv.isChat) {
+ let name = aMsg.who;
+ let color;
+ if (this.buddies.has(name)) {
+ let buddy = this.buddies.get(name);
+ color = buddy.color;
+ buddy.removeAttribute("inactive");
+ this._activeBuddies[name] = true;
+ } else {
+ // Buddy no longer in the room
+ color = this._computeColor(name);
+ }
+ aMsg.color = "color: hsl(" + color + ", 100%, 40%);";
+ }
+
+ // Porting note: In TB, this.tab points at the imconv richlistitem element.
+ let read = this._readCount > 0;
+ let isUnreadMessage = !read && aMsg.incoming && !aMsg.system;
+ let isTabFocused = this.tab && this.tab.selected && document.hasFocus();
+ let shouldSetUnreadFlag = this.tab && isUnreadMessage && !isTabFocused;
+ let firstUnread =
+ this.tab &&
+ !this.tab.hasAttribute("unread") &&
+ isUnreadMessage &&
+ this._isAfterFirstRealMessage &&
+ (!isTabFocused || this._writingContextMessages);
+
+ // Since the unread flag won't be set if the tab is focused,
+ // we need the following when showing the first messages to stop
+ // firstUnread being set for subsequent messages.
+ if (firstUnread) {
+ delete this._writingContextMessages;
+ }
+
+ this.convBrowser.appendMessage(aMsg, read, firstUnread);
+ if (!aMsg.system) {
+ this._isAfterFirstRealMessage = true;
+ }
+
+ if (read) {
+ --this._readCount;
+ if (!this._readCount && !this._isAfterFirstRealMessage) {
+ // If all the context messages were system messages, we don't want
+ // an unread ruler after the context messages, so we forget that
+ // we had context messages.
+ delete this._writingContextMessages;
+ }
+ return false;
+ }
+
+ if (isUnreadMessage && (!aMsg.conversation.isChat || aMsg.containsNick)) {
+ this._lastPing = aMsg.who;
+ this._lastPingTime = aMsg.time;
+ }
+
+ if (shouldSetUnreadFlag) {
+ if (conv.isChat && aMsg.containsNick) {
+ this.tab.setAttribute("attention", "true");
+ }
+ this.tab.setAttribute("unread", "true");
+ }
+
+ return isTabFocused;
+ }
+
+ /**
+ * Updates an existing message with the matching remote ID.
+ *
+ * @param {imIMessage} aMsg - Message to update.
+ */
+ updateMsg(aMsg) {
+ if (!this.loaded) {
+ throw new Error("Calling updateMsg before the browser is ready?");
+ }
+
+ var conv = aMsg.conversation;
+ if (!conv) {
+ // The conversation has already been destroyed,
+ // probably because the window was closed.
+ // Return without doing anything.
+ return;
+ }
+
+ // Update buddy color.
+ // Ugly hack... :(
+ if (!aMsg.system && conv.isChat) {
+ let name = aMsg.who;
+ let color;
+ if (this.buddies.has(name)) {
+ let buddy = this.buddies.get(name);
+ color = buddy.color;
+ buddy.removeAttribute("inactive");
+ this._activeBuddies[name] = true;
+ } else {
+ // Buddy no longer in the room
+ color = this._computeColor(name);
+ }
+ aMsg.color = "color: hsl(" + color + ", 100%, 40%);";
+ }
+
+ this.convBrowser.replaceMessage(aMsg);
+ }
+
+ /**
+ * Removes an existing message with matching remote ID.
+ *
+ * @param {string} remoteId - Remote ID of the message to remove.
+ */
+ removeMsg(remoteId) {
+ if (!this.loaded) {
+ throw new Error("Calling removeMsg before the browser is ready?");
+ }
+
+ this.convBrowser.removeMessage(remoteId);
+ }
+
+ sendMsg(aMsg) {
+ if (!aMsg) {
+ return;
+ }
+
+ let account = this._conv.account;
+
+ if (aMsg.startsWith("/")) {
+ let convToFocus = {};
+
+ // The /say command is used to bypass command processing
+ // (/say can be shortened to just /).
+ // "/say" or "/say " should be ignored, as should "/" and "/ ".
+ if (aMsg.match(/^\/(?:say)? ?$/)) {
+ this.resetInput();
+ return;
+ }
+
+ if (aMsg.match(/^\/(?:say)? .*/)) {
+ aMsg = aMsg.slice(aMsg.indexOf(" ") + 1);
+ } else if (
+ IMServices.cmd.executeCommand(aMsg, this._conv.target, convToFocus)
+ ) {
+ this._conv.sendTyping("");
+ this.resetInput();
+ if (convToFocus.value) {
+ chatHandler.focusConversation(convToFocus.value);
+ }
+ return;
+ }
+
+ if (account.protocol.slashCommandsNative && account.connected) {
+ let cmd = aMsg.match(/^\/[^ ]+/);
+ if (cmd && cmd != "/me") {
+ this._conv.systemMessage(
+ this.bundle.formatStringFromName("unknownCommand", [cmd], 1),
+ true
+ );
+ return;
+ }
+ }
+ }
+
+ this._conv.sendMsg(aMsg, false, false);
+
+ // reset the textbox to its original size
+ this.resetInput();
+ }
+
+ _onSplitterChange() {
+ // set the default height as the deck height (modified by the splitter)
+ this.inputBox.defaultHeight =
+ parseInt(this.inputBox.parentNode.getBoundingClientRect().height) -
+ this._TEXTBOX_VERTICAL_OVERHEAD;
+ }
+
+ calculateTextboxDefaultHeight() {
+ let totalSpace = parseInt(
+ window.getComputedStyle(this).getPropertyValue("height")
+ );
+ let textboxStyle = window.getComputedStyle(this.inputBox);
+ let lineHeight = textboxStyle.lineHeight;
+ if (lineHeight == "normal") {
+ lineHeight = parseFloat(textboxStyle.fontSize) * 1.2;
+ } else {
+ lineHeight = parseFloat(lineHeight);
+ }
+
+ // Compute the overhead size.
+ let textboxHeight = this.inputBox.clientHeight;
+ let deckHeight = this.inputBox.parentNode.getBoundingClientRect().height;
+ this._TEXTBOX_VERTICAL_OVERHEAD = deckHeight - textboxHeight;
+
+ // Calculate the number of lines to display.
+ let numberOfLines = Math.round(
+ (totalSpace * this._TEXTBOX_RATIO) / lineHeight
+ );
+ if (numberOfLines <= 0) {
+ numberOfLines = 1;
+ }
+ if (!this._maxEmptyLines) {
+ this._maxEmptyLines = Services.prefs.getIntPref(
+ "messenger.conversations.textbox.defaultMaxLines"
+ );
+ }
+
+ if (numberOfLines > this._maxEmptyLines) {
+ numberOfLines = this._maxEmptyLines;
+ }
+ this.inputBox.defaultHeight = numberOfLines * lineHeight;
+
+ // set minimum height (in case the user moves the splitter)
+ this.inputBox.parentNode.style.minHeight =
+ lineHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ }
+
+ initTextboxFormat() {
+ // Init the textbox size
+ this.calculateTextboxDefaultHeight();
+ this.inputBox.parentNode.style.height =
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ this.inputBox.style.overflowY = "hidden";
+
+ this.spellchecker = new InlineSpellChecker(this.inputBox);
+ if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
+ this.inputBox.setAttribute("spellcheck", "true");
+ this.spellchecker.enabled = true;
+ } else {
+ this.inputBox.removeAttribute("spellcheck");
+ this.spellchecker.enabled = false;
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ inputKeyPress(event) {
+ let text = this.inputBox.value;
+
+ const navKeyCodes = [
+ KeyEvent.DOM_VK_PAGE_UP,
+ KeyEvent.DOM_VK_PAGE_DOWN,
+ KeyEvent.DOM_VK_HOME,
+ KeyEvent.DOM_VK_END,
+ KeyEvent.DOM_VK_UP,
+ KeyEvent.DOM_VK_DOWN,
+ ];
+
+ // Pass navigation keys to the browser if
+ // 1) the textbox is empty or 2) it's an IB-specific key combination
+ if (
+ (!text && navKeyCodes.includes(event.keyCode)) ||
+ ((event.shiftKey || event.altKey) &&
+ (event.keyCode == KeyEvent.DOM_VK_PAGE_UP ||
+ event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN))
+ ) {
+ let newEvent = new KeyboardEvent("keypress", event);
+ event.preventDefault();
+ event.stopPropagation();
+ // Keyboard events must be sent to the focused element for bubbling to work.
+ this.convBrowser.focus();
+ this.convBrowser.dispatchEvent(newEvent);
+ this.inputBox.focus();
+ return;
+ }
+
+ // When attempting to copy an empty selection, copy the
+ // browser selection instead (see bug 693).
+ // The 'C' won't be lowercase if caps lock is enabled.
+ if (
+ (event.charCode == 99 /* 'c' */ ||
+ (event.charCode == 67 /* 'C' */ && !event.shiftKey)) &&
+ (navigator.platform.includes("Mac") ? event.metaKey : event.ctrlKey) &&
+ this.inputBox.selectionStart == this.inputBox.selectionEnd
+ ) {
+ this.convBrowser.doCommand();
+ return;
+ }
+
+ // We don't want to enable tab completion if the user has selected
+ // some text, as it's not clear what the user would expect
+ // to happen in that case.
+ let noSelection = !(
+ this.inputBox.selectionEnd - this.inputBox.selectionStart
+ );
+
+ // Undo tab complete.
+ if (
+ noSelection &&
+ this._completions &&
+ event.keyCode == KeyEvent.DOM_VK_BACK_SPACE &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey
+ ) {
+ if (text == this._beforeTabComplete) {
+ // Nothing to undo, so let backspace act normally.
+ delete this._completions;
+ } else {
+ event.preventDefault();
+
+ // First undo the comma separating multiple nicks or the suffix.
+ // More than one nick:
+ // "nick1, nick2: " -> "nick1: nick2"
+ // Single nick: remove the suffix
+ // "nick1: " -> "nick1"
+ let pos = this.inputBox.selectionStart;
+ const suffix = ": ";
+ if (
+ pos > suffix.length &&
+ text.substring(pos - suffix.length, pos) == suffix
+ ) {
+ let completions = Array.from(this.buddies.keys());
+ // Check if the preceding words are a sequence of nick completions.
+ let preceding = text.substring(0, pos - suffix.length).split(", ");
+ if (preceding.every(n => completions.includes(n))) {
+ let s = preceding.pop();
+ if (preceding.length) {
+ s = suffix + s;
+ }
+ this.inputBox.selectionStart -= s.length + suffix.length;
+ this.addString(s);
+ if (this._completions[0].slice(-suffix.length) == suffix) {
+ this._completions = this._completions.map(c =>
+ c.slice(0, -suffix.length)
+ );
+ }
+ if (
+ this._completions.length == 1 &&
+ this.inputBox.value == this._beforeTabComplete
+ ) {
+ // Nothing left to undo or to cycle through.
+ delete this._completions;
+ }
+ return;
+ }
+ }
+
+ // Full undo.
+ this.inputBox.selectionStart = 0;
+ this.addString(this._beforeTabComplete);
+ delete this._completions;
+ return;
+ }
+ }
+
+ // Tab complete.
+ // Keep the default behavior of the tab key if the input box
+ // is empty or a modifier is used.
+ if (
+ event.keyCode == KeyEvent.DOM_VK_TAB &&
+ text.length != 0 &&
+ noSelection &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ (!event.shiftKey || this._completions)
+ ) {
+ event.preventDefault();
+
+ if (this._completions) {
+ // Tab has been pressed more than once.
+ if (this._completions.length == 1) {
+ return;
+ }
+ if (this._shouldListCompletionsLater) {
+ this._conv.systemMessage(this._shouldListCompletionsLater);
+ delete this._shouldListCompletionsLater;
+ }
+
+ this.inputBox.selectionStart = this._completionsStart;
+ if (event.shiftKey) {
+ // Reverse cycle completions.
+ this._completionsIndex -= 2;
+ if (this._completionsIndex < 0) {
+ this._completionsIndex += this._completions.length;
+ }
+ }
+ this.addString(this._completions[this._completionsIndex++]);
+ this._completionsIndex %= this._completions.length;
+ return;
+ }
+
+ let completions = [];
+ let firstWordSuffix = " ";
+ let secondNick = false;
+
+ // Second regex result will contain word without leading special characters.
+ this._beforeTabComplete = text.substring(
+ 0,
+ this.inputBox.selectionStart
+ );
+ let words = this._beforeTabComplete.match(/\S*?([\w-]+)?$/);
+ let word = words[0];
+ if (!word) {
+ return;
+ }
+ let isFirstWord = this.inputBox.selectionStart == word.length;
+
+ // Check if we are completing a command.
+ let completingCommand = isFirstWord && word[0] == "/";
+ if (completingCommand) {
+ for (let cmd of IMServices.cmd.listCommandsForConversation(
+ this._conv
+ )) {
+ // It's possible to have a global and a protocol specific command
+ // with the same name. Avoid duplicates in the |completions| array.
+ let name = "/" + cmd.name;
+ if (!completions.includes(name)) {
+ completions.push(name);
+ }
+ }
+ } else {
+ // If it's not a command, the only thing we can complete is a nick.
+ if (!this._conv.isChat) {
+ return;
+ }
+
+ firstWordSuffix = ": ";
+ completions = Array.from(this.buddies.keys());
+
+ let outgoingNick = this._conv.nick;
+ completions = completions.filter(c => c != outgoingNick);
+
+ // Check if the preceding words are a sequence of nick completions.
+ let wordStart = this.inputBox.selectionStart - word.length;
+ if (wordStart > 2) {
+ let separator = text.substring(wordStart - 2, wordStart);
+ if (separator == ": " || separator == ", ") {
+ let preceding = text.substring(0, wordStart - 2).split(", ");
+ if (preceding.every(n => completions.includes(n))) {
+ secondNick = true;
+ isFirstWord = true;
+ // Remove preceding completions from possible completions.
+ completions = completions.filter(c => !preceding.includes(c));
+ }
+ }
+ }
+ }
+
+ // Keep only the completions that share |word| as a prefix.
+ // Be case insensitive only if |word| is entirely lower case.
+ let condition;
+ if (word.toLocaleLowerCase() == word) {
+ condition = c => c.toLocaleLowerCase().startsWith(word);
+ } else {
+ condition = c => c.startsWith(word);
+ }
+ let matchingCompletions = completions.filter(condition);
+ if (!matchingCompletions.length && words[1]) {
+ word = words[1];
+ firstWordSuffix = " ";
+ matchingCompletions = completions.filter(condition);
+ }
+ if (!matchingCompletions.length) {
+ return;
+ }
+
+ // If the cursor is in the middle of a word, and the word is a nick,
+ // there is no need to complete - just jump to the end of the nick.
+ let wholeWord = text.substring(
+ this.inputBox.selectionStart - word.length
+ );
+ for (let completion of matchingCompletions) {
+ if (wholeWord.lastIndexOf(completion, 0) == 0) {
+ let moveCursor = completion.length - word.length;
+ this.inputBox.selectionStart += moveCursor;
+ let separator = text.substring(
+ this.inputBox.selectionStart,
+ this.inputBox.selectionStart + 2
+ );
+ if (separator == ": " || separator == ", ") {
+ this.inputBox.selectionStart += 2;
+ } else if (!moveCursor) {
+ // If we're already at the end of a nick, carry on to display
+ // a list of possible alternatives and/or apply punctuation.
+ break;
+ }
+ return;
+ }
+ }
+
+ // We have possible completions!
+ this._completions = matchingCompletions.sort();
+ this._completionsIndex = 0;
+ // Save now the first and last completions in alphabetical order,
+ // as we will need them to find a common prefix. However they may
+ // not be the first and last completions in the list of completions
+ // actually exposed to the user, as if there are active nicks
+ // they will be moved to the beginning of the list.
+ let firstCompletion = this._completions[0];
+ let lastCompletion = this._completions.slice(-1)[0];
+
+ let preferredNick = false;
+ if (this._conv.isChat && !completingCommand) {
+ // If there are active nicks, prefer those.
+ let activeCompletions = this._completions.filter(
+ c =>
+ this.buddies.has(c) &&
+ !this.buddies.get(c).hasAttribute("inactive")
+ );
+ if (activeCompletions.length == 1) {
+ preferredNick = true;
+ }
+ if (activeCompletions.length) {
+ // Move active nicks to the front of the queue.
+ activeCompletions.reverse();
+ activeCompletions.forEach(function (c) {
+ this._completions.splice(this._completions.indexOf(c), 1);
+ this._completions.unshift(c);
+ }, this);
+ }
+
+ // If one of the completions is the sender of the last ping,
+ // take it, if it was less than an hour ago.
+ if (
+ this._lastPing &&
+ this.buddies.has(this._lastPing) &&
+ this._completions.includes(this._lastPing) &&
+ Date.now() / 1000 - this._lastPingTime < 3600
+ ) {
+ preferredNick = true;
+ this._completionsIndex = this._completions.indexOf(this._lastPing);
+ }
+ }
+
+ // Display the possible completions in a system message.
+ delete this._shouldListCompletionsLater;
+ if (this._completions.length > 1) {
+ let completionsList = this._completions.join(" ");
+ if (preferredNick) {
+ // If we have a preferred nick (which is completed as a whole
+ // even if there are alternatives), only show the list of
+ // completions on the next <tab> press.
+ this._shouldListCompletionsLater = completionsList;
+ } else {
+ this._conv.systemMessage(completionsList);
+ }
+ }
+
+ let suffix = isFirstWord ? firstWordSuffix : "";
+ this._completions = this._completions.map(c => c + suffix);
+
+ let completion;
+ if (this._completions.length == 1 || preferredNick) {
+ // Only one possible completion? Apply it! :-)
+ completion = this._completions[this._completionsIndex++];
+ this._completionsIndex %= this._completions.length;
+ } else {
+ // We have several possible completions, attempt to find a common prefix.
+ let maxLength = Math.min(
+ firstCompletion.length,
+ lastCompletion.length
+ );
+ let i = 0;
+ while (i < maxLength && firstCompletion[i] == lastCompletion[i]) {
+ ++i;
+ }
+
+ if (i) {
+ completion = firstCompletion.substring(0, i);
+ } else {
+ // Include this case so that secondNick is applied anyway,
+ // in case a completion is added by another tab press.
+ completion = word;
+ }
+ }
+
+ // Always replace what the user typed as its upper/lowercase may
+ // not be correct.
+ this.inputBox.selectionStart -= word.length;
+ this._completionsStart = this.inputBox.selectionStart;
+
+ if (secondNick) {
+ // Replace the trailing colon with a comma before the completed nick.
+ this.inputBox.selectionStart -= 2;
+ completion = ", " + completion;
+ }
+
+ this.addString(completion);
+ } else if (this._completions) {
+ delete this._completions;
+ }
+
+ if (event.keyCode != 13) {
+ return;
+ }
+
+ if (!event.ctrlKey && !event.shiftKey && !event.altKey) {
+ // Prevent the default action before calling sendMsg to avoid having
+ // a line break inserted in the textbox if sendMsg throws.
+ event.preventDefault();
+ this.sendMsg(text);
+ } else if (!event.shiftKey) {
+ this.addString("\n");
+ }
+ }
+
+ inputValueChanged() {
+ // Delaying typing notifications will avoid sending several updates in
+ // a row if the user is on a slow or overloaded machine that has
+ // trouble to handle keystrokes in a timely fashion.
+ // Make sure only one typing notification call can be pending.
+ if (this._pendingValueChangedCall) {
+ return;
+ }
+
+ this._pendingValueChangedCall = true;
+ Services.tm.mainThread.dispatch(
+ this.delayedInputValueChanged.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ }
+
+ delayedInputValueChanged() {
+ this._pendingValueChangedCall = false;
+
+ // By the time this function is executed, the conversation may have
+ // been closed.
+ if (!this._conv) {
+ return;
+ }
+
+ let text = this.inputBox.value;
+
+ // Try to avoid sending typing notifications when the user is
+ // typing a command in the conversation.
+ // These checks are not perfect (especially if non-existing
+ // commands are sent as regular messages on the in-use prpl).
+ let left = Ci.prplIConversation.NO_TYPING_LIMIT;
+ if (!text.startsWith("/")) {
+ left = this._conv.sendTyping(text);
+ } else if (/^\/me /.test(text)) {
+ left = this._conv.sendTyping(text.slice(4));
+ }
+
+ // When the input box is cleared or there is no character limit,
+ // don't show the character limit.
+ if (left == Ci.prplIConversation.NO_TYPING_LIMIT || !text.length) {
+ this.charCounter.setAttribute("value", "");
+ this.inputBox.removeAttribute("invalidInput");
+ } else {
+ // 200 is a 'magic' constant to avoid showing big numbers.
+ this.charCounter.setAttribute("value", left < 200 ? left : "");
+
+ if (left >= 0) {
+ this.inputBox.removeAttribute("invalidInput");
+ } else if (left < 0) {
+ this.inputBox.setAttribute("invalidInput", "true");
+ }
+ }
+ }
+
+ resetInput() {
+ this.inputBox.value = "";
+ this.charCounter.setAttribute("value", "");
+ this.inputBox.removeAttribute("invalidInput");
+
+ this._statusText = "";
+ this.displayStatusText();
+
+ if (TextboxSize.autoResize) {
+ let currHeight = Math.round(
+ this.inputBox.parentNode.getBoundingClientRect().height
+ );
+ if (
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD >
+ currHeight
+ ) {
+ this.inputBox.defaultHeight =
+ currHeight - this._TEXTBOX_VERTICAL_OVERHEAD;
+ }
+ this.convBottom.style.height =
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ this.inputBox.style.overflowY = "hidden";
+ }
+ }
+
+ inputExpand(event) {
+ // This feature has been disabled, or the user is currently dragging
+ // the splitter and the textbox has received an overflow event
+ if (
+ !TextboxSize.autoResize ||
+ this.splitter.getAttribute("state") == "dragging"
+ ) {
+ this.inputBox.style.overflowY = "";
+ return;
+ }
+
+ // Check whether we can increase the height without hiding the status bar
+ // (ensure the min-height property on the top part of this dialog)
+ let topBoxStyle = window.getComputedStyle(this.convTop);
+ let topMinSize = parseInt(topBoxStyle.getPropertyValue("min-height"));
+ let topSize = parseInt(topBoxStyle.getPropertyValue("height"));
+ let deck = this.inputBox.parentNode;
+ let oldDeckHeight = Math.round(deck.getBoundingClientRect().height);
+ let newDeckHeight =
+ parseInt(this.inputBox.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD;
+
+ if (!topMinSize || topSize - topMinSize > newDeckHeight - oldDeckHeight) {
+ // Hide a possible vertical scrollbar.
+ this.inputBox.style.overflowY = "hidden";
+ deck.style.height = newDeckHeight + "px";
+ } else {
+ this.inputBox.style.overflowY = "";
+ // Set it to the maximum possible value.
+ deck.style.height = oldDeckHeight + (topSize - topMinSize) + "px";
+ }
+ }
+
+ onConvResize() {
+ if (!this.splitter.hasAttribute("state")) {
+ this.calculateTextboxDefaultHeight();
+ this.inputBox.parentNode.style.height =
+ this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px";
+ } else {
+ // Used in case the browser is already on its min-height, resize the
+ // textbox to avoid hiding the status bar.
+ let convTopStyle = window.getComputedStyle(this.convTop);
+ let convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
+ let convTopMinHeight = parseInt(
+ convTopStyle.getPropertyValue("min-height")
+ );
+
+ if (convTopHeight == convTopMinHeight) {
+ this.inputBox.parentNode.style.height =
+ this.inputBox.parentNode.style.minHeight;
+ convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
+ this.inputBox.parentNode.style.height =
+ parseInt(this.inputBox.parentNode.style.minHeight) +
+ (convTopHeight - convTopMinHeight) +
+ "px";
+ }
+ }
+ if (TextboxSize.autoResize) {
+ this.inputExpand();
+ }
+ }
+
+ _onTextboxUnderflow(event) {
+ if (TextboxSize.autoResize) {
+ this.style.overflowY = "hidden";
+ }
+ }
+
+ browserKeyPress(event) {
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;
+
+ // 118 is the decimal code for "v" character, 13 keyCode for "return" key
+ if (
+ ((accelKeyPressed && event.charCode != 118) || event.altKey) &&
+ event.keyCode != 13
+ ) {
+ return;
+ }
+
+ if (
+ event.charCode == 0 && // it's not a character, it's a command key
+ event.keyCode != 13 && // Return
+ event.keyCode != 8 && // Backspace
+ event.keyCode != 46
+ ) {
+ // Delete
+ return;
+ }
+
+ if (
+ accelKeyPressed ||
+ !Services.prefs.getBoolPref("accessibility.typeaheadfind")
+ ) {
+ this.inputBox.focus();
+
+ // A common use case is to click somewhere in the conversation and
+ // start typing a command (often /me). If quick find is enabled, it
+ // will pick up the "/" keypress and open the findbar.
+ if (event.charCode == "/".charCodeAt(0)) {
+ event.preventDefault();
+ }
+ }
+
+ // Returns for Ctrl+V
+ if (accelKeyPressed) {
+ return;
+ }
+
+ // resend the event
+ let clonedEvent = new KeyboardEvent("keypress", event);
+ this.inputBox.dispatchEvent(clonedEvent);
+ }
+
+ browserDblClick(event) {
+ if (
+ !Services.prefs.getBoolPref(
+ "messenger.conversations.doubleClickToReply"
+ )
+ ) {
+ return;
+ }
+
+ for (let node = event.target; node; node = node.parentNode) {
+ if (node._originalMsg) {
+ let msg = node._originalMsg;
+ if (
+ msg.system ||
+ msg.outgoing ||
+ !msg.incoming ||
+ msg.error ||
+ !this._conv.isChat
+ ) {
+ return;
+ }
+ this.addPrompt(msg.who + ": ");
+ return;
+ }
+ }
+ }
+
+ /**
+ * Replace the current selection in the inputBox by the given string
+ *
+ * @param {string} aString
+ */
+ addString(aString) {
+ let cursorPosition = this.inputBox.selectionStart + aString.length;
+
+ this.inputBox.value =
+ this.inputBox.value.substr(0, this.inputBox.selectionStart) +
+ aString +
+ this.inputBox.value.substr(this.inputBox.selectionEnd);
+ this.inputBox.selectionStart = this.inputBox.selectionEnd =
+ cursorPosition;
+ this.inputValueChanged();
+ }
+
+ addPrompt(aPrompt) {
+ let currentEditorValue = this.inputBox.value;
+ if (!currentEditorValue.startsWith(aPrompt)) {
+ this.inputBox.value = aPrompt + currentEditorValue;
+ }
+
+ this.inputBox.focus();
+ this.inputValueChanged();
+ }
+
+ /**
+ * Update the participant count of a chat conversation
+ */
+ updateParticipantCount() {
+ document.getElementById("participantCount").value = this.buddies.size;
+ }
+
+ /**
+ * Set the attributes (flags) of a chat buddy
+ *
+ * @param {object} aItem
+ */
+ setBuddyAttributes(aItem) {
+ let buddy = aItem.chatBuddy;
+ let src;
+ let l10nId;
+ if (buddy.founder) {
+ src = "chrome://messenger/skin/icons/founder.png";
+ l10nId = "chat-participant-owner-role-icon2";
+ } else if (buddy.admin) {
+ src = "chrome://messenger/skin/icons/operator.png";
+ l10nId = "chat-participant-administrator-role-icon2";
+ } else if (buddy.moderator) {
+ src = "chrome://messenger/skin/icons/half-operator.png";
+ l10nId = "chat-participant-moderator-role-icon2";
+ } else if (buddy.voiced) {
+ src = "chrome://messenger/skin/icons/voice.png";
+ l10nId = "chat-participant-voiced-role-icon2";
+ }
+ let imageEl = aItem.querySelector(".conv-nicklist-image");
+ if (src) {
+ imageEl.setAttribute("src", src);
+ document.l10n.setAttributes(imageEl, l10nId);
+ } else {
+ imageEl.removeAttribute("src");
+ imageEl.removeAttribute("data-l10n-id");
+ imageEl.removeAttribute("alt");
+ }
+ }
+
+ /**
+ * Compute color for a nick
+ *
+ * @param {string} aName
+ */
+ _computeColor(aName) {
+ // Compute the color based on the nick
+ let nick = aName.match(/[a-zA-Z0-9]+/);
+ nick = nick ? nick[0].toLowerCase() : (nick = aName);
+ // We compute a hue value (between 0 and 359) based on the
+ // characters of the nick.
+ // The first character weights kInitialWeight, each following
+ // character weights kWeightReductionPerChar * the weight of the
+ // previous character.
+ const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters.
+ const kWeightReductionPerChar = 0.52; // arbitrary value
+ let weight = kInitialWeight;
+ let res = 0;
+ for (let i = 0; i < nick.length; ++i) {
+ let char = nick.charCodeAt(i) - 47;
+ if (char > 10) {
+ char -= 39;
+ }
+ // now char contains a value between 1 and 36
+ res += char * weight;
+ weight *= kWeightReductionPerChar;
+ }
+ return Math.round(res) % 360;
+ }
+
+ _isBuddyActive(aBuddyName) {
+ return Object.prototype.hasOwnProperty.call(
+ this._activeBuddies,
+ aBuddyName
+ );
+ }
+
+ /**
+ * Create a buddy item to add in the visible list of participants
+ *
+ * @param {object} aBuddy
+ */
+ createBuddy(aBuddy) {
+ let name = aBuddy.name;
+ if (!name) {
+ throw new Error("The empty string isn't a valid nick.");
+ }
+ if (this.buddies.has(name)) {
+ throw new Error("Adding chat buddy " + name + " twice?!");
+ }
+
+ this.trackNick(name);
+
+ let image = document.createElement("img");
+ image.classList.add("conv-nicklist-image");
+ let label = document.createXULElement("label");
+ label.classList.add("conv-nicklist-label");
+ label.setAttribute("value", name);
+ label.setAttribute("flex", "1");
+ label.setAttribute("crop", "end");
+
+ // Fix insertBuddy below if you change the DOM makeup!
+ let item = document.createXULElement("richlistitem");
+ item.chatBuddy = aBuddy;
+ item.appendChild(image);
+ item.appendChild(label);
+ this.setBuddyAttributes(item);
+
+ let color = this._computeColor(name);
+ let style = "color: hsl(" + color + ", 100%, 40%);";
+ item.colorStyle = style;
+ item.setAttribute("style", style);
+ item.setAttribute("align", "center");
+ if (!this._isBuddyActive(name)) {
+ item.setAttribute("inactive", "true");
+ }
+ item.color = color;
+ this.buddies.set(name, item);
+
+ return item;
+ }
+
+ /**
+ * Insert item at the right position
+ *
+ * @param {Node} aListItem
+ */
+ insertBuddy(aListItem) {
+ let nicklist = document.getElementById("nicklist");
+ let nick = aListItem.querySelector("label").value.toLowerCase();
+
+ // Look for the place of the nick in the list
+ let start = 0;
+ let end = nicklist.itemCount;
+ while (start < end) {
+ let middle = start + Math.floor((end - start) / 2);
+ if (
+ nick <
+ nicklist
+ .getItemAtIndex(middle)
+ .firstElementChild.nextElementSibling.getAttribute("value")
+ .toLowerCase()
+ ) {
+ end = middle;
+ } else {
+ start = middle + 1;
+ }
+ }
+
+ // Now insert the element
+ if (end == nicklist.itemCount) {
+ nicklist.appendChild(aListItem);
+ } else {
+ nicklist.insertBefore(aListItem, nicklist.getItemAtIndex(end));
+ }
+ }
+
+ /**
+ * Update a buddy in the visible list of participants
+ *
+ * @param {object} aBuddy
+ * @param {string} aOldName
+ */
+ updateBuddy(aBuddy, aOldName) {
+ let name = aBuddy.name;
+ if (!name) {
+ throw new Error("The empty string isn't a valid nick.");
+ }
+
+ if (!aOldName) {
+ if (!this._isConversationSelected) {
+ return;
+ }
+ // If aOldName is null, we are changing the flags of the buddy
+ let item = this.buddies.get(name);
+ item.chatBuddy = aBuddy;
+ this.setBuddyAttributes(item);
+ return;
+ }
+
+ if (this._isBuddyActive(aOldName)) {
+ delete this._activeBuddies[aOldName];
+ this._activeBuddies[aBuddy.name] = true;
+ }
+
+ this.trackNick(name);
+
+ if (!this._isConversationSelected) {
+ return;
+ }
+
+ // Is aOldName is not null, then we are renaming the buddy
+ if (!this.buddies.has(aOldName)) {
+ throw new Error(
+ "Updating a chat buddy that does not exist: " + aOldName
+ );
+ }
+
+ if (this.buddies.has(name)) {
+ throw new Error(
+ "Updating a chat buddy to an already existing one: " + name
+ );
+ }
+
+ let item = this.buddies.get(aOldName);
+ item.chatBuddy = aBuddy;
+ this.buddies.delete(aOldName);
+ this.buddies.set(name, item);
+ item.querySelector("label").value = name;
+
+ // Move this item to the right position if its name changed
+ item.remove();
+ this.insertBuddy(item);
+ }
+
+ removeBuddy(aName) {
+ if (!this.buddies.has(aName)) {
+ throw new Error("Cannot remove a buddy that was not in the room");
+ }
+ this.buddies.get(aName).remove();
+ this.buddies.delete(aName);
+ if (this._isBuddyActive(aName)) {
+ delete this._activeBuddies[aName];
+ }
+ }
+
+ trackNick(aNick) {
+ if ("_showNickList" in this) {
+ this._showNickList[aNick.replace(this._nickEscape, "\\$&")] = true;
+ delete this._showNickRegExp;
+ }
+ }
+
+ getShowNickModifier() {
+ return function (aNode) {
+ if (!("_showNickRegExp" in this)) {
+ if (!("_showNickList" in this)) {
+ this._showNickList = {};
+ for (let n of this.buddies.keys()) {
+ this._showNickList[n.replace(this._nickEscape, "\\$&")] = true;
+ }
+ }
+
+ // The reverse sort ensures that if we have "foo" and "foobar",
+ // "foobar" will be matched first by the regexp.
+ let nicks = Object.keys(this._showNickList)
+ .sort()
+ .reverse()
+ .join("|");
+ if (nicks) {
+ // We use \W to match for word-boundaries, as \b will not match the
+ // nick if it starts/ends with \W characters.
+ // XXX Ideally we would use unicode word boundaries:
+ // http://www.unicode.org/reports/tr29/#Word_Boundaries
+ this._showNickRegExp = new RegExp("\\W(?:" + nicks + ")\\W");
+ } else {
+ // nobody, disable...
+ this._showNickRegExp = { exec: () => null };
+ return 0;
+ }
+ }
+ let exp = this._showNickRegExp;
+ let result = 0;
+ let match;
+ // Add leading/trailing spaces to match at beginning and end of
+ // the string as well. (If we used regex ^ and $, match.index would
+ // not be reliable.)
+ while ((match = exp.exec(" " + aNode.data + " "))) {
+ // \W is not zero-length, but this is cancelled by the
+ // extra leading space here.
+ let nickNode = aNode.splitText(match.index);
+ // subtract the 2 \W's to get the length of the nick.
+ aNode = nickNode.splitText(match[0].length - 2);
+ // at this point, nickNode is a text node with only the text
+ // of the nick and aNode is a text node with the text after
+ // the nick. The text in aNode hasn't been processed yet.
+ let nick = nickNode.data;
+ let elt = aNode.ownerDocument.createElement("span");
+ elt.setAttribute("class", "ib-nick");
+ if (this.buddies.has(nick)) {
+ let buddy = this.buddies.get(nick);
+ elt.setAttribute("style", buddy.colorStyle);
+ elt.setAttribute("data-nickColor", buddy.color);
+ } else {
+ elt.setAttribute("data-left", "true");
+ }
+ nickNode.parentNode.replaceChild(elt, nickNode);
+ elt.textContent = nick;
+ result += 2;
+ }
+ return result;
+ }.bind(this);
+ }
+
+ /**
+ * Display the topic and topic editable flag for the current MUC in the
+ * conversation header.
+ */
+ updateTopic() {
+ let cti = document.getElementById("conv-top-info");
+ let editable = !!this._conv.topicSettable;
+
+ let topicText = this._conv.topic;
+ let noTopic = !topicText;
+ cti.setAsChat(topicText || this._conv.noTopicString, noTopic, editable);
+ }
+
+ focus() {
+ this.inputBox.focus();
+
+ if (!this.loaded) {
+ return;
+ }
+
+ if (this.tab) {
+ this.tab.removeAttribute("unread");
+ this.tab.removeAttribute("attention");
+ }
+ this._conv.markAsRead();
+ }
+
+ switchingToPanel() {
+ if (this._visibleTimer) {
+ return;
+ }
+
+ // Start a timer to detect if the tab has been visible to the
+ // user for long enough to actually be seen (as opposed to the
+ // tab only being visible "accidentally in passing").
+ delete this._wasVisible;
+ this._visibleTimer = setTimeout(() => {
+ this._wasVisible = true;
+ delete this._visibleTimer;
+
+ // Porting note: For TB, we also need to update the conv title
+ // and reset the unread flag. In IB, this is done by tabbrowser.
+ this.tab.update();
+ }, 1000);
+ this.convBrowser.isActive = true;
+ }
+
+ switchingAwayFromPanel(aHidden) {
+ if (this._visibleTimer) {
+ clearTimeout(this._visibleTimer);
+ delete this._visibleTimer;
+ }
+ // Remove the unread ruler if the tab has been visible without
+ // interruptions for sufficiently long.
+ if (this._wasVisible) {
+ this.convBrowser.removeUnreadRuler();
+ }
+
+ if (aHidden) {
+ this.convBrowser.isActive = false;
+ }
+ }
+
+ updateConvStatus() {
+ let cti = document.getElementById("conv-top-info");
+ cti.setProtocol(this._conv.account.protocol);
+
+ // Set the icon, potentially showing a fallback icon if this is an IM.
+ cti.setUserIcon(this._conv.convIconFilename, !this._conv.isChat);
+
+ if (this._conv.isChat) {
+ this.updateTopic();
+ cti.setAttribute("displayName", this._conv.title);
+ } else {
+ let displayName = this._conv.title;
+ let statusText = "";
+ let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+
+ let buddy = this._conv.buddy;
+ if (buddy?.account.connected) {
+ displayName = buddy.displayName;
+ statusText = buddy.statusText;
+ statusType = buddy.statusType;
+ }
+ cti.setAttribute("displayName", displayName);
+
+ let statusName;
+
+ let typingState = this._conv.typingState;
+ let typingName = this._currentTypingName || this._conv.title;
+
+ switch (typingState) {
+ case Ci.prplIConvIM.TYPING:
+ statusName = "active-typing";
+ statusText = this.bundle.formatStringFromName(
+ "chat.contactIsTyping",
+ [typingName],
+ 1
+ );
+ break;
+ case Ci.prplIConvIM.TYPED:
+ statusName = "paused-typing";
+ statusText = this.bundle.formatStringFromName(
+ "chat.contactHasStoppedTyping",
+ [typingName],
+ 1
+ );
+ break;
+ default:
+ statusName = Status.toAttribute(statusType);
+ statusText = Status.toLabel(statusType, statusText);
+ break;
+ }
+ cti.setStatus(statusName, statusText);
+ }
+ }
+
+ showParticipants() {
+ if (this._conv.isChat) {
+ let nicklist = document.getElementById("nicklist");
+ while (nicklist.hasChildNodes()) {
+ nicklist.lastChild.remove();
+ }
+ // Populate the nicklist
+ this.buddies = new Map();
+ for (let n of this.conv.getParticipants()) {
+ this.createBuddy(n);
+ }
+ nicklist.append(
+ ...Array.from(this.buddies.keys())
+ .sort((a, b) => a.localeCompare(b))
+ .map(nick => this.buddies.get(nick))
+ );
+ this.updateParticipantCount();
+ }
+ }
+
+ /**
+ * Set up the shared conversation specific components (conversation browser
+ * references, status header, participants list, text input) for this
+ * conversation.
+ */
+ initConversationUI() {
+ this._activeBuddies = {};
+ if (this._conv.isChat) {
+ let cti = document.getElementById("conv-top-info");
+ cti.setAttribute("displayName", this._conv.title);
+
+ this.showParticipants();
+
+ if (Services.prefs.getBoolPref("messenger.conversations.showNicks")) {
+ this.convBrowser.addTextModifier(this.getShowNickModifier());
+ }
+ }
+
+ if (this.tab) {
+ this.tab.setAttribute("label", this._conv.title);
+ }
+
+ this.findbar.browser = this.convBrowser;
+
+ this.updateConvStatus();
+ this.initTextboxFormat();
+ }
+
+ /**
+ * Change the UI Conversation attached to this component and its browser.
+ * Does not clear any existing messages in the conversation browser.
+ *
+ * @param {imIConversation} conv
+ */
+ changeConversation(conv) {
+ this._conv.removeObserver(this.observer);
+ this._conv = conv;
+ this._conv.addObserver(this.observer);
+ this.convBrowser._conv = conv;
+ this.initConversationUI();
+ }
+
+ get editor() {
+ return this.inputBox;
+ }
+
+ get _isConversationSelected() {
+ // TB-only: returns true if the chat conversation element is the currently
+ // selected one, i.e if it has to maintain the participant list.
+ // The JS property this.tab.selected is always false when the chat tab
+ // is inactive, so we need to double-check to be sure.
+ return this.tab.selected || this.tab.hasAttribute("selected");
+ }
+
+ get convId() {
+ return this._conv.id;
+ }
+
+ get conv() {
+ return this._conv;
+ }
+
+ set conv(val) {
+ if (this._conv && val) {
+ throw new Error("chat-conversation already initialized");
+ }
+ if (!val) {
+ // this conversation has probably been moved to another
+ // tab. Forget the prplConversation so that it isn't
+ // closed when destroying this binding.
+ this._forgetConv();
+ return;
+ }
+ this._conv = val;
+ this._conv.addObserver(this.observer);
+ this.convBrowser.init(this._conv);
+ this.initConversationUI();
+ }
+
+ get contentWindow() {
+ return this.convBrowser.contentWindow;
+ }
+
+ get bundle() {
+ if (!this._bundle) {
+ this._bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ }
+ return this._bundle;
+ }
+ }
+
+ customElements.define("chat-conversation", MozChatConversation);
+}
diff --git a/comm/mail/components/im/content/chat-group.js b/comm/mail/components/im/content/chat-group.js
new file mode 100644
index 0000000000..80bf25159c
--- /dev/null
+++ b/comm/mail/components/im/content/chat-group.js
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozXULElement, MozElements */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozChatGroupRichlistitem widget displays chat group name and behave as a
+ * expansion twisty for groups such as "Conversations",
+ * "Online Contacts" and "Offline Contacts".
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatGroupRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ label: "value=name",
+ };
+ }
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-group-richlistitem");
+ this.setAttribute("collapsed", "true");
+
+ /* Here we use a div, rather than the usual img because the icon image
+ * relies on CSS -moz-locale-dir(rtl). The corresponding icon
+ * twisty-collapsed-rtl icon is not a simple mirror transformation of
+ * twisty-collapsed.
+ * Currently, CSS sets the background-image based on the "closed" state.
+ * The element is a visual decoration and does not require any alt text
+ * since the aria-expanded attribute describes its state.
+ */
+ this._image = document.createElement("div");
+ this._image.classList.add("twisty");
+
+ this._label = document.createXULElement("label");
+ this._label.setAttribute("flex", "1");
+ this._label.setAttribute("crop", "end");
+
+ this.appendChild(this._image);
+ this.appendChild(this._label);
+
+ this.contacts = [];
+
+ this.contactsById = {};
+
+ this.displayName = "";
+
+ this.addEventListener("click", event => {
+ // Check if there was 1 click on the image or 2 clicks on the label
+ if (
+ (event.detail == 1 && event.target.classList.contains("twisty")) ||
+ (event.detail == 2 && event.target.localName == "label")
+ ) {
+ this.toggleClosed();
+ } else if (event.target.localName == "button") {
+ this.hide();
+ }
+ });
+
+ this.addEventListener("contextmenu", event => {
+ event.preventDefault();
+ });
+
+ if (this.classList.contains("closed")) {
+ this.setAttribute("aria-expanded", "true");
+ } else {
+ this.setAttribute("aria-expanded", "false");
+ }
+
+ this.initializeAttributeInheritance();
+ }
+
+ /**
+ * Takes as input two contact elements (imIContact type) and compares
+ * their nicknames alphabetically (case insensitive). This method
+ * behaves as a callback that Array.prototype.sort accepts as a
+ * parameter.
+ */
+ sortComparator(contactA, contactB) {
+ if (contactA.statusType != contactB.statusType) {
+ return contactB.statusType - contactA.statusType;
+ }
+ let a = contactA.displayName.toLowerCase();
+ let b = contactB.displayName.toLowerCase();
+ return a.localeCompare(b);
+ }
+
+ addContact(contact, tagName) {
+ if (this.contactsById.hasOwnProperty(contact.id)) {
+ return null;
+ }
+
+ let contactElt;
+ if (tagName) {
+ contactElt = document.createXULElement("richlistitem", {
+ is: "chat-imconv-richlistitem",
+ });
+ } else {
+ contactElt = document.createXULElement("richlistitem", {
+ is: "chat-contact-richlistitem",
+ });
+ }
+ if (this.classList.contains("closed")) {
+ contactElt.setAttribute("collapsed", "true");
+ }
+
+ let end = this.contacts.length;
+ // Avoid the binary search loop if the contacts were already sorted.
+ if (
+ end != 0 &&
+ this.sortComparator(contact, this.contacts[end - 1].contact) < 0
+ ) {
+ let start = 0;
+ while (start < end) {
+ let middle = start + Math.floor((end - start) / 2);
+ if (this.sortComparator(contact, this.contacts[middle].contact) < 0) {
+ end = middle;
+ } else {
+ start = middle + 1;
+ }
+ }
+ }
+ let last = end == 0 ? this : this.contacts[end - 1];
+ this.parentNode.insertBefore(contactElt, last.nextElementSibling);
+ contactElt.build(contact);
+ contactElt.group = this;
+ this.contacts.splice(end, 0, contactElt);
+ this.contactsById[contact.id] = contactElt;
+ this.removeAttribute("collapsed");
+ this._updateGroupLabel();
+ return contactElt;
+ }
+
+ updateContactPosition(subject, tagName) {
+ let contactElt = this.contactsById[subject.id];
+ let index = this.contacts.indexOf(contactElt);
+ if (index == -1) {
+ // Sometimes we get a display-name-changed notification for
+ // an offline contact, if it's not in the list, just ignore it.
+ return;
+ }
+ // See if the position of the contact should be changed.
+ if (
+ (index != 0 &&
+ this.sortComparator(
+ contactElt.contact,
+ this.contacts[index - 1].contact
+ ) < 0) ||
+ (index != this.contacts.length - 1 &&
+ this.sortComparator(
+ contactElt.contact,
+ this.contacts[index + 1].contact
+ ) > 0)
+ ) {
+ let list = this.parentNode;
+ let selectedItem = list.selectedItem;
+ let oldItem = this.removeContact(subject);
+ let newItem = this.addContact(subject, tagName);
+ if (selectedItem == oldItem) {
+ list.selectedItem = newItem;
+ }
+ }
+ }
+
+ removeContact(contactForID) {
+ let contact = this.contactsById[contactForID.id];
+ if (!contact) {
+ throw new Error("Can't remove contact for id=" + contactForID.id);
+ }
+
+ // create a new array to remove without breaking for each loops.
+ this.contacts = this.contacts.filter(c => c !== contact);
+ delete this.contactsById[contact.contact.id];
+
+ contact.destroy();
+
+ // Check if some contacts remain in the group, if empty hide it.
+ if (!this.contacts.length) {
+ this.setAttribute("collapsed", "true");
+ } else {
+ this._updateGroupLabel();
+ }
+
+ return contact;
+ }
+
+ _updateClosedState(closed) {
+ for (let contact of this.contacts) {
+ contact.collapsed = closed;
+ }
+ }
+
+ toggleClosed() {
+ if (this.classList.contains("closed")) {
+ this.classList.remove("closed");
+ this.setAttribute("aria-expanded", "true");
+ this._updateClosedState(false);
+ } else {
+ this.classList.add("closed");
+ this.setAttribute("aria-expanded", "false");
+ this._updateClosedState(true);
+ }
+
+ this._updateGroupLabel();
+ }
+
+ _updateGroupLabel() {
+ if (!this.displayName) {
+ this.displayName = this.getAttribute("name");
+ }
+ let name = this.displayName;
+ if (this.classList.contains("closed")) {
+ name += " (" + this.contacts.length + ")";
+ }
+
+ this.setAttribute("name", name);
+ }
+
+ keyPress(event) {
+ switch (event.keyCode) {
+ case event.DOM_VK_RETURN:
+ this.toggleClosed();
+ break;
+
+ case event.DOM_VK_LEFT:
+ if (!this.classList.contains("closed")) {
+ this.toggleClosed();
+ }
+ break;
+
+ case event.DOM_VK_RIGHT:
+ if (this.classList.contains("closed")) {
+ this.toggleClosed();
+ }
+ break;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatGroupRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define("chat-group-richlistitem", MozChatGroupRichlistitem, {
+ extends: "richlistitem",
+ });
+}
diff --git a/comm/mail/components/im/content/chat-imconv.js b/comm/mail/components/im/content/chat-imconv.js
new file mode 100644
index 0000000000..759a3ce78a
--- /dev/null
+++ b/comm/mail/components/im/content/chat-imconv.js
@@ -0,0 +1,366 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozElements, MozXULElement, gChatTab, chatHandler */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ /**
+ * The MozChatConvRichlistitem widget displays opened conversation information from the
+ * contacts: i.e name and icon. It gets displayed under conversation expansion
+ * twisty in the contactlist richlistbox.
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatConvRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ ".box-line": "selected",
+ ".convDisplayName": "value=displayname,status",
+ ".convUnreadTargetedCount": "value=unreadTargetedCount",
+ ".convUnreadCount": "value=unreadCount",
+ ".convUnreadTargetedCountLabel": "value=unreadTargetedCount",
+ };
+ }
+
+ static get markup() {
+ return `
+ <vbox class="box-line"></vbox>
+ <button class="closeConversationButton close-icon"
+ tooltiptext="&closeConversationButton.tooltip;"></button>
+ <stack class="prplBuddyIcon">
+ <html:img class="protoIcon" alt="" />
+ <html:img class="smallStatusIcon" />
+ </stack>
+ <hbox flex="1" class="conv-hbox">
+ <label crop="end" class="convDisplayName blistDisplayName">
+ </label>
+ <label class="convUnreadCount" crop="end"></label>
+ <box class="convUnreadTargetedCount">
+ <label class="convUnreadTargetedCountLabel" crop="end"></label>
+ </box>
+ <spacer style="flex: 1000000 1000000;"></spacer>
+ </hbox>
+ `;
+ }
+
+ static get entities() {
+ return ["chrome://messenger/locale/chat.dtd"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-imconv-richlistitem");
+
+ this.addEventListener(
+ "mousedown",
+ event => {
+ if (event.target.classList.contains("closeConversationButton")) {
+ this.closeConversation();
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ },
+ true
+ );
+
+ this.appendChild(this.constructor.fragment);
+
+ this.convView = null;
+
+ this.directedUnreadCount = 0;
+
+ new MutationObserver(mutations => {
+ if (!this.convView || !this.convView.loaded) {
+ return;
+ }
+ if (this.hasAttribute("selected")) {
+ this.convView.switchingToPanel();
+ } else {
+ this.convView.switchingAwayFromPanel(true);
+ }
+ }).observe(this, { attributes: true, attributeFilter: ["selected"] });
+
+ // @implements {nsIObserver}
+ this.observer = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ observe: function (subject, topic, data) {
+ if (
+ topic == "target-prpl-conversation-changed" ||
+ topic == "unread-message-count-changed" ||
+ topic == "update-conv-title" ||
+ topic == "update-buddy-status" ||
+ topic == "update-buddy-status" ||
+ topic == "update-conv-chatleft" ||
+ topic == "update-conv-chatjoining" ||
+ topic == "chat-update-topic"
+ ) {
+ this.update();
+ }
+ if (topic == "update-conv-title") {
+ this.group.updateContactPosition(
+ this.conv,
+ "chat-imconv-richlistitem"
+ );
+ }
+ }.bind(this),
+ };
+
+ if (this.hasAttribute("is-search-result")) {
+ let icon = this.querySelector(".protoIcon");
+ icon.classList.add("searchProtoIcon");
+ icon.setAttribute("src", "chrome://global/skin/icons/search-glass.svg");
+ let statusIcon = this.querySelector(".smallStatusIcon");
+ statusIcon.hidden = true;
+ this.setAttribute("unreadCount", "0");
+ this.setAttribute("unreadTargetedCount", "0");
+ }
+
+ this.initializeAttributeInheritance();
+ }
+
+ get displayName() {
+ return this.conv.title;
+ }
+
+ /**
+ * This getter exists to provide compatibility with the imgroup sortComparator.
+ */
+ get contact() {
+ return this.conv;
+ }
+
+ set selected(val) {
+ if (val) {
+ this.setAttribute("selected", "true");
+ } else {
+ this.removeAttribute("selected");
+ }
+ }
+
+ get selected() {
+ return (
+ gChatTab &&
+ gChatTab.tabNode.selected &&
+ this.getAttribute("selected") == "true"
+ );
+ }
+
+ /**
+ * Set the conversation this item should represent. Updates appearance and
+ * adds observers to keep it up to date.
+ *
+ * @param {imIConversation} conv - Conversation this item represents.
+ */
+ build(conv) {
+ this.conv = conv;
+ this.conv.addObserver(this.observer);
+ this.update();
+ }
+
+ update() {
+ this.setAttribute("displayname", this.displayName);
+ if (this.selected && document.hasFocus()) {
+ if (this.convView && this.convView.loaded) {
+ this.conv.markAsRead();
+ this.directedUnreadCount = 0;
+ chatHandler.updateTitle();
+ chatHandler.updateChatButtonState();
+ }
+ this.setAttribute("unreadCount", "0");
+ this.setAttribute("unreadTargetedCount", "0");
+ this.removeAttribute("unread");
+ this.removeAttribute("attention");
+ } else {
+ let unreadCount =
+ this.conv.unreadIncomingMessageCount +
+ this.conv.unreadOTRNotificationCount;
+ let directedMessages = unreadCount;
+ if (unreadCount) {
+ this.setAttribute("unread", "true");
+ if (this.conv.isChat) {
+ directedMessages = this.conv.unreadTargetedMessageCount;
+ if (directedMessages) {
+ this.setAttribute("attention", "true");
+ }
+ }
+ unreadCount -= directedMessages;
+ if (directedMessages > this.directedUnreadCount) {
+ this.directedUnreadCount = directedMessages;
+ }
+ }
+ if (unreadCount) {
+ unreadCount = "(" + unreadCount + ")";
+ }
+ this.setAttribute("unreadCount", unreadCount);
+ if (
+ Services.prefs.getBoolPref(
+ "messenger.options.getAttentionOnNewMessages"
+ ) &&
+ directedMessages > parseInt(this.getAttribute("unreadTargetedCount"))
+ ) {
+ window.getAttention();
+ }
+ this.setAttribute("unreadTargetedCount", directedMessages);
+ chatHandler.updateTitle();
+ }
+
+ let statusIcon = this.querySelector(".smallStatusIcon");
+ let statusName;
+ statusIcon.hidden = false;
+ if (this.conv.isChat) {
+ if (this.conv.joining) {
+ statusName = "joining";
+ } else if (!this.conv.account.connected || this.conv.left) {
+ statusName = "left";
+ }
+ if (statusName) {
+ statusIcon.setAttribute(
+ "src",
+ ChatIcons.getStatusIconURI(statusName)
+ );
+ // Set alt using messenger/chat.ftl.
+ document.l10n.setAttributes(
+ statusIcon,
+ `chat-${statusName}-chat-icon2`
+ );
+ } else {
+ statusIcon.removeAttribute("src");
+ statusIcon.removeAttribute("data-l10n-id");
+ statusIcon.removeAttribute("alt");
+ statusIcon.hidden = true;
+ // Treat protoIcon as if connected.
+ statusName = "connected";
+ }
+ } else {
+ let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ let buddy = this.conv.buddy;
+ if (buddy && buddy.account.connected) {
+ statusType = buddy.statusType;
+ }
+ statusName = Status.toAttribute(statusType);
+ statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName));
+ statusIcon.removeAttribute("data-l10n-id");
+ statusIcon.setAttribute("alt", Status.toLabel(statusType));
+ }
+
+ if (!this.hasAttribute("is-search-result")) {
+ let protoIcon = this.querySelector(".protoIcon");
+ protoIcon.setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(this.conv.account.protocol)
+ );
+ ChatIcons.setProtocolIconOpacity(protoIcon, statusName);
+ }
+ }
+
+ destroy() {
+ if (this.conv) {
+ this.conv.removeObserver(this.observer);
+ }
+ if (this.convView) {
+ this.convView.destroy();
+ this.convView.remove();
+ }
+
+ // If the conversation we are destroying was selected, we should
+ // select something else, but the 'select' event handler of
+ // the listbox will choke while updating the Chat tab title if
+ // there are conversation nodes associated with a conversation
+ // that no longer exists from the chat core's point of view, so
+ // we do the actual selection change only after this conversation
+ // item is fully destroyed and removed from the list.
+ let newSelectedItem;
+ let list = this.parentNode;
+ if (list.selectedItem == this) {
+ newSelectedItem = this.previousElementSibling;
+ }
+
+ if (this.log) {
+ this.hidden = true;
+ delete this.log;
+ } else {
+ this.remove();
+ delete this.conv;
+ }
+ if (newSelectedItem) {
+ list.selectedItem = newSelectedItem;
+ }
+ }
+
+ closeConversation() {
+ if (this.conv) {
+ this.conv.close();
+ } else {
+ this.destroy();
+ }
+ }
+
+ keyPress(event) {
+ // If Enter or Return is pressed, focus the input box.
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.convView.focus();
+ return;
+ }
+
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;
+ // If a character was typed or the accel+v copy shortcut was used,
+ // focus the input box and resend the key event.
+ if (
+ event.charCode != 0 &&
+ !event.altKey &&
+ ((accelKeyPressed && event.charCode == "v".charCodeAt(0)) ||
+ (!event.ctrlKey && !event.metaKey))
+ ) {
+ this.convView.focus();
+
+ let clonedEvent = new KeyboardEvent("keypress", event);
+ this.convView.editor.dispatchEvent(clonedEvent);
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Replace the conversation that this item represents.
+ *
+ * @param {imIConversation} conv - Updated conversation this should
+ * represent.
+ */
+ changeConversation(conv) {
+ this.conv?.removeObserver(this.observer);
+ this.build(conv);
+ }
+
+ disconnectedCallback() {
+ if (this.conv) {
+ this.conv.removeObserver(this.observer);
+ delete this.conv;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatConvRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define("chat-imconv-richlistitem", MozChatConvRichlistitem, {
+ extends: "richlistitem",
+ });
+}
diff --git a/comm/mail/components/im/content/chat-menu.inc.xhtml b/comm/mail/components/im/content/chat-menu.inc.xhtml
new file mode 100644
index 0000000000..8ded5e0edb
--- /dev/null
+++ b/comm/mail/components/im/content/chat-menu.inc.xhtml
@@ -0,0 +1,109 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ <tooltip is="chat-tooltip" id="imTooltip"/>
+
+ <menupopup id="buddyListContextMenu"
+ onpopupshowing="if (event.target != this) { return true; } gBuddyListContextMenu = new buddyListContextMenu(this); return gBuddyListContextMenu.shouldDisplay;"
+ onpopuphiding="if (event.target == this) { gBuddyListContextMenu = null; }">
+ <menuitem id="context-openconversation"
+ label="&openConversationCmd.label;"
+ accesskey="&openConversationCmd.accesskey;"
+ oncommand="gBuddyListContextMenu.openConversation();"/>
+ <menuitem id="context-close-conversation"
+ label="&closeConversationCmd.label;"
+ accesskey="&closeConversationCmd.accesskey;"
+ oncommand="gBuddyListContextMenu.closeConversation();"/>
+ <menuitem id="context-verifyBuddy"
+ data-l10n-id="chat-verify-identity"
+ oncommand="gBuddyListContextMenu.verifyIdentity();"/>
+ <menuseparator id="context-edit-buddy-separator"/>
+ <menuitem id="context-alias"
+ label="&aliasCmd.label;"
+ accesskey="&aliasCmd.accesskey;"
+ oncommand="gBuddyListContextMenu.alias();"/>
+ <menuitem id="context-delete"
+ data-l10n-id="text-action-delete"
+ oncommand="gBuddyListContextMenu.delete();"/>
+ </menupopup>
+
+ <menupopup id="chatConversationContextMenu"
+ onpopupshowing="if (event.target != this) { return true; } gChatContextMenu = new imContextMenu(this); return gChatContextMenu.shouldDisplay;"
+ onpopuphiding="if (event.target == this &amp;&amp; gChatContextMenu) { gChatContextMenu.cleanup(); gChatContextMenu = null; }">
+ <menuitem id="context-openlink"
+ label="&openLinkCmd.label;"
+ accesskey="&openLinkCmd.accesskey;"
+ oncommand="gChatContextMenu.openLink();"/>
+ <menuitem id="context-copyemail"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"
+ oncommand="gChatContextMenu.copyEmail();"/>
+ <menuitem id="context-copylink"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ oncommand="goDoCommand('cmd_copyLink');"/>
+ <menuseparator id="context-sep-copylink"/>
+
+ <menuitem id="context-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="context-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuseparator id="context-sep-messageactions"/>
+ </menupopup>
+
+ <menupopup id="chat-toolbar-context-menu">
+ <menuitem id="CustomizeChatToolbar"
+ oncommand="CustomizeMailToolbar('chat-view-toolbox', 'CustomizeChatToolbar')"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ </menupopup>
+
+ <menupopup id="chatContextMenu"
+ onpopupshowing="if (event.target != this) { return true; } openChatContextMenu(this);"
+ onpopuphiding="if (event.target == this) { clearChatContextMenu(this); }">
+
+ <!-- Spellchecking menu items -->
+ <menuitem id="spellCheckNoSuggestions"
+ data-l10n-id="text-action-spell-no-suggestions"
+ disabled="true"/>
+ <menuseparator id="spellCheckAddSep" />
+ <menuitem id="spellCheckAddToDictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="gChatSpellChecker.addToDictionary();"/>
+ <menuseparator id="spellCheckSuggestionsSeparator"/>
+
+ <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/>
+ <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/>
+ <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/>
+ <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/>
+
+ <!-- Spellchecking general menu items (enable, add dictionaries...) -->
+ <menuseparator id="spellCheckSeparator"/>
+ <menuitem id="spellCheckEnable"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="enableInlineSpellCheck(!gChatSpellChecker.enabled);"/>
+ <menu id="spellCheckDictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="spellCheckDictionariesMenu">
+ <menuseparator id="spellCheckLanguageSeparator"/>
+ <menuitem id="spellCheckAddDictionaries"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="openDictionaryList();"/>
+ </menupopup>
+ </menu>
+
+ </menupopup>
+
+ <menupopup id="participantListContextMenu"
+ onpopupshowing="return showParticipantMenu(this);">
+ <menuitem id="context-verifyParticipant"
+ data-l10n-id="chat-verify-identity"
+ oncommand="verifyChatParticipant();"/>
+ </menupopup>
diff --git a/comm/mail/components/im/content/chat-messenger.inc.xhtml b/comm/mail/components/im/content/chat-messenger.inc.xhtml
new file mode 100644
index 0000000000..6b1fbb9f8f
--- /dev/null
+++ b/comm/mail/components/im/content/chat-messenger.inc.xhtml
@@ -0,0 +1,192 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ <vbox id="chatTabPanel">
+ <toolbox id="chat-view-toolbox" class="mail-toolbox"
+ mode="full" defaultmode="full"
+ labelalign="end" defaultlabelalign="end">
+ <toolbar is="customizable-toolbar" id="chat-toolbar"
+ class="inline-toolbar chromeclass-toolbar themeable-full"
+ fullscreentoolbar="true"
+ customizable="true"
+ context="chat-toolbar-context-menu"
+ mode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+#endif
+ defaultset="button-add-buddy,button-join-chat,spacer,chat-status-selector,button-chat-accounts,spacer,gloda-im-search"/>
+
+ <toolbarpalette id="ChatToolbarPalette">
+ <toolbarbutton id="button-add-buddy"
+ class="toolbarbutton-1"
+ label="&addBuddyButton.label;"
+ oncommand="chatHandler.addBuddy()"/>
+ <toolbarbutton id="button-join-chat"
+ class="toolbarbutton-1"
+ label="&joinChatButton.label;"
+ oncommand="chatHandler.joinChat()"/>
+ <toolbaritem id="chat-status-selector"
+ orient="horizontal"
+ align="center" flex="1">
+ <toolbarbutton id="statusTypeIcon"
+ type="menu"
+ wantdropmarker="true"
+ class="toolbarbutton-1"
+ status="available">
+ <menupopup id="setStatusTypeMenupopup"
+ oncommand="statusSelector.editStatus(event);">
+ <menuitem id="statusTypeAvailable" label="&status.available;"
+ status="available" class="menuitem-iconic"/>
+ <menuitem id="statusTypeUnavailable" label="&status.unavailable;"
+ status="unavailable" class="menuitem-iconic"/>
+ <menuseparator id="statusTypeOfflineSeparator"/>
+ <menuitem id="statusTypeOffline" label="&status.offline;"
+ status="offline" class="menuitem-iconic"/>
+ </menupopup>
+ </toolbarbutton>
+ <vbox flex="1"
+ orient="horizontal"
+ align="center"
+ class="input-container status-container">
+ <label id="statusMessageLabel"
+ flex="1"
+ value=""
+ class="statusMessageToolbarItem label-inline"
+ onclick="statusSelector.statusMessageClick();"/>
+ <html:input id="statusMessageInput"
+ value=""
+ class="statusMessageInput statusMessageToolbarItem status-message-input"
+ hidden="hidden"/>
+ </vbox>
+ </toolbaritem>
+ <toolbarbutton id="button-chat-accounts"
+ class="toolbarbutton-1"
+ label="&chatAccountsButton.label;"
+ oncommand="openIMAccountMgr()"/>
+ </toolbarpalette>
+ </toolbox>
+
+ <vbox flex="1">
+ <hbox id="chatPanel" flex="1">
+ <vbox id="listPaneBox" style="min-width:125px;" width="200" persist="width">
+ <richlistbox id="contactlistbox"
+ context="buddyListContextMenu"
+ tooltip="imTooltip" flex="1">
+ <richlistitem is="chat-group-richlistitem" id="conversationsGroup"
+ name="&conversationsHeader.label;"/>
+ <richlistitem is="chat-imconv-richlistitem"
+ id="searchResultConv"
+ displayname="&searchResultConversation.label;"
+ is-search-result=""
+ hidden="true"/>
+ <richlistitem is="chat-group-richlistitem" id="onlinecontactsGroup"
+ name="&onlineContactsHeader.label;"/>
+ <richlistitem is="chat-group-richlistitem" id="offlinecontactsGroup"
+ name="&offlineContactsHeader.label;"
+ class="closed"/>
+ </richlistbox>
+ </vbox>
+ <splitter id="listSplitter" collapse="before"/>
+ <vbox id="chat-notification-top" flex="1">
+ <!-- notificationbox will be added here lazily. -->
+ <vbox id="conversationsBox" flex="1">
+
+ <vbox flex="1" id="noConvScreen" class="im-placeholder-screen" align="center" pack="center">
+ <hbox id="noConvBox" class="im-placeholder-box" align="start">
+ <vbox id="noConvInnerBox" class="im-placeholder-innerbox" flex="1">
+ <label id="noConvTitle" class="im-placeholder-title">&chat.noConv.title;</label>
+ <description id="noConvDesc"
+ class="im-placeholder-desc">&chat.noConv.description;</description>
+ </vbox>
+ <vbox id="noAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true">
+ <label id="noAccountTitle" class="im-placeholder-title">&chat.noAccount.title;</label>
+ <description id="noAccountDesc"
+ class="im-placeholder-desc">&chat.noAccount.description;</description>
+ <hbox class="im-placeholder-button-box" flex="1">
+ <spacer flex="1"/>
+ <button id="openIMAccountWizardButton" label="&chat.accountWizard.button;"
+ oncommand="openIMAccountWizard();"/>
+ </hbox>
+ </vbox>
+ <vbox id="noConnectedAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true">
+ <label id="noConnectedAccountTitle"
+ class="im-placeholder-title">&chat.noConnectedAccount.title;</label>
+ <description id="noConnectedAccountDesc"
+ class="im-placeholder-desc">&chat.noConnectedAccount.description;</description>
+ <hbox class="im-placeholder-button-box" flex="1">
+ <spacer flex="1"/>
+ <button id="openIMAccountManagerButton" label="&chat.showAccountManager.button;"
+ oncommand="openIMAccountMgr();"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ </vbox>
+
+ <vbox id="logDisplay" flex="1" hidden="true">
+ <vbox flex="1">
+ <vbox flex="1" id="noPreviousConvScreen" class="im-placeholder-screen" align="center" pack="center">
+ <hbox id="noPreviousConvBox" class="im-placeholder-box" align="start">
+ <vbox id="noPreviousConvInnerBox" class="im-placeholder-innerbox" flex="1">
+ <description id="noPreviousConvDesc"
+ class="im-placeholder-desc">&chat.noPreviousConv.description;</description>
+ </vbox>
+ </hbox>
+ </vbox>
+ <vbox flex="1" id="logDisplayBrowserBox">
+ <browser id="conv-log-browser" is="conversation-browser" type="content"
+ contextmenu="chatConversationContextMenu" flex="1"
+ tooltip="imTooltip"
+ messagemanagergroup="browsers"/>
+ <html:progress id="log-browserProgress" max="100" hidden="true"/>
+ <findbar id="log-findbar" browserid="conv-log-browser"/>
+ </vbox>
+ </vbox>
+ <button id="goToConversation" hidden="true"
+ oncommand="chatHandler.showCurrentConversation();"/>
+ </vbox>
+
+ </vbox>
+ </vbox>
+ <splitter id="contextSplitter" hidden="true" collapse="after"/>
+ <vbox id="contextPane" hidden="true" width="250" persist="width">
+ <chat-conversation-info id="conv-top-info" class="conv-top-info"/>
+ <vbox id="contextPaneFlexibleBox" flex="1">
+ <vbox class="conv-chat" width="150">
+ <hbox align="baseline" class="conv-nicklist-header input-container">
+ <label class="conv-nicklist-header-label conv-header-label"
+ control="participantCount"
+ value="&chat.participants;"
+ crop="end"/>
+ <html:input id="participantCount" readonly="readonly" class="plain"/>
+ </hbox>
+ <richlistbox id="nicklist" class="conv-nicklist"
+ flex="1" seltype="multiple"
+ tooltip="imTooltip"
+ context="participantListContextMenu"
+ onclick="chatHandler.onNickClick(event);"
+ onkeypress="chatHandler.onNicklistKeyPress(event);"/>
+ </vbox>
+ <splitter id="logsSplitter" class="conv-chat" collapse="after" orient="vertical"/>
+ <vbox id="previousConversations" style="min-height: 200px;">
+ <label class="conv-logs-header-label conv-header-label"
+ crop="end"
+ value="&chat.previousConversations;"/>
+ <tree id="logTree" flex="1" hidecolumnpicker="true" seltype="single"
+ context="logTreeContext" onselect="chatHandler.onLogSelect();">
+ <treecols>
+ <treecol id="logCol"
+ style="flex: 1 auto"
+ primary="true"
+ hideheader="true"
+ crop="center"
+ ignoreincolumnpicker="true"/>
+ </treecols>
+ <treechildren/>
+ </tree>
+ </vbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </vbox>
diff --git a/comm/mail/components/im/content/chat-messenger.js b/comm/mail/components/im/content/chat-messenger.js
new file mode 100644
index 0000000000..b3030bf9df
--- /dev/null
+++ b/comm/mail/components/im/content/chat-messenger.js
@@ -0,0 +1,2162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements MozXULElement */
+/* import-globals-from ../../../base/content/globalOverlay.js */
+
+// This file is loaded in messenger.xhtml.
+/* globals MailToolboxCustomizeDone, openIMAccountMgr,
+ PROTO_TREE_VIEW, statusSelector, ZoomManager, gSpacesToolbar */
+
+var { Notifications } = ChromeUtils.importESModule(
+ "resource:///modules/chatNotifications.sys.mjs"
+);
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs",
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+var gChatSpellChecker;
+var gRangeParent;
+var gRangeOffset;
+
+var gBuddyListContextMenu = null;
+var gChatBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+);
+
+function openChatContextMenu(popup) {
+ let conv = chatHandler._getActiveConvView();
+ let spellchecker = conv.spellchecker;
+ let textbox = conv.editor;
+
+ // The context menu uses gChatSpellChecker, so set it here for the duration of the menu.
+ gChatSpellChecker = spellchecker;
+
+ spellchecker.init(textbox.editor);
+ spellchecker.initFromEvent(gRangeParent, gRangeOffset);
+ let onMisspelling = spellchecker.overMisspelling;
+ document.getElementById("spellCheckSuggestionsSeparator").hidden =
+ !onMisspelling;
+ document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling;
+ let separator = document.getElementById("spellCheckAddSep");
+ separator.hidden = !onMisspelling;
+ document.getElementById("spellCheckNoSuggestions").hidden =
+ !onMisspelling || spellchecker.addSuggestionsToMenu(popup, separator, 5);
+
+ let dictMenu = document.getElementById("spellCheckDictionariesMenu");
+ let dictSep = document.getElementById("spellCheckLanguageSeparator");
+ spellchecker.addDictionaryListToMenu(dictMenu, dictSep);
+
+ document
+ .getElementById("spellCheckEnable")
+ .setAttribute("checked", spellchecker.enabled);
+ document
+ .getElementById("spellCheckDictionaries")
+ .setAttribute("hidden", !spellchecker.enabled);
+
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_selectAll");
+}
+
+function clearChatContextMenu(popup) {
+ let conv = chatHandler._getActiveConvView();
+ let spellchecker = conv.spellchecker;
+ spellchecker.clearDictionaryListFromMenu();
+ spellchecker.clearSuggestionsFromMenu();
+}
+
+function getSelectedPanel() {
+ for (let element of document.getElementById("conversationsBox").children) {
+ if (!element.hidden) {
+ return element;
+ }
+ }
+ return null;
+}
+
+/**
+ * Hide all the child elements in the conversations box. After hiding all the
+ * child elements, one element will be from chat conversation, chat log or
+ * no conversation screen.
+ */
+function hideConversationsBoxPanels() {
+ for (let element of document.getElementById("conversationsBox").children) {
+ element.hidden = true;
+ }
+}
+
+// This function modifies gChatSpellChecker and updates the UI accordingly. It's
+// called when the user clicks on context menu to toggle the spellcheck feature.
+function enableInlineSpellCheck(aEnableInlineSpellCheck) {
+ gChatSpellChecker.enabled = aEnableInlineSpellCheck;
+ document
+ .getElementById("spellCheckEnable")
+ .setAttribute("checked", aEnableInlineSpellCheck);
+ document
+ .getElementById("spellCheckDictionaries")
+ .setAttribute("hidden", !aEnableInlineSpellCheck);
+}
+
+function buddyListContextMenu(aXulMenu) {
+ // Clear the context menu from OTR related entries.
+ OTRUI.removeBuddyContextMenu(document);
+
+ this.target = aXulMenu.triggerNode.closest("richlistitem");
+ if (!this.target) {
+ this.shouldDisplay = false;
+ return;
+ }
+
+ this.menu = aXulMenu;
+ let localName = this.target.localName;
+ this.onContact =
+ localName == "richlistitem" &&
+ this.target.getAttribute("is") == "chat-contact-richlistitem";
+ this.onConv =
+ localName == "richlistitem" &&
+ this.target.getAttribute("is") == "chat-imconv-richlistitem";
+ this.shouldDisplay = this.onContact || this.onConv;
+
+ let hide = !this.onContact;
+ [
+ "context-openconversation",
+ "context-edit-buddy-separator",
+ "context-alias",
+ "context-delete",
+ ].forEach(function (aId) {
+ document.getElementById(aId).hidden = hide;
+ });
+
+ document.getElementById("context-close-conversation").hidden = !this.onConv;
+ document.getElementById("context-openconversation").disabled =
+ !hide && !this.target.canOpenConversation();
+
+ // Show OTR related context menu items if:
+ // - The OTR feature is currently enabled.
+ // - The target's status is not currently offline or unknown.
+ // - The target can send messages.
+ if (
+ ChatEncryption.otrEnabled &&
+ this.target.contact &&
+ this.target.contact.statusType != Ci.imIStatusInfo.STATUS_UNKNOWN &&
+ this.target.contact.statusType != Ci.imIStatusInfo.STATUS_OFFLINE &&
+ this.target.contact.canSendMessage
+ ) {
+ OTRUI.addBuddyContextMenu(this.menu, document, this.target.contact);
+ }
+
+ const accountBuddy = this._getAccountBuddy();
+ const canVerifyBuddy = accountBuddy?.canVerifyIdentity;
+ const verifyMenuItem = document.getElementById("context-verifyBuddy");
+ verifyMenuItem.hidden = !canVerifyBuddy;
+ if (canVerifyBuddy) {
+ const identityVerified = accountBuddy.identityVerified;
+ verifyMenuItem.disabled = identityVerified;
+ document.l10n.setAttributes(
+ verifyMenuItem,
+ identityVerified ? "chat-identity-verified" : "chat-verify-identity"
+ );
+ }
+}
+
+buddyListContextMenu.prototype = {
+ /**
+ * Get the prplIAccountBuddy instance that is related to the current context.
+ *
+ * @returns {prplIAccountBuddy?}
+ */
+ _getAccountBuddy() {
+ if (this.onConv && this.target.conv?.buddy) {
+ return this.target.conv.buddy;
+ }
+ return this.target.contact?.preferredBuddy?.preferredAccountBuddy;
+ },
+ openConversation() {
+ if (this.onContact || this.onConv) {
+ this.target.openConversation();
+ }
+ },
+ closeConversation() {
+ if (this.onConv) {
+ this.target.closeConversation();
+ }
+ },
+ alias() {
+ if (this.onContact) {
+ this.target.startAliasing();
+ }
+ },
+ delete() {
+ if (!this.onContact) {
+ return;
+ }
+
+ let buddy = this.target.contact.preferredBuddy;
+ let displayName = this.target.displayName;
+ let promptTitle = gChatBundle.formatStringFromName(
+ "buddy.deletePrompt.title",
+ [displayName]
+ );
+ let userName = buddy.userName;
+ if (displayName != userName) {
+ displayName = gChatBundle.formatStringFromName(
+ "buddy.deletePrompt.displayName",
+ [displayName, userName]
+ );
+ }
+ let proto = buddy.protocol.name; // FIXME build a list
+ let promptMessage = gChatBundle.formatStringFromName(
+ "buddy.deletePrompt.message",
+ [displayName, proto]
+ );
+ let deleteButton = gChatBundle.GetStringFromName(
+ "buddy.deletePrompt.button"
+ );
+ let prompts = Services.prompt;
+ let flags =
+ prompts.BUTTON_TITLE_IS_STRING * prompts.BUTTON_POS_0 +
+ prompts.BUTTON_TITLE_CANCEL * prompts.BUTTON_POS_1 +
+ prompts.BUTTON_POS_1_DEFAULT;
+ if (
+ prompts.confirmEx(
+ window,
+ promptTitle,
+ promptMessage,
+ flags,
+ deleteButton,
+ null,
+ null,
+ null,
+ {}
+ )
+ ) {
+ return;
+ }
+
+ this.target.deleteContact();
+ },
+ /**
+ * Command event handler to verify the identity of the buddy the context menu
+ * is currently opened for.
+ */
+ verifyIdentity() {
+ const accountBuddy = this._getAccountBuddy();
+ if (!accountBuddy) {
+ return;
+ }
+ ChatEncryption.verifyIdentity(window, accountBuddy);
+ },
+};
+
+var gChatTab = null;
+
+var chatTabType = {
+ name: "chat",
+ panelId: "chatTabPanel",
+ hasBeenOpened: false,
+ modes: {
+ chat: {
+ type: "chat",
+ },
+ },
+
+ tabMonitor: {
+ monitorName: "chattab",
+
+ // Unused, but needed functions
+ onTabTitleChanged() {},
+ onTabOpened(aTab) {},
+ onTabPersist() {},
+ onTabRestored() {},
+
+ onTabClosing() {
+ chatHandler._onTabDeactivated(true);
+ },
+ onTabSwitched(aNewTab, aOldTab) {
+ // aNewTab == chat is handled earlier by showTab() below.
+ if (aOldTab?.mode.name == "chat") {
+ chatHandler._onTabDeactivated(true);
+ }
+ },
+ },
+
+ _handleArgs(aArgs) {
+ if (
+ !aArgs ||
+ !("convType" in aArgs) ||
+ (aArgs.convType != "log" && aArgs.convType != "focus")
+ ) {
+ return;
+ }
+
+ if (aArgs.convType == "focus") {
+ chatHandler.focusConversation(aArgs.conv);
+ return;
+ }
+
+ let item = document.getElementById("searchResultConv");
+ item.log = aArgs.conv;
+ if (aArgs.searchTerm) {
+ item.searchTerm = aArgs.searchTerm;
+ } else {
+ delete item.searchTerm;
+ }
+ item.hidden = false;
+ if (item.getAttribute("selected")) {
+ chatHandler.onListItemSelected();
+ } else {
+ document.getElementById("contactlistbox").selectedItem = item;
+ }
+ },
+ _onWindowActivated() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "chat") {
+ chatHandler._onTabActivated();
+ }
+ },
+ _onWindowDeactivated() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "chat") {
+ chatHandler._onTabDeactivated(false);
+ }
+ },
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/chat.svg");
+ if (!this.hasBeenOpened) {
+ if (chatHandler.ChatCore && chatHandler.ChatCore.initialized) {
+ let convs = IMServices.conversations.getUIConversations();
+ if (convs.length != 0) {
+ convs.sort((a, b) =>
+ a.title.toLowerCase().localeCompare(b.title.toLowerCase())
+ );
+ for (let conv of convs) {
+ chatHandler._addConversation(conv);
+ }
+ }
+ }
+ this.hasBeenOpened = true;
+ }
+
+ // The tab monitor will inform us when a different tab is selected.
+ let tabmail = document.getElementById("tabmail");
+ tabmail.registerTabMonitor(this.tabMonitor);
+ window.addEventListener("deactivate", chatTabType._onWindowDeactivated);
+ window.addEventListener("activate", chatTabType._onWindowActivated);
+
+ gChatTab = aTab;
+ this._handleArgs(aArgs);
+ this.showTab(aTab);
+ chatHandler.updateTitle();
+ },
+ shouldSwitchTo(aArgs) {
+ if (!gChatTab) {
+ return -1;
+ }
+ this._handleArgs(aArgs);
+ return document.getElementById("tabmail").tabInfo.indexOf(gChatTab);
+ },
+ showTab(aTab) {
+ gChatTab = aTab;
+ chatHandler._onTabActivated();
+ // The next call may change the selected conversation, but that
+ // will be handled by the selected mutation observer of the chat-imconv-richlistitem.
+ chatHandler._updateSelectedConversation();
+ chatHandler._updateFocus();
+ },
+ closeTab(aTab) {
+ gChatTab = null;
+ let tabmail = document.getElementById("tabmail");
+ tabmail.unregisterTabMonitor(this.tabMonitor);
+ window.removeEventListener("deactivate", chatTabType._onWindowDeactivated);
+ window.removeEventListener("activate", chatTabType._onWindowActivated);
+ },
+ persistTab(aTab) {
+ return {};
+ },
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("chat", {});
+ },
+
+ supportsCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ return true;
+ default:
+ return false;
+ }
+ },
+ isCommandEnabled(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ return !!this.getBrowser();
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ return !!this.getFindbar();
+ default:
+ return false;
+ }
+ },
+ doCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ ZoomManager.reduce();
+ break;
+ case "cmd_fullZoomEnlarge":
+ ZoomManager.enlarge();
+ break;
+ case "cmd_fullZoomReset":
+ ZoomManager.reset();
+ break;
+ case "cmd_fullZoomToggle":
+ ZoomManager.toggleZoom();
+ break;
+ case "cmd_find":
+ this.getFindbar().onFindCommand();
+ break;
+ case "cmd_findAgain":
+ this.getFindbar().onFindAgainCommand(false);
+ break;
+ case "cmd_findPrevious":
+ this.getFindbar().onFindAgainCommand(true);
+ break;
+ }
+ },
+ onEvent(aEvent, aTab) {},
+ getBrowser(aTab) {
+ let panel = getSelectedPanel();
+ if (panel == document.getElementById("logDisplay")) {
+ if (!document.getElementById("logDisplayBrowserBox").hidden) {
+ return document.getElementById("conv-log-browser");
+ }
+ } else if (panel && panel.localName == "chat-conversation") {
+ return panel.convBrowser;
+ }
+ return null;
+ },
+ getFindbar(aTab) {
+ let panel = getSelectedPanel();
+ if (panel == document.getElementById("logDisplay")) {
+ if (!document.getElementById("logDisplayBrowserBox").hidden) {
+ return document.getElementById("log-findbar");
+ }
+ } else if (panel && panel.localName == "chat-conversation") {
+ return panel.findbar;
+ }
+ return null;
+ },
+
+ saveTabState(aTab) {},
+};
+
+var chatHandler = {
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document.getElementById("chat-notification-top").prepend(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ _addConversation(aConv) {
+ let list = document.getElementById("contactlistbox");
+ let convs = document.getElementById("conversationsGroup");
+ let selectedItem = list.selectedItem;
+ let shouldSelect =
+ gChatTab &&
+ gChatTab.tabNode.selected &&
+ (!selectedItem ||
+ (selectedItem == convs &&
+ convs.nextElementSibling.localName != "richlistitem" &&
+ convs.nextSibling.getAttribute("is") != "chat-imconv-richlistitem"));
+ let elt = convs.addContact(aConv, "imconv");
+ if (shouldSelect) {
+ list.selectedItem = elt;
+ }
+
+ if (aConv.isChat || !aConv.buddy) {
+ return;
+ }
+
+ let contact = aConv.buddy.buddy.contact;
+ elt.imContact = contact;
+ let groupName = (contact.online ? "on" : "off") + "linecontactsGroup";
+ let item = document.getElementById(groupName).removeContact(contact);
+ if (list.selectedItem == item) {
+ list.selectedItem = elt;
+ }
+ },
+
+ _hasConversationForContact(aContact) {
+ let convs = document.getElementById("conversationsGroup").contacts;
+ return convs.some(
+ aConversation =>
+ aConversation.hasOwnProperty("imContact") &&
+ aConversation.imContact.id == aContact.id
+ );
+ },
+
+ _chatButtonUpdatePending: false,
+ updateChatButtonState() {
+ if (this._chatButtonUpdatePending) {
+ return;
+ }
+ this._chatButtonUpdatePending = true;
+ Services.tm.mainThread.dispatch(
+ this._updateChatButtonState.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ },
+ // This is the unread count that was part of the latest
+ // unread-im-count-changed notification.
+ _notifiedUnreadCount: 0,
+ _updateChatButtonState() {
+ delete this._chatButtonUpdatePending;
+
+ let [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount] =
+ this.countUnreadMessages();
+ let unreadCount = unreadTargetedCount + unreadOTRNotificationCount;
+
+ let chatButton = document.getElementById("button-chat");
+ if (chatButton) {
+ chatButton.badgeCount = unreadCount;
+ if (unreadTotalCount || unreadOTRNotificationCount) {
+ chatButton.setAttribute("unreadMessages", "true");
+ } else {
+ chatButton.removeAttribute("unreadMessages");
+ }
+ }
+
+ let spacesChatButton = document.getElementById("chatButton");
+ if (spacesChatButton) {
+ spacesChatButton.classList.toggle("has-badge", unreadCount);
+ document.l10n.setAttributes(
+ spacesChatButton.querySelector(".spaces-badge-container"),
+ "chat-button-unread-messages",
+ {
+ count: unreadCount,
+ }
+ );
+ }
+ let spacesPopupButtonChat = document.getElementById(
+ "spacesPopupButtonChat"
+ );
+ if (spacesPopupButtonChat) {
+ spacesPopupButtonChat.classList.toggle("has-badge", unreadCount);
+ gSpacesToolbar.updatePinnedBadgeState();
+ }
+
+ let unifiedToolbarButtons = document.querySelectorAll(
+ "#unifiedToolbarContent .chat .unified-toolbar-button"
+ );
+ for (const button of unifiedToolbarButtons) {
+ if (unreadCount) {
+ button.badge = unreadCount;
+ continue;
+ }
+ button.badge = null;
+ }
+
+ if (unreadCount != this._notifiedUnreadCount) {
+ let unreadInt = Cc["@mozilla.org/supports-PRInt32;1"].createInstance(
+ Ci.nsISupportsPRInt32
+ );
+ unreadInt.data = unreadCount;
+ Services.obs.notifyObservers(
+ unreadInt,
+ "unread-im-count-changed",
+ unreadCount
+ );
+ this._notifiedUnreadCount = unreadCount;
+ }
+ },
+
+ countUnreadMessages() {
+ let convs = IMServices.conversations.getUIConversations();
+ let unreadTargetedCount = 0;
+ let unreadTotalCount = 0;
+ let unreadOTRNotificationCount = 0;
+ for (let conv of convs) {
+ unreadTargetedCount += conv.unreadTargetedMessageCount;
+ unreadTotalCount += conv.unreadIncomingMessageCount;
+ unreadOTRNotificationCount += conv.unreadOTRNotificationCount;
+ }
+ return [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount];
+ },
+
+ updateTitle() {
+ if (!gChatTab) {
+ return;
+ }
+
+ let title = gChatBundle.GetStringFromName("chatTabTitle");
+ let [unreadTargetedCount] = this.countUnreadMessages();
+ if (unreadTargetedCount) {
+ title += " (" + unreadTargetedCount + ")";
+ } else {
+ let selectedItem = document.getElementById("contactlistbox").selectedItem;
+ if (
+ selectedItem &&
+ selectedItem.localName == "richlistitem" &&
+ selectedItem.getAttribute("is") == "chat-imconv-richlistitem" &&
+ !selectedItem.hidden
+ ) {
+ title += " - " + selectedItem.getAttribute("displayname");
+ }
+ }
+ gChatTab.title = title;
+ document.getElementById("tabmail").setTabTitle(gChatTab);
+ },
+
+ onConvResize() {
+ let panel = getSelectedPanel();
+ if (panel && panel.localName == "chat-conversation") {
+ panel.onConvResize();
+ }
+ },
+
+ setStatusMenupopupCommand(aEvent) {
+ let target = aEvent.target;
+ if (target.getAttribute("id") == "imStatusShowAccounts") {
+ openIMAccountMgr();
+ return;
+ }
+
+ let status = target.getAttribute("status");
+ if (!status) {
+ // Can status really be null? Maybe because of an add-on...
+ return;
+ }
+
+ let us = IMServices.core.globalUserStatus;
+ us.setStatus(Status.toFlag(status), us.statusText);
+ },
+
+ _pendingLogBrowserLoad: false,
+ _showLogPanel() {
+ hideConversationsBoxPanels();
+ document.getElementById("logDisplay").hidden = false;
+ document.getElementById("logDisplayBrowserBox").hidden = false;
+ document.getElementById("noPreviousConvScreen").hidden = true;
+ },
+ _showLog(aConversation, aSearchTerm) {
+ if (!aConversation) {
+ return;
+ }
+ this._showLogPanel();
+ let browser = document.getElementById("conv-log-browser");
+ browser._convScrollEnabled = false;
+ if (this._pendingLogBrowserLoad) {
+ browser._conv = aConversation;
+ return;
+ }
+ browser.init(aConversation);
+ this._pendingLogBrowserLoad = true;
+ if (aSearchTerm) {
+ this._pendingSearchTerm = aSearchTerm;
+ }
+ Services.obs.addObserver(this, "conversation-loaded");
+
+ // Conversation title may not be set yet if this is a search result.
+ let cti = document.getElementById("conv-top-info");
+ cti.setAttribute("displayName", aConversation.title);
+
+ // Find and display the contact for this log.
+ for (let account of IMServices.accounts.getAccounts()) {
+ if (
+ account.normalizedName == aConversation.account.normalizedName &&
+ account.protocol.normalizedName == aConversation.account.protocol.name
+ ) {
+ if (aConversation.isChat) {
+ // Display information for MUCs.
+ cti.setAsChat("", false, false);
+ cti.setProtocol(account.protocol);
+ return;
+ }
+ // Display information for contacts.
+ let accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount(
+ aConversation.normalizedName,
+ account
+ );
+ if (!accountBuddy) {
+ return;
+ }
+ let contact = accountBuddy.buddy.contact;
+ if (!contact) {
+ return;
+ }
+ if (this.observedContact && this.observedContact.id == contact.id) {
+ return;
+ }
+ this.showContactInfo(contact);
+ this.observedContact = contact;
+ return;
+ }
+ }
+ },
+
+ /**
+ * Display a list of logs into a tree, and optionally handle a default selection.
+ *
+ * @param {imILog} aLogs - An array of imILog.
+ * @param {boolean|imILog} aShouldSelect - Either a boolean (true means select the first log
+ * of the list, false or undefined means don't mess with the selection) or a log
+ * item that needs to be selected.
+ * @returns {boolean} True if there's at least one log in the list, false if empty.
+ */
+ _showLogList(aLogs, aShouldSelect) {
+ let logTree = document.getElementById("logTree");
+ let treeView = (this._treeView = new chatLogTreeView(logTree, aLogs));
+ if (!treeView._rowMap.length) {
+ return false;
+ }
+ if (!aShouldSelect) {
+ return true;
+ }
+ if (aShouldSelect === true) {
+ // Select the first line.
+ let selectIndex = 0;
+ if (treeView.isContainer(selectIndex)) {
+ // If the first line is a group, open it and select the
+ // next line instead.
+ treeView.toggleOpenState(selectIndex++);
+ }
+ logTree.view.selection.select(selectIndex);
+ return true;
+ }
+ // Find the aShouldSelect log and select it.
+ let logTime = aShouldSelect.time;
+ for (let index = 0; index < treeView._rowMap.length; ++index) {
+ if (
+ !treeView.isContainer(index) &&
+ treeView._rowMap[index].log.time == logTime
+ ) {
+ logTree.view.selection.select(index);
+ logTree.ensureRowIsVisible(index);
+ return true;
+ }
+ if (!treeView._rowMap[index].children.some(i => i.log.time == logTime)) {
+ continue;
+ }
+ treeView.toggleOpenState(index);
+ ++index;
+ while (
+ index < treeView._rowMap.length &&
+ treeView._rowMap[index].log.time != logTime
+ ) {
+ ++index;
+ }
+ if (treeView._rowMap[index].log.time == logTime) {
+ logTree.view.selection.select(index);
+ logTree.ensureRowIsVisible(index);
+ }
+ return true;
+ }
+ throw new Error(
+ "Couldn't find the log to select among the set of logs passed."
+ );
+ },
+
+ onLogSelect() {
+ let selection = this._treeView.selection;
+ let currentIndex = selection.currentIndex;
+ // The current (focused) row may not be actually selected...
+ if (!selection.isSelected(currentIndex)) {
+ return;
+ }
+
+ let log = this._treeView._rowMap[currentIndex].log;
+ if (!log) {
+ return;
+ }
+
+ let list = document.getElementById("contactlistbox");
+ if (list.selectedItem.getAttribute("id") != "searchResultConv") {
+ document.getElementById("goToConversation").hidden = false;
+ }
+ log.getConversation().then(aLogConv => {
+ this._showLog(aLogConv);
+ });
+ },
+
+ _contactObserver: {
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic == "contact-status-changed" ||
+ aTopic == "contact-display-name-changed" ||
+ aTopic == "contact-icon-changed"
+ ) {
+ chatHandler.showContactInfo(aSubject);
+ }
+ },
+ },
+ _observedContact: null,
+ get observedContact() {
+ return this._observedContact;
+ },
+ set observedContact(aContact) {
+ if (aContact == this._observedContact) {
+ return;
+ }
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this._contactObserver);
+ delete this._observedContact;
+ }
+ this._observedContact = aContact;
+ if (aContact) {
+ aContact.addObserver(this._contactObserver);
+ }
+ },
+ /**
+ * Callback for the button that closes the log view. Resets the shared UI
+ * elements to match the state of the active conversation. Hides the log
+ * browser.
+ */
+ showCurrentConversation() {
+ let item = document.getElementById("contactlistbox").selectedItem;
+ if (!item) {
+ return;
+ }
+ if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem"
+ ) {
+ hideConversationsBoxPanels();
+ item.convView.hidden = false;
+ item.convView.querySelector(".conv-bottom").setAttribute("height", 90);
+ document.getElementById("logTree").view.selection.clearSelection();
+ if (item.conv.isChat) {
+ item.convView.updateTopic();
+ }
+ ChatEncryption.updateEncryptionButton(document, item.conv);
+ item.convView.focus();
+ } else if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-contact-richlistitem"
+ ) {
+ item.openConversation();
+ }
+ },
+ focusConversation(aUIConv) {
+ let conv =
+ document.getElementById("conversationsGroup").contactsById[aUIConv.id];
+ document.getElementById("contactlistbox").selectedItem = conv;
+ if (conv.convView) {
+ conv.convView.focus();
+ }
+ },
+ showContactInfo(aContact) {
+ let cti = document.getElementById("conv-top-info");
+ cti.setUserIcon(aContact.buddyIconFilename, true);
+ cti.setAttribute("displayName", aContact.displayName);
+ cti.setProtocol(aContact.preferredBuddy.protocol);
+
+ let statusText = aContact.statusText;
+ let statusType = aContact.statusType;
+ cti.setStatus(
+ Status.toAttribute(statusType),
+ Status.toLabel(statusType, statusText)
+ );
+
+ let button = document.getElementById("goToConversation");
+ button.label = gChatBundle.formatStringFromName(
+ "startAConversationWith.button",
+ [aContact.displayName]
+ );
+ button.disabled = !aContact.canSendMessage;
+ },
+ _hideContextPane(aHide) {
+ document.getElementById("contextSplitter").hidden = aHide;
+ document.getElementById("contextPane").hidden = aHide;
+ },
+ onListItemClick(aEvent) {
+ // We only care about single clicks of the left button.
+ if (aEvent.button != 0 || aEvent.detail != 1) {
+ return;
+ }
+ let item = document.getElementById("contactlistbox").selectedItem;
+ if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem" &&
+ item.convView
+ ) {
+ item.convView.focus();
+ }
+ },
+ onListItemSelected() {
+ let contactlistbox = document.getElementById("contactlistbox");
+ let item = contactlistbox.selectedItem;
+ if (
+ !item ||
+ item.hidden ||
+ (item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-group-richlistitem")
+ ) {
+ this._hideContextPane(true);
+ hideConversationsBoxPanels();
+ document.getElementById("noConvScreen").hidden = false;
+ this.updateTitle();
+ this.observedContact = null;
+ ChatEncryption.hideEncryptionButton(document);
+ return;
+ }
+
+ this._hideContextPane(false);
+
+ if (item.getAttribute("id") == "searchResultConv") {
+ document.getElementById("goToConversation").hidden = true;
+ document.getElementById("contextPane").removeAttribute("chat");
+ let cti = document.getElementById("conv-top-info");
+ cti.clear();
+ this.observedContact = null;
+ // Always hide encryption options for search conv
+ ChatEncryption.hideEncryptionButton(document);
+
+ let path = "logs/" + item.log.path;
+ path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ ...path.split("/")
+ );
+ IMServices.logs.getLogFromFile(path, true).then(aLog => {
+ IMServices.logs.getSimilarLogs(aLog).then(aSimilarLogs => {
+ if (contactlistbox.selectedItem != item) {
+ return;
+ }
+ this._pendingSearchTerm = item.searchTerm || undefined;
+ this._showLogList(aSimilarLogs, aLog);
+ });
+ });
+ } else if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem"
+ ) {
+ if (!item.convView) {
+ let convBox = document.getElementById("conversationsBox");
+ let conv = document.createXULElement("chat-conversation");
+ convBox.appendChild(conv);
+ conv.conv = item.conv;
+ conv.tab = item;
+ conv.convBrowser.setAttribute("context", "chatConversationContextMenu");
+ conv.setAttribute("tooltip", "imTooltip");
+ item.convView = conv;
+ document.getElementById("contextSplitter").hidden = false;
+ document.getElementById("contextPane").hidden = false;
+ conv.editor.addEventListener("contextmenu", e => {
+ // Stash away the original event's parent and range for later use.
+ gRangeParent = e.rangeParent;
+ gRangeOffset = e.rangeOffset;
+ let popup = document.getElementById("chatContextMenu");
+ popup.openPopupAtScreen(e.screenX, e.screenY, true);
+ e.preventDefault();
+ });
+
+ // Set "mail editor mask" so changing the language doesn't
+ // affect the global preference and multiple chats can have
+ // individual languages.
+ conv.editor.editor.flags |= Ci.nsIEditor.eEditorMailMask;
+
+ let preferredLanguages =
+ Services.prefs.getStringPref("spellchecker.dictionary")?.split(",") ??
+ [];
+ let initialLanguage = "";
+ if (preferredLanguages.length === 1) {
+ initialLanguage = preferredLanguages[0];
+ }
+ // Initialise language to the default.
+ conv.editor.setAttribute("lang", initialLanguage);
+
+ // Attach listener so we hear about language changes.
+ document.addEventListener("spellcheck-changed", e => {
+ let conv = chatHandler._getActiveConvView();
+ let activeLanguages = e.detail.dictionaries ?? [];
+ let languageToSet = "";
+ if (activeLanguages.length === 1) {
+ languageToSet = activeLanguages[0];
+ }
+ conv.editor.setAttribute("lang", languageToSet);
+ });
+ } else {
+ item.convView.onConvResize();
+ }
+
+ hideConversationsBoxPanels();
+ item.convView.hidden = false;
+ item.convView.querySelector(".conv-bottom").setAttribute("height", 90);
+ item.convView.updateConvStatus();
+ item.update();
+
+ ChatEncryption.updateEncryptionButton(document, item.conv);
+
+ IMServices.logs.getLogsForConversation(item.conv).then(aLogs => {
+ if (contactlistbox.selectedItem != item) {
+ return;
+ }
+ this._showLogList(aLogs);
+ });
+
+ document
+ .querySelectorAll("#contextPaneFlexibleBox .conv-chat")
+ .forEach(e => {
+ e.setAttribute("hidden", !item.conv.isChat);
+ });
+ if (item.conv.isChat) {
+ item.convView.showParticipants();
+ }
+
+ let button = document.getElementById("goToConversation");
+ button.label = gChatBundle.GetStringFromName(
+ "goBackToCurrentConversation.button"
+ );
+ button.disabled = false;
+ this.observedContact = null;
+ } else if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-contact-richlistitem"
+ ) {
+ ChatEncryption.hideEncryptionButton(document);
+ let contact = item.contact;
+ if (
+ this.observedContact &&
+ contact &&
+ this.observedContact.id == contact.id
+ ) {
+ return; // onselect has just been fired again because a status
+ // change caused the chat-contact-richlistitem to move.
+ // Return early to avoid flickering and changing the selected log.
+ }
+
+ this.showContactInfo(contact);
+ this.observedContact = contact;
+
+ document
+ .querySelectorAll("#contextPaneFlexibleBox .conv-chat")
+ .forEach(e => {
+ e.setAttribute("hidden", "true");
+ });
+
+ IMServices.logs.getLogsForContact(contact).then(aLogs => {
+ if (contactlistbox.selectedItem != item) {
+ return;
+ }
+ if (!this._showLogList(aLogs, true)) {
+ hideConversationsBoxPanels();
+ document.getElementById("logDisplay").hidden = false;
+ document.getElementById("logDisplayBrowserBox").hidden = false;
+ document.getElementById("noPreviousConvScreen").hidden = true;
+ }
+ });
+ }
+ this.updateTitle();
+ },
+
+ onNickClick(aEvent) {
+ // Open a private conversation only for a middle or double click.
+ if (aEvent.button != 1 && (aEvent.button != 0 || aEvent.detail != 2)) {
+ return;
+ }
+
+ let conv = document.getElementById("contactlistbox").selectedItem.conv;
+ let nick = aEvent.target.chatBuddy.name;
+ let name = conv.target.getNormalizedChatBuddyName(nick);
+ try {
+ let newconv = conv.account.createConversation(name);
+ this.focusConversation(newconv);
+ } catch (e) {}
+ },
+
+ onNicklistKeyPress(aEvent) {
+ if (aEvent.keyCode != aEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ let listbox = aEvent.target;
+ if (listbox.selectedCount == 0) {
+ return;
+ }
+
+ let conv = document.getElementById("contactlistbox").selectedItem.conv;
+ let newconv;
+ for (let i = 0; i < listbox.selectedCount; ++i) {
+ let nick = listbox.getSelectedItem(i).chatBuddy.name;
+ let name = conv.target.getNormalizedChatBuddyName(nick);
+ try {
+ newconv = conv.account.createConversation(name);
+ } catch (e) {}
+ }
+ // Only focus last of the opened conversations.
+ if (newconv) {
+ this.focusConversation(newconv);
+ }
+ },
+
+ addBuddy() {
+ window.openDialog(
+ "chrome://messenger/content/chat/addbuddy.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen"
+ );
+ },
+
+ joinChat() {
+ window.openDialog(
+ "chrome://messenger/content/chat/joinchat.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen"
+ );
+ },
+
+ _colorCache: {},
+ // Duplicated code from chat-conversation.js :-(
+ _computeColor(aName) {
+ if (Object.prototype.hasOwnProperty.call(this._colorCache, aName)) {
+ return this._colorCache[aName];
+ }
+
+ // Compute the color based on the nick
+ var nick = aName.match(/[a-zA-Z0-9]+/);
+ nick = nick ? nick[0].toLowerCase() : (nick = aName);
+ // We compute a hue value (between 0 and 359) based on the
+ // characters of the nick.
+ // The first character weights kInitialWeight, each following
+ // character weights kWeightReductionPerChar * the weight of the
+ // previous character.
+ const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters.
+ const kWeightReductionPerChar = 0.52; // arbitrary value
+ var weight = kInitialWeight;
+ var res = 0;
+ for (var i = 0; i < nick.length; ++i) {
+ var char = nick.charCodeAt(i) - 47;
+ if (char > 10) {
+ char -= 39;
+ }
+ // now char contains a value between 1 and 36
+ res += char * weight;
+ weight *= kWeightReductionPerChar;
+ }
+ return (this._colorCache[aName] = Math.round(res) % 360);
+ },
+
+ _placeHolderButtonId: "",
+ _updateNoConvPlaceHolder() {
+ let connected = false;
+ let hasAccount = false;
+ let canJoinChat = false;
+ for (let account of IMServices.accounts.getAccounts()) {
+ hasAccount = true;
+ if (account.connected) {
+ connected = true;
+ if (account.canJoinChat) {
+ canJoinChat = true;
+ break;
+ }
+ }
+ }
+ document.getElementById("noConvInnerBox").hidden = !connected;
+ document.getElementById("noAccountInnerBox").hidden = hasAccount;
+ document.getElementById("noConnectedAccountInnerBox").hidden =
+ connected || !hasAccount;
+ if (connected) {
+ delete this._placeHolderButtonId;
+ } else {
+ this._placeHolderButtonId = hasAccount
+ ? "openIMAccountManagerButton"
+ : "openIMAccountWizardButton";
+ }
+
+ for (let id of [
+ "statusTypeIcon",
+ "statusMessage",
+ "button-chat-accounts",
+ ]) {
+ let elt = document.getElementById(id);
+ if (elt) {
+ elt.disabled = !hasAccount;
+ }
+ }
+
+ let chatStatusCmd = document.getElementById("cmd_chatStatus");
+ if (chatStatusCmd) {
+ if (hasAccount) {
+ chatStatusCmd.removeAttribute("disabled");
+ } else {
+ chatStatusCmd.setAttribute("disabled", true);
+ }
+ }
+
+ let addBuddyButton = document.getElementById("button-add-buddy");
+ if (addBuddyButton) {
+ addBuddyButton.disabled = !connected;
+ }
+
+ let addBuddyCmd = document.getElementById("cmd_addChatBuddy");
+ if (addBuddyCmd) {
+ if (connected) {
+ addBuddyCmd.removeAttribute("disabled");
+ } else {
+ addBuddyCmd.setAttribute("disabled", true);
+ }
+ }
+
+ let joinChatButton = document.getElementById("button-join-chat");
+ if (joinChatButton) {
+ joinChatButton.disabled = !canJoinChat;
+ }
+
+ let joinChatCmd = document.getElementById("cmd_joinChat");
+ if (joinChatCmd) {
+ if (canJoinChat) {
+ joinChatCmd.removeAttribute("disabled");
+ } else {
+ joinChatCmd.setAttribute("disabled", true);
+ }
+ }
+
+ let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"];
+ let contactlist = document.getElementById("contactlistbox");
+ if (
+ !hasAccount ||
+ (!connected &&
+ groupIds.every(
+ id => document.getElementById(id + "Group").contacts.length
+ ))
+ ) {
+ contactlist.disabled = true;
+ } else {
+ contactlist.disabled = false;
+ this._updateSelectedConversation();
+ }
+ },
+ _updateSelectedConversation() {
+ let list = document.getElementById("contactlistbox");
+ // We can't select anything if there's no account.
+ if (list.disabled) {
+ return;
+ }
+
+ // If the selection is already a conversation with unread messages, keep it.
+ let selectedItem = list.selectedItem;
+ if (
+ selectedItem &&
+ selectedItem.localName == "richlistitem" &&
+ selectedItem.getAttribute("is") == "chat-imconv-richlistitem" &&
+ selectedItem.directedUnreadCount
+ ) {
+ selectedItem.update();
+ return;
+ }
+
+ let firstConv;
+ let convs = document.getElementById("conversationsGroup");
+ let conv = convs.nextElementSibling;
+ while (conv.id != "searchResultConv") {
+ if (!firstConv) {
+ firstConv = conv;
+ }
+ // If there is a conversation with unread messages, select it.
+ if (conv.directedUnreadCount) {
+ list.selectedItem = conv;
+ return;
+ }
+ conv = conv.nextElementSibling;
+ }
+
+ // No unread messages, select the first conversation, but only if
+ // the existing selection is uninteresting (a section header).
+ if (firstConv) {
+ if (
+ !selectedItem ||
+ (selectedItem.localName == "richlistitem" &&
+ selectedItem.getAttribute("is") == "chat-group-richlistitem")
+ ) {
+ list.selectedItem = firstConv;
+ }
+ return;
+ }
+
+ // No conversation, if a visible item is selected, keep it.
+ if (selectedItem && !selectedItem.collapsed) {
+ return;
+ }
+
+ // Select the first visible group header.
+ let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"];
+ for (let id of groupIds) {
+ let item = document.getElementById(id + "Group");
+ if (item.collapsed) {
+ continue;
+ }
+ list.selectedItem = item;
+ return;
+ }
+ },
+ _updateFocus() {
+ let focusId = this._placeHolderButtonId || "contactlistbox";
+ document.getElementById(focusId).focus();
+ },
+ _getActiveConvView() {
+ let list = document.getElementById("contactlistbox");
+ if (list.disabled) {
+ return null;
+ }
+ let selectedItem = list.selectedItem;
+ if (
+ !selectedItem ||
+ (selectedItem.localName != "richlistitem" &&
+ selectedItem.getAttribute("is") != "chat-imconv-richlistitem")
+ ) {
+ return null;
+ }
+ let convView = selectedItem.convView;
+ if (!convView || !convView.loaded) {
+ return null;
+ }
+ return convView;
+ },
+ _onTabActivated() {
+ let convView = chatHandler._getActiveConvView();
+ if (convView) {
+ convView.switchingToPanel();
+ }
+ },
+ _onTabDeactivated(aHidden) {
+ let convView = chatHandler._getActiveConvView();
+ if (convView) {
+ convView.switchingAwayFromPanel(aHidden);
+ }
+ },
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "chat-core-initialized") {
+ this.initAfterChatCore();
+ return;
+ }
+
+ if (aTopic == "conversation-loaded") {
+ let browser = document.getElementById("conv-log-browser");
+ if (aSubject != browser) {
+ return;
+ }
+
+ for (let msg of browser._conv.getMessages()) {
+ if (!msg.system) {
+ msg.color =
+ "color: hsl(" + this._computeColor(msg.who) + ", 100%, 40%);";
+ }
+ browser.appendMessage(msg);
+ }
+
+ if (this._pendingSearchTerm) {
+ let findbar = document.getElementById("log-findbar");
+ findbar._findField.value = this._pendingSearchTerm;
+ findbar.open();
+ browser.focus();
+ delete this._pendingSearchTerm;
+ let eventListener = function () {
+ findbar.onFindAgainCommand();
+ if (findbar._findFailedString && browser._messageDisplayPending) {
+ return;
+ }
+ // Search result found or all messages added, we're done.
+ browser.removeEventListener("MessagesDisplayed", eventListener);
+ };
+ browser.addEventListener("MessagesDisplayed", eventListener);
+ }
+ this._pendingLogBrowserLoad = false;
+ Services.obs.removeObserver(this, "conversation-loaded");
+ return;
+ }
+
+ if (
+ aTopic == "account-connected" ||
+ aTopic == "account-disconnected" ||
+ aTopic == "account-added" ||
+ aTopic == "account-removed"
+ ) {
+ this._updateNoConvPlaceHolder();
+ return;
+ }
+
+ if (aTopic == "contact-signed-on") {
+ if (!this._hasConversationForContact(aSubject)) {
+ document.getElementById("onlinecontactsGroup").addContact(aSubject);
+ document.getElementById("offlinecontactsGroup").removeContact(aSubject);
+ }
+ return;
+ }
+ if (aTopic == "contact-signed-off") {
+ if (!this._hasConversationForContact(aSubject)) {
+ document.getElementById("offlinecontactsGroup").addContact(aSubject);
+ document.getElementById("onlinecontactsGroup").removeContact(aSubject);
+ }
+ return;
+ }
+ if (aTopic == "contact-added") {
+ let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup";
+ document.getElementById(groupName).addContact(aSubject);
+ return;
+ }
+ if (aTopic == "contact-removed") {
+ let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup";
+ document.getElementById(groupName).removeContact(aSubject);
+ return;
+ }
+ if (aTopic == "contact-no-longer-dummy") {
+ let oldId = parseInt(aData);
+ let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup";
+ let group = document.getElementById(groupName);
+ if (group.contactsById.hasOwnProperty(oldId)) {
+ let contact = group.contactsById[oldId];
+ delete group.contactsById[oldId];
+ group.contactsById[contact.contact.id] = contact;
+ }
+ return;
+ }
+ if (aTopic == "new-text") {
+ this.updateChatButtonState();
+ return;
+ }
+ if (aTopic == "new-ui-conversation") {
+ if (chatTabType.hasBeenOpened) {
+ chatHandler._addConversation(aSubject);
+ }
+ return;
+ }
+ if (aTopic == "ui-conversation-closed") {
+ this.updateChatButtonState();
+ if (!chatTabType.hasBeenOpened) {
+ return;
+ }
+ let conv = document
+ .getElementById("conversationsGroup")
+ .removeContact(aSubject);
+ if (conv.imContact) {
+ let contact = conv.imContact;
+ let groupName = (contact.online ? "on" : "off") + "linecontactsGroup";
+ document.getElementById(groupName).addContact(contact);
+ }
+ return;
+ }
+
+ if (aTopic == "buddy-authorization-request") {
+ aSubject.QueryInterface(Ci.prplIBuddyRequest);
+ let authLabel = gChatBundle.formatStringFromName(
+ "buddy.authRequest.label",
+ [aSubject.userName]
+ );
+ let value =
+ "buddy-auth-request-" + aSubject.account.id + aSubject.userName;
+ let acceptButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.authRequest.allow.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName("buddy.authRequest.allow.label"),
+ callback() {
+ aSubject.grant();
+ },
+ };
+ let denyButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.authRequest.deny.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName("buddy.authRequest.deny.label"),
+ callback() {
+ aSubject.deny();
+ },
+ };
+ let box = this.msgNotificationBar;
+ let notification = box.appendNotification(
+ value,
+ {
+ label: authLabel,
+ priority: box.PRIORITY_INFO_HIGH,
+ },
+ [acceptButton, denyButton]
+ );
+ notification.removeAttribute("dismissable");
+ if (!gChatTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("chat", { background: true });
+ }
+ return;
+ }
+ if (aTopic == "buddy-authorization-request-canceled") {
+ aSubject.QueryInterface(Ci.prplIBuddyRequest);
+ let value =
+ "buddy-auth-request-" + aSubject.account.id + aSubject.userName;
+ let box = this.msgNotificationBar;
+ let notification = box.getNotificationWithValue(value);
+ if (notification) {
+ notification.close();
+ }
+ return;
+ }
+ if (aTopic == "buddy-verification-request") {
+ aSubject.QueryInterface(Ci.imIIncomingSessionVerification);
+ let barLabel = gChatBundle.formatStringFromName(
+ "buddy.verificationRequest.label",
+ [aSubject.subject]
+ );
+ let value =
+ "buddy-verification-request-" +
+ aSubject.account.id +
+ "-" +
+ aSubject.subject;
+ let acceptButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.allow.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.allow.label"
+ ),
+ callback() {
+ aSubject
+ .verify()
+ .then(() => {
+ window.openDialog(
+ "chrome://messenger/content/chat/verify.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ aSubject
+ );
+ })
+ .catch(error => {
+ aSubject.account.ERROR(error);
+ aSubject.cancel();
+ });
+ },
+ };
+ let denyButton = {
+ accessKey: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.deny.accesskey"
+ ),
+ label: gChatBundle.GetStringFromName(
+ "buddy.verificationRequest.deny.label"
+ ),
+ callback() {
+ aSubject.cancel();
+ },
+ };
+ let box = this.msgNotificationBar;
+ let notification = box.appendNotification(
+ value,
+ {
+ label: barLabel,
+ priority: box.PRIORITY_INFO_HIGH,
+ },
+ [acceptButton, denyButton]
+ );
+ notification.removeAttribute("dismissable");
+ if (!gChatTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("chat", { background: true });
+ }
+ return;
+ }
+ if (aTopic == "buddy-verification-request-canceled") {
+ aSubject.QueryInterface(Ci.imIIncomingSessionVerification);
+ let value =
+ "buddy-verification-request-" +
+ aSubject.account.id +
+ "-" +
+ aSubject.subject;
+ let box = this.msgNotificationBar;
+ let notification = box.getNotificationWithValue(value);
+ if (notification) {
+ notification.close();
+ }
+ return;
+ }
+ if (aTopic == "conv-authorization-request") {
+ aSubject.QueryInterface(Ci.prplIChatRequest);
+ let value =
+ "conv-auth-request-" + aSubject.account.id + aSubject.conversationName;
+ let buttons = [
+ {
+ "l10n-id": "chat-conv-invite-accept",
+ callback() {
+ aSubject.grant();
+ },
+ },
+ ];
+ if (aSubject.canDeny) {
+ buttons.push({
+ "l10n-id": "chat-conv-invite-deny",
+ callback() {
+ aSubject.deny();
+ },
+ });
+ }
+ let box = this.msgNotificationBar;
+ // Remove the notification when the request is cancelled.
+ aSubject.completePromise.catch(() => {
+ let notification = box.getNotificationWithValue(value);
+ if (notification) {
+ notification.close();
+ }
+ });
+ let notification = box.appendNotification(
+ value,
+ {
+ label: "",
+ priority: box.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ document.l10n.setAttributes(
+ notification.messageText,
+ "chat-conv-invite-label",
+ {
+ conversation: aSubject.conversationName,
+ }
+ );
+ notification.removeAttribute("dismissable");
+ if (!gChatTab) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("chat", { background: true });
+ }
+ return;
+ }
+ if (aTopic == "conversation-update-type") {
+ // Find conversation in conversation list.
+ let contactlistbox = document.getElementById("contactlistbox");
+ let convs = document.getElementById("conversationsGroup");
+ let convItem = convs.nextElementSibling;
+ while (
+ convItem.conv.target.id !== aSubject.target.id &&
+ convItem.id != "searchResultConv"
+ ) {
+ convItem = convItem.nextElementSibling;
+ }
+ if (convItem.conv.target.id !== aSubject.target.id) {
+ // Could not find a matching conversation in the front end.
+ return;
+ }
+ // Update UI conversation associated with components
+ if (convItem.convView && convItem.convView.conv !== aSubject) {
+ convItem.convView.changeConversation(aSubject);
+ }
+ if (convItem.conv !== aSubject) {
+ convItem.changeConversation(aSubject);
+ } else {
+ convItem.update();
+ }
+ // If the changed conversation is the selected item, make sure
+ // we update the UI elements to match the conversation type.
+ let selectedItem = contactlistbox.selectedItem;
+ if (selectedItem === convItem && selectedItem.convView) {
+ this.onListItemSelected();
+ }
+ }
+ },
+ initAfterChatCore() {
+ let onGroup = document.getElementById("onlinecontactsGroup");
+ let offGroup = document.getElementById("offlinecontactsGroup");
+
+ for (let name in chatHandler.allContacts) {
+ let contact = chatHandler.allContacts[name];
+ let group = contact.online ? onGroup : offGroup;
+ group.addContact(contact);
+ }
+
+ onGroup._updateGroupLabel();
+ offGroup._updateGroupLabel();
+
+ [
+ "new-text",
+ "new-ui-conversation",
+ "ui-conversation-closed",
+ "contact-signed-on",
+ "contact-signed-off",
+ "contact-added",
+ "contact-removed",
+ "contact-no-longer-dummy",
+ "account-connected",
+ "account-disconnected",
+ "account-added",
+ "account-removed",
+ "conversation-update-type",
+ ].forEach(chatHandler._addObserver);
+
+ chatHandler._updateNoConvPlaceHolder();
+ statusSelector.init();
+ },
+ _observedTopics: [],
+ _addObserver(aTopic) {
+ Services.obs.addObserver(chatHandler, aTopic);
+ chatHandler._observedTopics.push(aTopic);
+ },
+ _removeObservers() {
+ for (let topic of this._observedTopics) {
+ Services.obs.removeObserver(this, topic);
+ }
+ },
+ // TODO move this function away from here and test it.
+ _getNextUnreadConversation(aConversations, aCurrent, aReverse) {
+ let convCount = aConversations.length;
+ if (!convCount) {
+ return -1;
+ }
+
+ let direction = aReverse ? -1 : 1;
+ let next = i => {
+ i += direction;
+ if (i < 0) {
+ return i + convCount;
+ }
+ if (i >= convCount) {
+ return i - convCount;
+ }
+ return i;
+ };
+
+ // Find starting point
+ let start = 0;
+ if (Number.isInteger(aCurrent)) {
+ start = next(aCurrent);
+ } else if (aReverse) {
+ start = convCount - 1;
+ }
+
+ // Cycle through all conversations until we are at the start again.
+ let i = start;
+ do {
+ // If there is a conversation with unread messages, select it.
+ if (aConversations[i].unreadIncomingMessageCount) {
+ return i;
+ }
+ i = next(i);
+ } while (i !== start && i !== aCurrent);
+ return -1;
+ },
+ _selectNextUnreadConversation(aReverse, aList) {
+ let conversations = document.getElementById("conversationsGroup").contacts;
+ if (!conversations.length) {
+ return;
+ }
+
+ let rawConversations = conversations.map(c => c.conv);
+ let current;
+ if (
+ aList.selectedItem.localName == "richlistitem" &&
+ aList.selectedItem.getAttribute("is") == "chat-imconv-richlistitem"
+ ) {
+ current = aList.selectedIndex - aList.getIndexOfItem(conversations[0]);
+ }
+ let newIndex = this._getNextUnreadConversation(
+ rawConversations,
+ current,
+ aReverse
+ );
+ if (newIndex !== -1) {
+ aList.selectedItem = conversations[newIndex];
+ }
+ },
+ /**
+ * Restores the width in pixels stored on the width attribute of an element as
+ * CSS width, so it is used for flex layout calculations. Useful for restoring
+ * elements that were sized by a XUL splitter.
+ *
+ * @param {Element} element - Element to transfer the width attribute to CSS for.
+ */
+ _restoreWidth: element =>
+ (element.style.width = `${element.getAttribute("width")}px`),
+ async init() {
+ Notifications.init();
+ if (!Services.prefs.getBoolPref("mail.chat.enabled")) {
+ [
+ "chatButton",
+ "spacesPopupButtonChat",
+ "button-chat",
+ "menu_goChat",
+ "goChatSeparator",
+ "imAccountsStatus",
+ "joinChatMenuItem",
+ "newIMAccountMenuItem",
+ "newIMContactMenuItem",
+ "appmenu_newIMAccountMenuItem",
+ "appmenu_newIMContactMenuItem",
+ ].forEach(function (aId) {
+ let elt = document.getElementById(aId);
+ if (elt) {
+ elt.hidden = true;
+ }
+ });
+ return;
+ }
+
+ window.addEventListener("unload", this._removeObservers.bind(this));
+
+ // initialize the customizeDone method on the customizeable toolbar
+ var toolbox = document.getElementById("chat-view-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeChatToolbar");
+ };
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.registerTabType(chatTabType);
+ this._addObserver("buddy-authorization-request");
+ this._addObserver("buddy-authorization-request-canceled");
+ this._addObserver("buddy-verification-request");
+ this._addObserver("buddy-verification-request-canceled");
+ this._addObserver("conv-authorization-request");
+ let listbox = document.getElementById("contactlistbox");
+ listbox.addEventListener("keypress", function (aEvent) {
+ let item = listbox.selectedItem;
+ if (!item || !item.parentNode) {
+ // empty list or item no longer in the list
+ return;
+ }
+ item.keyPress(aEvent);
+ });
+ listbox.addEventListener("select", this.onListItemSelected.bind(this));
+ listbox.addEventListener("click", this.onListItemClick.bind(this));
+ document
+ .getElementById("chatTabPanel")
+ .addEventListener("keypress", function (aEvent) {
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey;
+ if (
+ !accelKeyPressed ||
+ (aEvent.keyCode != aEvent.DOM_VK_DOWN &&
+ aEvent.keyCode != aEvent.DOM_VK_UP)
+ ) {
+ return;
+ }
+ listbox._userSelecting = true;
+ let reverse = aEvent.keyCode != aEvent.DOM_VK_DOWN;
+ if (aEvent.shiftKey) {
+ chatHandler._selectNextUnreadConversation(reverse, listbox);
+ } else {
+ listbox.moveByOffset(reverse ? -1 : 1, true, false);
+ }
+ listbox._userSelecting = false;
+ let item = listbox.selectedItem;
+ if (
+ item.localName == "richlistitem" &&
+ item.getAttribute("is") == "chat-imconv-richlistitem" &&
+ item.convView
+ ) {
+ item.convView.focus();
+ } else {
+ listbox.focus();
+ }
+ });
+ window.addEventListener("resize", this.onConvResize.bind(this));
+ document.getElementById("conversationsGroup").sortComparator = (a, b) =>
+ a.title.toLowerCase().localeCompare(b.title.toLowerCase());
+
+ const { allContacts, onlineContacts, ChatCore } =
+ ChromeUtils.importESModule("resource:///modules/chatHandler.sys.mjs");
+ this.allContacts = allContacts;
+ this.onlineContacts = onlineContacts;
+ this.ChatCore = ChatCore;
+ if (this.ChatCore.initialized) {
+ this.initAfterChatCore();
+ } else {
+ this.ChatCore.init();
+ this._addObserver("chat-core-initialized");
+ }
+
+ if (ChatEncryption.otrEnabled) {
+ this._initOTR();
+ }
+
+ this._restoreWidth(document.getElementById("listPaneBox"));
+ this._restoreWidth(document.getElementById("contextPane"));
+ },
+
+ async _initOTR() {
+ if (!IMServices.core.initialized) {
+ await new Promise(resolve => {
+ function initObserver() {
+ Services.obs.removeObserver(initObserver, "prpl-init");
+ resolve();
+ }
+ Services.obs.addObserver(initObserver, "prpl-init");
+ });
+ }
+ // Avoid loading OTR until we have an im account set up.
+ if (IMServices.accounts.getAccounts().length === 0) {
+ await new Promise(resolve => {
+ function accountsObserver() {
+ if (IMServices.accounts.getAccounts().length > 0) {
+ Services.obs.removeObserver(accountsObserver, "account-added");
+ resolve();
+ }
+ }
+ Services.obs.addObserver(accountsObserver, "account-added");
+ });
+ }
+ await OTRUI.init();
+ },
+};
+
+function chatLogTreeGroupItem(aTitle, aLogItems) {
+ this._title = aTitle;
+ this._children = aLogItems;
+ for (let child of this._children) {
+ child._parent = this;
+ }
+ this._open = false;
+}
+chatLogTreeGroupItem.prototype = {
+ getText() {
+ return this._title;
+ },
+ get id() {
+ return this._title;
+ },
+ get open() {
+ return this._open;
+ },
+ get level() {
+ return 0;
+ },
+ get _parent() {
+ return null;
+ },
+ get children() {
+ return this._children;
+ },
+ getProperties() {
+ return "";
+ },
+};
+
+function chatLogTreeLogItem(aLog, aText, aLevel) {
+ this.log = aLog;
+ this._text = aText;
+ this._level = aLevel;
+}
+chatLogTreeLogItem.prototype = {
+ getText() {
+ return this._text;
+ },
+ get id() {
+ return this.log.title;
+ },
+ get open() {
+ return false;
+ },
+ get level() {
+ return this._level;
+ },
+ get children() {
+ return [];
+ },
+ getProperties() {
+ return "";
+ },
+};
+
+function chatLogTreeView(aTree, aLogs) {
+ this._tree = aTree;
+ this._logs = aLogs;
+ this._tree.view = this;
+ this._rebuild();
+}
+chatLogTreeView.prototype = {
+ __proto__: new PROTO_TREE_VIEW(),
+
+ _rebuild() {
+ // Some date helpers...
+ const kDayInMsecs = 24 * 60 * 60 * 1000;
+ const kWeekInMsecs = 7 * kDayInMsecs;
+ const kTwoWeeksInMsecs = 2 * kWeekInMsecs;
+
+ // Drop the old rowMap.
+ if (this._tree) {
+ this._tree.rowCountChanged(0, -this._rowMap.length);
+ }
+ this._rowMap = [];
+
+ let placesBundle = Services.strings.createBundle(
+ "chrome://places/locale/places.properties"
+ );
+ let dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "short" });
+ let monthYearFormat = new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ });
+ let monthFormat = new Intl.DateTimeFormat(undefined, { month: "long" });
+ let weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "long" });
+ let nowDate = new Date();
+ let todayDate = new Date(
+ nowDate.getFullYear(),
+ nowDate.getMonth(),
+ nowDate.getDate()
+ );
+
+ // The keys used in the 'firstgroups' object should match string ids.
+ // The order is the reverse of that in which they will appear
+ // in the logTree.
+ let firstgroups = {
+ previousWeek: [],
+ currentWeek: [],
+ };
+
+ // today and yesterday are treated differently, because for JSON logs they
+ // represent individual logs, and are not "groups".
+ let today = null,
+ yesterday = null;
+
+ // Build a chatLogTreeLogItem for each log, and put it in the right group.
+ let groups = {};
+ for (let log of this._logs) {
+ let logDate = new Date(log.time * 1000);
+ // Calculate elapsed time between the log and 00:00:00 today.
+ let timeFromToday = todayDate - logDate;
+ let title = dateFormat.format(logDate);
+ let group;
+ if (timeFromToday <= 0) {
+ today = new chatLogTreeLogItem(
+ log,
+ gChatBundle.GetStringFromName("log.today"),
+ 0
+ );
+ continue;
+ } else if (timeFromToday <= kDayInMsecs) {
+ yesterday = new chatLogTreeLogItem(
+ log,
+ gChatBundle.GetStringFromName("log.yesterday"),
+ 0
+ );
+ continue;
+ } else if (timeFromToday <= kWeekInMsecs - kDayInMsecs) {
+ // Note that the 7 days of the current week include today.
+ group = firstgroups.currentWeek;
+ title = weekdayFormat.format(logDate);
+ } else if (timeFromToday <= kTwoWeeksInMsecs - kDayInMsecs) {
+ group = firstgroups.previousWeek;
+ } else {
+ logDate.setHours(0);
+ logDate.setMinutes(0);
+ logDate.setSeconds(0);
+ logDate.setDate(1);
+ let groupID = logDate.toISOString();
+ if (!(groupID in groups)) {
+ let groupname;
+ if (logDate.getFullYear() == nowDate.getFullYear()) {
+ if (logDate.getMonth() == nowDate.getMonth()) {
+ groupname = placesBundle.GetStringFromName(
+ "finduri-AgeInMonths-is-0"
+ );
+ } else {
+ groupname = monthFormat.format(logDate);
+ }
+ } else {
+ groupname = monthYearFormat.format(logDate);
+ }
+ groups[groupID] = {
+ entries: [],
+ name: groupname,
+ };
+ }
+ group = groups[groupID].entries;
+ }
+ group.push(new chatLogTreeLogItem(log, title, 1));
+ }
+
+ let groupIDs = Object.keys(groups).sort().reverse();
+
+ // Add firstgroups to groups and groupIDs.
+ for (let groupID in firstgroups) {
+ let group = firstgroups[groupID];
+ if (!group.length) {
+ continue;
+ }
+ groupIDs.unshift(groupID);
+ groups[groupID] = {
+ entries: firstgroups[groupID],
+ name: gChatBundle.GetStringFromName("log." + groupID),
+ };
+ }
+
+ // Build tree.
+ if (today) {
+ this._rowMap.push(today);
+ }
+ if (yesterday) {
+ this._rowMap.push(yesterday);
+ }
+ groupIDs.forEach(function (aGroupID) {
+ let group = groups[aGroupID];
+ group.entries.sort((l1, l2) => l2.log.time - l1.log.time);
+ this._rowMap.push(new chatLogTreeGroupItem(group.name, group.entries));
+ }, this);
+
+ // Finally, notify the tree.
+ if (this._tree) {
+ this._tree.rowCountChanged(0, this._rowMap.length);
+ }
+ },
+};
+
+/**
+ * Handler for onpopupshowing event of the participantListContextMenu. Decides
+ * if the menu should be shown at all and manages the disabled state of its
+ * items.
+ *
+ * @param {XULMenuPopupElement} menu
+ * @returns {boolean} If the menu should be shown, currently decided based on
+ * if its only item has an action to perform.
+ */
+function showParticipantMenu(menu) {
+ const target = menu.triggerNode.closest("richlistitem");
+ if (!target?.chatBuddy?.canVerifyIdentity) {
+ return false;
+ }
+ const identityVerified = target.chatBuddy.identityVerified;
+ const verifyMenuItem = document.getElementById("context-verifyParticipant");
+ verifyMenuItem.disabled = identityVerified;
+ document.l10n.setAttributes(
+ verifyMenuItem,
+ identityVerified ? "chat-identity-verified" : "chat-verify-identity"
+ );
+ return true;
+}
+
+/**
+ * Command handler for the verify identity context menu item of the participant
+ * context menu. Initiates the verification for the participant the menu was
+ * opened on.
+ *
+ * @returns {undefined}
+ */
+function verifyChatParticipant() {
+ const target = document
+ .getElementById("participantListContextMenu")
+ .triggerNode.closest("richlistitem");
+ const buddy = target.chatBuddy;
+ if (!buddy) {
+ return;
+ }
+ ChatEncryption.verifyIdentity(window, buddy);
+}
+
+window.addEventListener("load", () => chatHandler.init());
diff --git a/comm/mail/components/im/content/imAccountWizard.js b/comm/mail/components/im/content/imAccountWizard.js
new file mode 100644
index 0000000000..128412aa5b
--- /dev/null
+++ b/comm/mail/components/im/content/imAccountWizard.js
@@ -0,0 +1,526 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// chat/content/imAccountOptionsHelper.js
+/* globals accountOptionsHelper */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var PREF_EXTENSIONS_GETMOREPROTOCOLSURL = "extensions.getMoreProtocolsURL";
+
+var accountWizard = {
+ onload() {
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardfinish", this.createAccount.bind(this));
+ let accountProtocolPage = document.getElementById("accountprotocol");
+ accountProtocolPage.addEventListener(
+ "pageadvanced",
+ this.selectProtocol.bind(this)
+ );
+ let accountUsernamePage = document.getElementById("accountusername");
+ accountUsernamePage.addEventListener(
+ "pageshow",
+ this.showUsernamePage.bind(this)
+ );
+ accountUsernamePage.addEventListener(
+ "pagehide",
+ this.hideUsernamePage.bind(this)
+ );
+ let accountAdvancedPage = document.getElementById("accountadvanced");
+ accountAdvancedPage.addEventListener(
+ "pageshow",
+ this.showAdvanced.bind(this)
+ );
+ let accountSummaryPage = document.getElementById("accountsummary");
+ accountSummaryPage.addEventListener(
+ "pageshow",
+ this.showSummary.bind(this)
+ );
+
+ // Ensure the im core is initialized before we get a list of protocols.
+ IMServices.core.init();
+
+ accountWizard.setGetMoreProtocols();
+
+ var protoList = document.getElementById("protolist");
+ var protos = IMServices.core.getProtocols();
+ protos.sort((a, b) => {
+ if (a.name < b.name) {
+ return -1;
+ }
+ return a.name > b.name ? 1 : 0;
+ });
+ protos.forEach(function (proto) {
+ let image = document.createElement("img");
+ image.setAttribute("src", ChatIcons.getProtocolIconURI(proto));
+ image.setAttribute("alt", "");
+ image.classList.add("protoIcon");
+
+ let label = document.createXULElement("label");
+ label.setAttribute("value", proto.name);
+
+ let item = document.createXULElement("richlistitem");
+ item.setAttribute("value", proto.id);
+ item.appendChild(image);
+ item.appendChild(label);
+ protoList.appendChild(item);
+ });
+
+ // there is a strange selection bug without this timeout
+ setTimeout(function () {
+ protoList.selectedIndex = 0;
+ }, 0);
+
+ Services.obs.addObserver(this, "prpl-quit");
+ window.addEventListener("unload", this.unload);
+ },
+ unload() {
+ Services.obs.removeObserver(accountWizard, "prpl-quit");
+ },
+ observe(aObject, aTopic, aData) {
+ if (aTopic == "prpl-quit") {
+ // libpurple is being uninitialized. We can't create any new
+ // account so keeping this wizard open would be pointless, close it.
+ window.close();
+ }
+ },
+
+ /**
+ * Builds the full username from the username boxes.
+ *
+ * @returns {string} assembled username
+ */
+ getUsername() {
+ let usernameBoxIndex = 0;
+ if (this.proto.usernamePrefix) {
+ usernameBoxIndex = 1;
+ }
+ // If the first username input is empty, make sure we return an empty
+ // string so that it blocks the 'next' button of the wizard.
+ if (!this.userNameBoxes[usernameBoxIndex].value) {
+ return "";
+ }
+
+ return this.userNameBoxes.reduce((prev, elt) => prev + elt.value, "");
+ },
+
+ /**
+ * Check that the username fields generate a new username, and if it is valid
+ * allow advancing the wizard.
+ */
+ checkUsername() {
+ var wizard = document.querySelector("wizard");
+ var name = accountWizard.getUsername();
+ var duplicateWarning = document.getElementById("duplicateAccount");
+ if (!name) {
+ wizard.canAdvance = false;
+ duplicateWarning.hidden = true;
+ return;
+ }
+
+ var exists = accountWizard.proto.accountExists(name);
+ wizard.canAdvance = !exists;
+ duplicateWarning.hidden = !exists;
+ },
+
+ /**
+ * Takes the value of the primary username field and splits it if the value
+ * matches the split field syntax.
+ */
+ splitUsername() {
+ let usernameBoxIndex = 0;
+ if (this.proto.usernamePrefix) {
+ usernameBoxIndex = 1;
+ }
+ let username = this.userNameBoxes[usernameBoxIndex].value;
+ let splitValues = this.proto.splitUsername(username);
+ if (!splitValues.length) {
+ return;
+ }
+ for (const box of this.userNameBoxes) {
+ if (Element.isInstance(box)) {
+ box.value = splitValues.shift();
+ }
+ }
+ this.checkUsername();
+ },
+
+ selectProtocol() {
+ var protoList = document.getElementById("protolist");
+ var id = protoList.selectedItem.value;
+ this.proto = IMServices.core.getProtocolById(id);
+ },
+
+ /**
+ * Create a new input field for receiving a username.
+ *
+ * @param {string} aName - The id for the input.
+ * @param {string} aLabel - The text for the username label.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ * @param {string} [aDefaultValue] - The initial value for the username.
+ *
+ * @returns {HTMLInputElement} - The newly created username input.
+ */
+ insertUsernameField(aName, aLabel, grid, aDefaultValue) {
+ var label = document.createXULElement("label");
+ label.setAttribute("value", aLabel);
+ label.setAttribute("control", aName);
+ label.setAttribute("id", aName + "-label");
+ label.classList.add("label-inline");
+ grid.appendChild(label);
+
+ var input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ input.setAttribute("id", aName);
+ input.classList.add("input-inline");
+ if (aDefaultValue) {
+ input.setAttribute("value", aDefaultValue);
+ }
+ input.addEventListener("input", event => {
+ this.checkUsername();
+ });
+ // Only add the split logic to the first input field
+ if (!this.userNameBoxes) {
+ input.addEventListener("blur", event => {
+ this.splitUsername();
+ });
+ }
+ grid.appendChild(input);
+
+ return input;
+ },
+
+ /**
+ * Builds the username input boxes from the username split defined by the
+ * protocol.
+ */
+ showUsernamePage() {
+ var proto = this.proto.id;
+ if ("userNameBoxes" in this && this.userNameProto == proto) {
+ this.checkUsername();
+ return;
+ }
+
+ var bundle = document.getElementById("accountsBundle");
+ var usernameInfo;
+ var emptyText = this.proto.usernameEmptyText;
+ if (emptyText) {
+ usernameInfo = bundle.getFormattedString(
+ "accountUsernameInfoWithDescription",
+ [emptyText, this.proto.name]
+ );
+ } else {
+ usernameInfo = bundle.getFormattedString("accountUsernameInfo", [
+ this.proto.name,
+ ]);
+ }
+ document.getElementById("usernameInfo").textContent = usernameInfo;
+
+ var grid = document.getElementById("userNameBox");
+ // remove anything that may be there for another protocol
+ while (grid.hasChildNodes()) {
+ grid.lastChild.remove();
+ }
+ this.userNameBoxes = undefined;
+
+ var splits = this.proto.getUsernameSplit();
+
+ var label = bundle.getString("accountUsername");
+ this.userNameBoxes = [this.insertUsernameField("name", label, grid)];
+ this.userNameBoxes[0].emptyText = emptyText;
+ let usernameBoxIndex = 0;
+
+ if (this.proto.usernamePrefix) {
+ this.userNameBoxes.unshift({ value: this.proto.usernamePrefix });
+ usernameBoxIndex = 1;
+ }
+
+ for (let i = 0; i < splits.length; ++i) {
+ this.userNameBoxes.push({ value: splits[i].separator });
+ label = bundle.getFormattedString("accountColon", [splits[i].label]);
+ let defaultVal = splits[i].defaultValue;
+ this.userNameBoxes.push(
+ this.insertUsernameField("username-split-" + i, label, grid, defaultVal)
+ );
+ }
+ this.userNameBoxes[usernameBoxIndex].focus();
+ this.userNameProto = proto;
+ this.checkUsername();
+ },
+
+ hideUsernamePage() {
+ document.querySelector("wizard").canAdvance = true;
+ var next = "account" + (this.proto.noPassword ? "advanced" : "password");
+ document.getElementById("accountusername").next = next;
+ },
+
+ showAdvanced() {
+ // ensure we don't destroy user data if it's not necessary
+ var id = this.proto.id;
+ if ("protoSpecOptId" in this && this.protoSpecOptId == id) {
+ return;
+ }
+ this.protoSpecOptId = id;
+
+ this.populateProtoSpecificBox();
+
+ // Make sure the protocol specific options and wizard buttons are visible.
+ let wizard = document.querySelector("wizard");
+ if (wizard.scrollHeight > window.innerHeight) {
+ window.resizeBy(0, wizard.scrollHeight - window.innerHeight);
+ }
+
+ let alias = document.getElementById("alias");
+ alias.focus();
+ },
+
+ populateProtoSpecificBox() {
+ let haveOptions = accountOptionsHelper.addOptions(
+ this.proto.id + "-",
+ this.proto.getOptions()
+ );
+ document.getElementById("protoSpecificGroupbox").hidden = !haveOptions;
+ if (haveOptions) {
+ var bundle = document.getElementById("accountsBundle");
+ document.getElementById("protoSpecificCaption").textContent =
+ bundle.getFormattedString("protoOptions", [this.proto.name]);
+ }
+ },
+
+ /**
+ * Create new summary field and value elements.
+ *
+ * @param {string} aLabel - The name of the field being summarised.
+ * @param {string} aValue - The value of the field being summarised.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ */
+ createSummaryRow(aLabel, aValue, grid) {
+ var label = document.createXULElement("label");
+ label.classList.add("header", "label-inline");
+ if (aLabel.length > 20) {
+ aLabel = aLabel.substring(0, 20);
+ aLabel += "…";
+ }
+
+ label.setAttribute("value", aLabel);
+ grid.appendChild(label);
+
+ var input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ input.setAttribute("value", aValue);
+ input.classList.add("plain", "input-inline");
+ input.setAttribute("readonly", true);
+ grid.appendChild(input);
+ },
+
+ showSummary() {
+ var rows = document.getElementById("summaryRows");
+ var bundle = document.getElementById("accountsBundle");
+ while (rows.hasChildNodes()) {
+ rows.lastChild.remove();
+ }
+
+ var label = document.getElementById("protoLabel").value;
+ this.createSummaryRow(label, this.proto.name, rows);
+ this.username = this.getUsername();
+ label = bundle.getString("accountUsername");
+ this.createSummaryRow(label, this.username, rows);
+ if (!this.proto.noPassword) {
+ this.password = this.getValue("password");
+ if (this.password) {
+ label = document.getElementById("passwordLabel").value;
+ var pass = "";
+ for (let i = 0; i < this.password.length; ++i) {
+ pass += "*";
+ }
+ this.createSummaryRow(label, pass, rows);
+ }
+ }
+ this.alias = this.getValue("alias");
+ if (this.alias) {
+ label = document.getElementById("aliasLabel").value;
+ this.createSummaryRow(label, this.alias, rows);
+ }
+
+ var id = this.proto.id;
+ this.prefs = [];
+ for (let opt of this.proto.getOptions()) {
+ let name = opt.name;
+ let eltName = id + "-" + name;
+ let val = this.getValue(eltName);
+ // The value will be undefined if the proto specific groupbox has never been opened
+ if (val === undefined) {
+ continue;
+ }
+ switch (opt.type) {
+ case Ci.prplIPref.typeBool:
+ if (val != opt.getBool()) {
+ this.prefs.push({ opt, name, value: !!val });
+ }
+ break;
+ case Ci.prplIPref.typeInt:
+ if (val != opt.getInt()) {
+ this.prefs.push({ opt, name, value: val });
+ }
+ break;
+ case Ci.prplIPref.typeString:
+ if (val != opt.getString()) {
+ this.prefs.push({ opt, name, value: val });
+ }
+ break;
+ case Ci.prplIPref.typeList:
+ if (val != opt.getListDefault()) {
+ this.prefs.push({ opt, name, value: val });
+ }
+ break;
+ default:
+ throw new Error("unknown preference type " + opt.type);
+ }
+ }
+
+ for (let i = 0; i < this.prefs.length; ++i) {
+ let opt = this.prefs[i];
+ let label = bundle.getFormattedString("accountColon", [opt.opt.label]);
+ this.createSummaryRow(label, opt.value, rows);
+ }
+ },
+
+ createAccount() {
+ var acc = IMServices.accounts.createAccount(this.username, this.proto.id);
+ if (!this.proto.noPassword && this.password) {
+ acc.password = this.password;
+ }
+ if (this.alias) {
+ acc.alias = this.alias;
+ }
+
+ for (let i = 0; i < this.prefs.length; ++i) {
+ let option = this.prefs[i];
+ let opt = option.opt;
+ switch (opt.type) {
+ case Ci.prplIPref.typeBool:
+ acc.setBool(option.name, option.value);
+ break;
+ case Ci.prplIPref.typeInt:
+ acc.setInt(option.name, option.value);
+ break;
+ case Ci.prplIPref.typeString:
+ case Ci.prplIPref.typeList:
+ acc.setString(option.name, option.value);
+ break;
+ default:
+ throw new Error("unknown type");
+ }
+ }
+ var autologin = this.getValue("connectNow");
+ acc.autoLogin = autologin;
+
+ acc.save();
+
+ try {
+ if (autologin) {
+ acc.connect();
+ }
+ } catch (e) {
+ // If the connection fails (for example if we are currently in
+ // offline mode), we still want to close the account wizard
+ }
+
+ if (window.opener) {
+ var am = window.opener.gAccountManager;
+ if (am) {
+ am.selectAccount(acc.id);
+ }
+ }
+
+ var inServer = MailServices.accounts.createIncomingServer(
+ this.username,
+ this.proto.id, // hostname
+ "im"
+ );
+ inServer.wrappedJSObject.imAccount = acc;
+
+ var account = MailServices.accounts.createAccount();
+ // Avoid new folder notifications.
+ inServer.valid = false;
+ account.incomingServer = inServer;
+ inServer.valid = true;
+ MailServices.accounts.notifyServerLoaded(inServer);
+
+ return true;
+ },
+
+ getValue(aId) {
+ var elt = document.getElementById(aId);
+ if ("selectedItem" in elt) {
+ return elt.selectedItem.value;
+ }
+ // Strangely various input types also have a "checked" property defined,
+ // so we check for the expected elements explicitly.
+ if (
+ ((elt.localName == "input" && elt.getAttribute("type") == "checkbox") ||
+ elt.localName == "checkbox") &&
+ "checked" in elt
+ ) {
+ return elt.checked;
+ }
+ if ("value" in elt) {
+ return elt.value;
+ }
+ // If the groupbox has never been opened, the binding isn't attached
+ // so the attributes don't exist. The calling code in showSummary
+ // has a special handling of the undefined value for this case.
+ return undefined;
+ },
+
+ *getIter(aEnumerator) {
+ for (let iter of aEnumerator) {
+ yield iter;
+ }
+ },
+
+ /* Check for correctness and set URL for the "Get more protocols..."-link
+ * Stripped down code from preferences/themes.js
+ */
+ setGetMoreProtocols() {
+ let prefURL = PREF_EXTENSIONS_GETMOREPROTOCOLSURL;
+ var getMore = document.getElementById("getMoreProtocols");
+ var showGetMore = false;
+ const nsIPrefBranch = Ci.nsIPrefBranch;
+
+ if (Services.prefs.getPrefType(prefURL) != nsIPrefBranch.PREF_INVALID) {
+ try {
+ var getMoreURL = Services.urlFormatter.formatURLPref(prefURL);
+ getMore.setAttribute("getMoreURL", getMoreURL);
+ showGetMore = getMoreURL != "about:blank";
+ } catch (e) {}
+ }
+ getMore.hidden = !showGetMore;
+ },
+
+ openURL(aURL) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(aURL));
+ },
+};
+
+window.addEventListener("load", event => {
+ accountWizard.onload();
+});
diff --git a/comm/mail/components/im/content/imAccountWizard.xhtml b/comm/mail/components/im/content/imAccountWizard.xhtml
new file mode 100644
index 0000000000..9ff3cf33ad
--- /dev/null
+++ b/comm/mail/components/im/content/imAccountWizard.xhtml
@@ -0,0 +1,180 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/accountWizard.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % accountWizardDTD SYSTEM "chrome://messenger/locale/imAccountWizard.dtd">
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%accountWizardDTD; %brandDTD; ]>
+
+<html
+ id="accountWizard"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&windowTitle.label;</title>
+ <link rel="localization" href="toolkit/global/wizard.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://chat/content/imAccountOptionsHelper.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/imAccountWizard.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <stringbundle
+ id="accountsBundle"
+ src="chrome://messenger/locale/imAccounts.properties"
+ />
+
+ <wizard id="wizard">
+ <wizardpage
+ id="accountprotocol"
+ pageid="accountprotocol"
+ next="accountusername"
+ label="&accountProtocolTitle.label;"
+ >
+ <description>&accountProtocolInfo.label;</description>
+ <separator />
+ <label
+ value="&accountProtocolField.label;"
+ control="protolist"
+ id="protoLabel"
+ hidden="true"
+ />
+ <richlistbox
+ flex="1"
+ id="protolist"
+ ondblclick="document.getElementById('wizard').advance();"
+ />
+ <hbox pack="end">
+ <label
+ id="getMoreProtocols"
+ class="text-link"
+ value="&accountProtocolGetMore.label;"
+ onclick="if (event.button == 0) { accountWizard.openURL(this.getAttribute('getMoreURL')); }"
+ />
+ </hbox>
+ </wizardpage>
+
+ <wizardpage
+ id="accountusername"
+ pageid="accountusername"
+ next="accountpassword"
+ label="&accountUsernameTitle.label;"
+ >
+ <description id="usernameInfo" />
+ <separator />
+ <html:div
+ id="userNameBox"
+ class="grid-block-two-column-fr grid-items-center"
+ >
+ </html:div>
+ <separator />
+ <description id="duplicateAccount" hidden="true"
+ >&accountUsernameDuplicate.label;</description
+ >
+ </wizardpage>
+
+ <wizardpage
+ id="accountpassword"
+ pageid="accountpassword"
+ next="accountadvanced"
+ label="&accountPasswordTitle.label;"
+ >
+ <description>&accountPasswordInfo.label;</description>
+ <separator />
+ <hbox id="passwordBox" align="baseline" class="input-container">
+ <label
+ id="passwordLabel"
+ value="&accountPasswordField.label;"
+ class="label-inline"
+ control="password"
+ />
+ <html:input id="password" type="password" class="input-inline" />
+ </hbox>
+ <separator />
+ <description id="passwordManagerDescription"
+ >&accountPasswordManager.label;</description
+ >
+ </wizardpage>
+
+ <wizardpage
+ id="accountadvanced"
+ pageid="accountadvanced"
+ next="accountsummary"
+ label="&accountAdvancedTitle.label;"
+ >
+ <description>&accountAdvancedInfo.label;</description>
+ <separator class="thin" />
+ <html:fieldset id="aliasGroupbox">
+ <html:legend id="aliasGroupboxCaption"
+ >&accountAliasGroupbox.caption;</html:legend
+ >
+ <hbox id="aliasBox" align="baseline" class="input-container">
+ <label
+ id="aliasLabel"
+ value="&accountAliasField.label;"
+ class="label-inline"
+ control="alias"
+ />
+ <html:input id="alias" type="text" class="input-inline" />
+ </hbox>
+ <description>&accountAliasInfo.label;</description>
+ </html:fieldset>
+
+ <html:fieldset id="protoSpecificGroupbox">
+ <html:legend id="protoSpecificCaption"></html:legend>
+ <html:div
+ id="protoSpecific"
+ class="grid-block-two-column-fr grid-items-baseline"
+ >
+ </html:div>
+ </html:fieldset>
+ </wizardpage>
+
+ <wizardpage
+ id="accountsummary"
+ pageid="accountsummary"
+ label="&accountSummaryTitle.label;"
+ >
+ <description>&accountSummaryInfo.label;</description>
+ <separator />
+ <html:div
+ id="summaryRows"
+ class="grid-block-two-column-fr grid-items-baseline"
+ >
+ </html:div>
+ <separator />
+ <checkbox
+ id="connectNow"
+ label="&accountSummary.connectNow.label;"
+ checked="true"
+ />
+ </wizardpage>
+ </wizard>
+ </html:body>
+</html>
diff --git a/comm/mail/components/im/content/imAccounts.js b/comm/mail/components/im/content/imAccounts.js
new file mode 100644
index 0000000000..46bb72c197
--- /dev/null
+++ b/comm/mail/components/im/content/imAccounts.js
@@ -0,0 +1,663 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals MozElements */
+/* globals statusSelector */
+/* globals MsgAccountManager */
+
+var { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+// This is the list of notifications that the account manager window observes
+var events = [
+ "prpl-quit",
+ "account-list-updated",
+ "account-added",
+ "account-updated",
+ "account-removed",
+ "account-connected",
+ "account-connecting",
+ "account-disconnected",
+ "account-disconnecting",
+ "account-connect-progress",
+ "account-connect-error",
+ "autologin-processed",
+ "status-changed",
+ "network:offline-status-changed",
+];
+
+var gAccountManager = {
+ // Sets the delay after connect() or disconnect() during which
+ // it is impossible to perform disconnect() and connect()
+ _disabledDelay: 500,
+ disableTimerID: 0,
+ _connectedLabelInterval: 0,
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ document.getElementById("accounts-notification-box").prepend(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ load() {
+ // Wait until the password service is ready before offering anything.
+ Services.logins.initializationPromise.then(
+ () => {
+ this.accountList = document.getElementById("accountlist");
+ let defaultID;
+ IMServices.core.init(); // ensure the imCore is initialized.
+ for (let acc of this.getAccounts()) {
+ let elt = document.createXULElement("richlistitem", {
+ is: "chat-account-richlistitem",
+ });
+ this.accountList.appendChild(elt);
+ elt.build(acc);
+ if (
+ !defaultID &&
+ acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED
+ ) {
+ defaultID = acc.id;
+ }
+ }
+ for (let event of events) {
+ Services.obs.addObserver(this, event);
+ }
+ if (!this.accountList.getRowCount()) {
+ // This is horrible, but it works. Otherwise (at least on mac)
+ // the wizard is not centered relatively to the account manager
+ setTimeout(function () {
+ gAccountManager.new();
+ }, 0);
+ } else {
+ // we have accounts, show the list
+ document.getElementById("noAccountScreen").hidden = true;
+ document.getElementById("accounts-notification-box").hidden = false;
+
+ // ensure an account is selected
+ if (defaultID) {
+ this.selectAccount(defaultID);
+ } else {
+ this.accountList.selectedIndex = 0;
+ }
+ }
+
+ this.setAutoLoginNotification();
+
+ this.accountList.addEventListener("keypress", this.onKeyPress, true);
+ window.addEventListener("unload", this.unload.bind(this));
+ this._connectedLabelInterval = setInterval(
+ this.updateConnectedLabels,
+ 60000
+ );
+ statusSelector.init();
+ },
+ () => {
+ this.close();
+ }
+ );
+ },
+ unload() {
+ clearInterval(this._connectedLabelInterval);
+ for (let event of events) {
+ Services.obs.removeObserver(this, event);
+ }
+ },
+ _updateAccountList() {
+ let accountList = this.accountList;
+ let i = 0;
+ for (let acc of this.getAccounts()) {
+ let oldItem = accountList.getItemAtIndex(i);
+ if (oldItem.id != acc.id) {
+ let accElt = document.getElementById(acc.id);
+ accountList.insertBefore(accElt, oldItem);
+ accElt.refreshState();
+ }
+ ++i;
+ }
+
+ if (accountList.itemCount == 0) {
+ // Focus the "New Account" button if there are no accounts left.
+ document.getElementById("newaccount").focus();
+ // Return early, otherwise we'll run into an 'undefined property' strict
+ // warning when trying to focus the buttons. Fixes bug 408.
+ return;
+ }
+
+ // The selected item is still selected
+ if (accountList.selectedItem) {
+ accountList.selectedItem.setFocus();
+ }
+ accountList.ensureSelectedElementIsVisible();
+
+ // We need to refresh the disabled menu items
+ this.disableCommandItems();
+ },
+ observe(aObject, aTopic, aData) {
+ if (aTopic == "prpl-quit") {
+ // libpurple is being uninitialized. We don't need the account
+ // manager window anymore, close it.
+ this.close();
+ return;
+ } else if (aTopic == "autologin-processed") {
+ let notification =
+ this.msgNotificationBar.getNotificationWithValue("autoLoginStatus");
+ if (notification) {
+ notification.close();
+ }
+ return;
+ } else if (aTopic == "network:offline-status-changed") {
+ this.setOffline(aData == "offline");
+ return;
+ } else if (aTopic == "status-changed") {
+ this.setOffline(aObject.statusType == Ci.imIStatusInfo.STATUS_OFFLINE);
+ return;
+ } else if (aTopic == "account-list-updated") {
+ this._updateAccountList();
+ return;
+ }
+
+ // The following notification handlers need an account.
+ let account = aObject.QueryInterface(Ci.imIAccount);
+
+ if (aTopic == "account-added") {
+ document.getElementById("noAccountScreen").hidden = true;
+ document.getElementById("accounts-notification-box").hidden = false;
+ let elt = document.createXULElement("richlistitem", {
+ is: "chat-account-richlistitem",
+ });
+ this.accountList.appendChild(elt);
+ elt.build(account);
+ if (this.accountList.getRowCount() == 1) {
+ this.accountList.selectedIndex = 0;
+ }
+ } else if (aTopic == "account-removed") {
+ let elt = document.getElementById(account.id);
+ elt.destroy();
+ if (!elt.selected) {
+ elt.remove();
+ return;
+ }
+ // The currently selected element is removed,
+ // ensure another element gets selected (if the list is not empty)
+ var selectedIndex = this.accountList.selectedIndex;
+ // Prevent errors if the timer is active and the account deleted
+ clearTimeout(this.disableTimerID);
+ this.disableTimerID = 0;
+ elt.remove();
+ var count = this.accountList.getRowCount();
+ if (!count) {
+ document.getElementById("noAccountScreen").hidden = false;
+ document.getElementById("accounts-notification-box").hidden = true;
+ return;
+ }
+ if (selectedIndex == count) {
+ --selectedIndex;
+ }
+ this.accountList.selectedIndex = selectedIndex;
+ } else if (aTopic == "account-updated") {
+ document.getElementById(account.id).build(account);
+ this.disableCommandItems();
+ } else if (aTopic == "account-connect-progress") {
+ document.getElementById(account.id).updateConnectingProgress();
+ } else if (aTopic == "account-connect-error") {
+ document.getElementById(account.id).updateConnectionError();
+ // See NSSErrorsService::ErrorIsOverridable.
+ if (
+ [
+ "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED",
+ "MOZILLA_PKIX_ERROR_CA_CERT_USED_AS_END_ENTITY",
+ "MOZILLA_PKIX_ERROR_EMPTY_ISSUER_NAME",
+ "MOZILLA_PKIX_ERROR_INADEQUATE_KEY_SIZE",
+ "MOZILLA_PKIX_ERROR_MITM_DETECTED",
+ "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE",
+ "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE",
+ "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT",
+ "MOZILLA_PKIX_ERROR_V1_CERT_USED_AS_CA",
+ "SEC_ERROR_CA_CERT_INVALID",
+ "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED",
+ "SEC_ERROR_EXPIRED_CERTIFICATE",
+ "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE",
+ "SEC_ERROR_INVALID_TIME",
+ "SEC_ERROR_UNKNOWN_ISSUER",
+ "SSL_ERROR_BAD_CERT_DOMAIN",
+ ].includes(account.prplAccount.securityInfo?.errorCodeString)
+ ) {
+ this.addException();
+ }
+ } else {
+ const stateEvents = {
+ "account-connected": "connected",
+ "account-connecting": "connecting",
+ "account-disconnected": "disconnected",
+ "account-disconnecting": "disconnecting",
+ };
+ if (aTopic in stateEvents) {
+ let elt = document.getElementById(account.id);
+ if (!elt) {
+ // Probably disconnecting a removed account.
+ return;
+ }
+ elt.refreshState(stateEvents[aTopic]);
+ }
+ }
+ },
+ cancelReconnection() {
+ this.accountList.selectedItem.cancelReconnection();
+ },
+ connect() {
+ let account = this.accountList.selectedItem.account;
+ if (account.disconnected) {
+ this.temporarilyDisableButtons();
+ account.connect();
+ }
+ },
+ disconnect() {
+ let account = this.accountList.selectedItem.account;
+ if (account.connected || account.connecting) {
+ this.temporarilyDisableButtons();
+ account.disconnect();
+ }
+ },
+ addException() {
+ let account = this.accountList.selectedItem.account;
+ let prplAccount = account.prplAccount;
+ if (!prplAccount.connectionTarget) {
+ return;
+ }
+
+ // Open the Gecko SSL exception dialog.
+ let params = {
+ exceptionAdded: false,
+ securityInfo: prplAccount.securityInfo,
+ prefetchCert: true,
+ location: prplAccount.connectionTarget,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ // Reconnect the account if an exception was added.
+ if (params.exceptionAdded) {
+ account.disconnect();
+ account.connect();
+ }
+ },
+ copyDebugLog() {
+ let account = this.accountList.selectedItem.account;
+ let text = account
+ .getDebugMessages()
+ .map(function (dbgMsg) {
+ let m = dbgMsg.message;
+ let time = new Date(m.timeStamp);
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "long",
+ });
+ time = dateTimeFormatter.format(time);
+ let level = dbgMsg.logLevel;
+ if (!level) {
+ return "(" + m.errorMessage + ")";
+ }
+ if (level == dbgMsg.LEVEL_ERROR) {
+ level = "ERROR";
+ } else if (level == dbgMsg.LEVEL_WARNING) {
+ level = "WARN.";
+ } else if (level == dbgMsg.LEVEL_LOG) {
+ level = "LOG ";
+ } else {
+ level = "DEBUG";
+ }
+ return (
+ "[" +
+ time +
+ "] " +
+ level +
+ " (@ " +
+ m.sourceLine +
+ " " +
+ m.sourceName +
+ ":" +
+ m.lineNumber +
+ ")\n" +
+ m.errorMessage
+ );
+ })
+ .join("\n");
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(text);
+ },
+ updateConnectedLabels() {
+ for (let i = 0; i < gAccountManager.accountList.itemCount; ++i) {
+ let item = gAccountManager.accountList.getItemAtIndex(i);
+ if (item.account.connected) {
+ item.refreshConnectedLabel();
+ }
+ }
+ },
+ /* This function disables the connect/disconnect buttons for
+ * `this._disabledDelay` ms before calling disableCommandItems to restore
+ * the state of the buttons.
+ */
+ temporarilyDisableButtons() {
+ document.getElementById("cmd_disconnect").setAttribute("disabled", "true");
+ document.getElementById("cmd_connect").setAttribute("disabled", "true");
+ clearTimeout(this.disableTimerID);
+ this.accountList.focus();
+ this.disableTimerID = setTimeout(
+ function (aItem) {
+ gAccountManager.disableTimerID = 0;
+ gAccountManager.disableCommandItems();
+ aItem.setFocus();
+ },
+ this._disabledDelay,
+ this.accountList.selectedItem
+ );
+ },
+
+ new() {
+ this.openDialog("chrome://messenger/content/chat/imAccountWizard.xhtml");
+ },
+ edit() {
+ // Find the nsIIncomingServer for the current imIAccount.
+ let server = null;
+ let imAccountId = this.accountList.selectedItem.account.numericId;
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ if (incomingServer.wrappedJSObject.imAccount.numericId == imAccountId) {
+ server = incomingServer;
+ break;
+ }
+ }
+
+ MsgAccountManager(null, server);
+ },
+ autologin() {
+ var elt = this.accountList.selectedItem;
+ elt.autoLogin = !elt.autoLogin;
+ },
+ close() {
+ // If a modal dialog is opened, we can't close this window now
+ if (this.modalDialog) {
+ setTimeout(function () {
+ window.close();
+ }, 0);
+ } else {
+ window.close();
+ }
+ },
+
+ /* This function disables or enables the currently selected button and
+ the corresponding context menu item */
+ disableCommandItems() {
+ let accountList = this.accountList;
+ let selectedItem = accountList.selectedItem;
+ // When opening the account manager, if accounts have errors, we
+ // can be called during build(), before any item is selected.
+ // In this case, just return early.
+ if (!selectedItem) {
+ return;
+ }
+
+ // If the timer that disables the button (for a short time) already exists,
+ // we don't want to interfere and set the button as enabled.
+ if (this.disableTimerID) {
+ return;
+ }
+
+ let account = selectedItem.account;
+ let isCommandDisabled =
+ this.isOffline ||
+ (account.disconnected &&
+ account.connectionErrorReason == Ci.imIAccount.ERROR_UNKNOWN_PRPL);
+
+ let disabledItems = ["connect", "disconnect"];
+ for (let name of disabledItems) {
+ let elt = document.getElementById("cmd_" + name);
+ if (isCommandDisabled) {
+ elt.setAttribute("disabled", "true");
+ } else {
+ elt.removeAttribute("disabled");
+ }
+ }
+ },
+ onContextMenuShowing(event) {
+ let targetElt = event.target.triggerNode.closest(
+ 'richlistitem[is="chat-account-richlistitem"]'
+ );
+ document.querySelectorAll(".im-context-account-item").forEach(e => {
+ e.hidden = !targetElt;
+ });
+ if (targetElt) {
+ let account = targetElt.account;
+ let hiddenItems = {
+ connect: !account.disconnected,
+ disconnect: account.disconnected || account.disconnecting,
+ cancelReconnection: !targetElt.hasAttribute("reconnectPending"),
+ accountsItemsSeparator: account.disconnecting,
+ };
+ for (let name in hiddenItems) {
+ document.getElementById("context_" + name).hidden = hiddenItems[name];
+ }
+ }
+ },
+
+ selectAccount(aAccountId) {
+ this.accountList.selectedItem = document.getElementById(aAccountId);
+ this.accountList.ensureSelectedElementIsVisible();
+ },
+ onAccountSelect() {
+ clearTimeout(this.disableTimerID);
+ this.disableTimerID = 0;
+ this.disableCommandItems();
+ // Horrible hack here too, see Bug 177
+ setTimeout(
+ function (aThis) {
+ try {
+ aThis.accountList.selectedItem.setFocus();
+ } catch (e) {
+ /* Sometimes if the user goes too fast with VK_UP or VK_DOWN, the
+ selectedItem doesn't have the expected binding attached */
+ }
+ },
+ 0,
+ this
+ );
+ },
+
+ onKeyPress(event) {
+ if (!this.selectedItem) {
+ return;
+ }
+ // As we stop propagation, the default action applies to the richlistbox
+ // so that the selected account is changed with this default action
+ if (event.keyCode == event.DOM_VK_DOWN) {
+ if (this.selectedIndex < this.itemCount - 1) {
+ this.ensureIndexIsVisible(this.selectedIndex + 1);
+ }
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.keyCode == event.DOM_VK_UP) {
+ if (this.selectedIndex > 0) {
+ this.ensureIndexIsVisible(this.selectedIndex - 1);
+ }
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ let target = event.target;
+ if (
+ target.localName != "checkbox" &&
+ (target.localName != "button" ||
+ /^(dis)?connect$/.test(target.getAttribute("anonid")))
+ ) {
+ this.selectedItem.buttons.proceedDefaultAction();
+ }
+ }
+ },
+
+ *getAccounts() {
+ for (let account of IMServices.accounts.getAccounts()) {
+ yield account;
+ }
+ },
+
+ openDialog(aUrl, aArgs) {
+ this.modalDialog = true;
+ window.openDialog(aUrl, "", "chrome,modal,titlebar,centerscreen", aArgs);
+ this.modalDialog = false;
+ },
+
+ setAutoLoginNotification() {
+ var as = IMServices.accounts;
+ var autoLoginStatus = as.autoLoginStatus;
+ let isOffline = false;
+ let crashCount = 0;
+ for (let acc of this.getAccounts()) {
+ if (
+ acc.autoLogin &&
+ acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED
+ ) {
+ ++crashCount;
+ }
+ }
+
+ if (autoLoginStatus == as.AUTOLOGIN_ENABLED && crashCount == 0) {
+ let status = IMServices.core.globalUserStatus.statusType;
+ this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE);
+ return;
+ }
+
+ var bundle = document.getElementById("accountsBundle");
+ let box = this.msgNotificationBar;
+ var prio = box.PRIORITY_INFO_HIGH;
+ var connectNowButton = {
+ accessKey: bundle.getString(
+ "accountsManager.notification.button.accessKey"
+ ),
+ callback: this.processAutoLogin,
+ label: bundle.getString("accountsManager.notification.button.label"),
+ };
+ var barLabel;
+
+ switch (autoLoginStatus) {
+ case as.AUTOLOGIN_USER_DISABLED:
+ barLabel = bundle.getString(
+ "accountsManager.notification.userDisabled.label"
+ );
+ break;
+
+ case as.AUTOLOGIN_SAFE_MODE:
+ barLabel = bundle.getString(
+ "accountsManager.notification.safeMode.label"
+ );
+ break;
+
+ case as.AUTOLOGIN_START_OFFLINE:
+ barLabel = bundle.getString(
+ "accountsManager.notification.startOffline.label"
+ );
+ isOffline = true;
+ break;
+
+ case as.AUTOLOGIN_CRASH:
+ barLabel = bundle.getString("accountsManager.notification.crash.label");
+ prio = box.PRIORITY_WARNING_MEDIUM;
+ break;
+
+ /* One or more accounts made the application crash during their connection.
+ If none, this function has already returned */
+ case as.AUTOLOGIN_ENABLED:
+ barLabel = bundle.getString(
+ "accountsManager.notification.singleCrash.label"
+ );
+ barLabel = PluralForm.get(crashCount, barLabel).replace(
+ "#1",
+ crashCount
+ );
+ prio = box.PRIORITY_WARNING_MEDIUM;
+ connectNowButton.callback = this.processCrashedAccountsLogin;
+ break;
+
+ default:
+ barLabel = bundle.getString("accountsManager.notification.other.label");
+ }
+ let status = IMServices.core.globalUserStatus.statusType;
+ this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE);
+
+ box.appendNotification(
+ "autologinStatus",
+ {
+ label: barLabel,
+ priority: prio,
+ },
+ [connectNowButton]
+ );
+ },
+ processAutoLogin() {
+ var ioService = Services.io;
+ if (ioService.offline) {
+ ioService.manageOfflineStatus = false;
+ ioService.offline = false;
+ }
+
+ IMServices.accounts.processAutoLogin();
+
+ gAccountManager.accountList.selectedItem.setFocus();
+ },
+ processCrashedAccountsLogin() {
+ for (let acc in gAccountManager.getAccounts()) {
+ if (
+ acc.disconnected &&
+ acc.autoLogin &&
+ acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED
+ ) {
+ acc.connect();
+ }
+ }
+
+ let notification =
+ this.msgNotificationBar.getNotificationWithValue("autoLoginStatus");
+ if (notification) {
+ notification.close();
+ }
+
+ gAccountManager.accountList.selectedItem.setFocus();
+ },
+ setOffline(aState) {
+ this.isOffline = aState;
+ if (aState) {
+ this.accountList.setAttribute("offline", "true");
+ } else {
+ this.accountList.removeAttribute("offline");
+ }
+ this.disableCommandItems();
+ },
+};
+
+window.addEventListener("DOMContentLoaded", () => {
+ gAccountManager.load();
+});
diff --git a/comm/mail/components/im/content/imAccounts.xhtml b/comm/mail/components/im/content/imAccounts.xhtml
new file mode 100644
index 0000000000..d123521be1
--- /dev/null
+++ b/comm/mail/components/im/content/imAccounts.xhtml
@@ -0,0 +1,250 @@
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/imRichlistbox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/imAccounts.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % accountsDTD SYSTEM "chrome://chat/locale/accounts.dtd">
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd">
+%accountsDTD; %brandDTD; %chatDTD; ]>
+
+<html
+ id="accountManager"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Messenger:Accounts"
+ scrolling="false"
+ lightweightthemes="true"
+ persist="width height screenX screenY"
+>
+ <head>
+ <title>&accountsWindow.title;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/imAccounts.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountUtils.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/imStatusSelector.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://chat/content/chat-account-richlistitem.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <stringbundle
+ id="accountsBundle"
+ src="chrome://messenger/locale/imAccounts.properties"
+ />
+
+ <commandset id="accountsCommands">
+ <command
+ id="cmd_connect"
+ accesskey="&account.connect.accesskey;"
+ label="&account.connect.label;"
+ oncommand="gAccountManager.connect()"
+ />
+ <command
+ id="cmd_disconnect"
+ label="&account.disconnect.label;"
+ accesskey="&account.disconnect.accesskey;"
+ oncommand="gAccountManager.disconnect()"
+ />
+ <command
+ id="cmd_cancelReconnection"
+ label="&account.cancelReconnection.label;"
+ accesskey="&account.cancelReconnection.accesskey;"
+ oncommand="gAccountManager.cancelReconnection()"
+ />
+ <command
+ id="cmd_copyDebugLog"
+ label="&account.copyDebugLog.label;"
+ accesskey="&account.copyDebugLog.accesskey;"
+ oncommand="gAccountManager.copyDebugLog();"
+ />
+ <command
+ id="cmd_edit"
+ label="&account.edit.label;"
+ accesskey="&account.edit.accesskey;"
+ oncommand="gAccountManager.edit()"
+ />
+ <command
+ id="cmd_new"
+ label="&accountManager.newAccount.label;"
+ accesskey="&accountManager.newAccount.accesskey;"
+ oncommand="gAccountManager.new()"
+ />
+ <command
+ id="cmd_close"
+ label="&accountManager.close.label;"
+ accesskey="&accountManager.close.accesskey;"
+ oncommand="gAccountManager.close()"
+ />
+ </commandset>
+
+ <keyset id="accountsKeys">
+ <key id="key_close1" key="w" modifiers="accel" command="cmd_close" />
+ <key id="key_close2" keycode="VK_ESCAPE" command="cmd_close" />
+ <key
+ id="key_close3"
+ command="cmd_close"
+ key="&accountManager.close.commandkey;"
+ modifiers="accel,shift"
+ />
+ </keyset>
+
+ <menupopup
+ id="accountsContextMenu"
+ onpopupshowing="gAccountManager.onContextMenuShowing(event)"
+ >
+ <menuitem
+ id="context_connect"
+ command="cmd_connect"
+ class="im-context-account-item"
+ />
+ <menuitem
+ id="context_disconnect"
+ command="cmd_disconnect"
+ class="im-context-account-item"
+ />
+ <menuitem
+ id="context_cancelReconnection"
+ command="cmd_cancelReconnection"
+ class="im-context-account-item"
+ />
+ <menuitem id="context_copyDebugLog" command="cmd_copyDebugLog" />
+ <menuseparator
+ id="context_accountsItemsSeparator"
+ class="im-context-account-item"
+ />
+ <menuitem command="cmd_new" />
+ <menuseparator class="im-context-account-item" />
+ <menuitem command="cmd_edit" class="im-context-account-item" />
+ </menupopup>
+
+ <html:div class="displayUserAccount">
+ <stack id="statusImageStack">
+ <html:img
+ id="userIcon"
+ class="userIcon"
+ alt=""
+ onclick="statusSelector.userIconClick();"
+ />
+ <button
+ type="menu"
+ id="statusTypeIcon"
+ class="statusTypeIcon"
+ status="available"
+ >
+ <menupopup
+ id="setStatusTypeMenupopup"
+ oncommand="statusSelector.editStatus(event);"
+ >
+ <menuitem
+ id="statusTypeAvailable"
+ label="&status.available;"
+ status="available"
+ class="menuitem-iconic"
+ />
+ <menuitem
+ id="statusTypeUnavailable"
+ label="&status.unavailable;"
+ status="unavailable"
+ class="menuitem-iconic"
+ />
+ <menuseparator id="statusTypeOfflineSeparator" />
+ <menuitem
+ id="statusTypeOffline"
+ label="&status.offline;"
+ status="offline"
+ class="menuitem-iconic"
+ />
+ </menupopup>
+ </button>
+ </stack>
+ <html:div id="displayNameAndstatusMessageGrid">
+ <label
+ id="displayName"
+ onclick="statusSelector.displayNameClick();"
+ align="center"
+ pack="center"
+ />
+ <!-- FIXME: A keyboard user cannot focus the hidden input, nor click
+ - the above label in order to reveal it. -->
+ <html:input
+ id="displayNameInput"
+ class="statusMessageInput input-inline"
+ hidden="hidden"
+ />
+ <html:hr />
+ <label
+ id="statusMessageLabel"
+ crop="end"
+ value=""
+ onclick="statusSelector.statusMessageClick();"
+ />
+ <html:input
+ id="statusMessageInput"
+ class="statusMessageInput input-inline"
+ value=""
+ hidden="hidden"
+ />
+ </html:div>
+ </html:div>
+
+ <hbox flex="1" ondblclick="gAccountManager.new();">
+ <vbox flex="1" id="noAccountScreen" align="center" pack="center">
+ <hbox id="noAccountBox" align="start">
+ <vbox id="noAccountInnerBox" flex="1">
+ <label
+ id="noAccountTitle"
+ value="&accountManager.noAccount.title;"
+ />
+ <description id="noAccountDesc"
+ >&accountManager.noAccount.description;</description
+ >
+ </vbox>
+ </hbox>
+ </vbox>
+
+ <vbox id="accounts-notification-box" flex="1">
+ <!-- notificationbox will be added here lazily. -->
+ <richlistbox
+ id="accountlist"
+ flex="1"
+ context="accountsContextMenu"
+ onselect="gAccountManager.onAccountSelect();"
+ />
+ </vbox>
+ </hbox>
+
+ <hbox id="bottombuttons" align="center">
+ <button id="newaccount" command="cmd_new" />
+ <spacer flex="1" />
+ <button id="close" command="cmd_close" />
+ </hbox>
+ </html:body>
+</html>
diff --git a/comm/mail/components/im/content/imContextMenu.js b/comm/mail/components/im/content/imContextMenu.js
new file mode 100644
index 0000000000..0d9ecf0763
--- /dev/null
+++ b/comm/mail/components/im/content/imContextMenu.js
@@ -0,0 +1,276 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file is loaded in messenger.xhtml.
+/* globals gatherTextUnder, goUpdateGlobalEditMenuItems, makeURLAbsolute, Services */
+/* import-globals-from ../../../base/content/widgets/browserPopups.js */
+
+var gChatContextMenu = null;
+
+function imContextMenu(aXulMenu) {
+ this.target = null;
+ this.menu = null;
+ this.onLink = false;
+ this.onMailtoLink = false;
+ this.onSaveableLink = false;
+ this.link = false;
+ this.linkURL = "";
+ this.linkURI = null;
+ this.linkProtocol = null;
+ this.isTextSelected = false;
+ this.isContentSelected = false;
+ this.shouldDisplay = true;
+ this.ellipsis = "\u2026";
+ this.initedActions = false;
+
+ try {
+ this.ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+
+ // Initialize new menu.
+ this.initMenu(aXulMenu);
+}
+
+// Prototype for nsContextMenu "class."
+imContextMenu.prototype = {
+ cleanup() {
+ nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal
+ ?.getActor("ChatAction")
+ .reportHide();
+ let elt = document.getElementById(
+ "context-sep-messageactions"
+ ).nextElementSibling;
+ // remove the action menuitems added last time we opened the popup
+ while (elt && elt.localName != "menuseparator") {
+ let tmp = elt.nextElementSibling;
+ elt.remove();
+ elt = tmp;
+ }
+ },
+
+ /**
+ * Initialize context menu. Shows/hides relevant items. Message actions are
+ * handled separately in |initActions| if the actor gets them after this is
+ * called.
+ *
+ * @param {XULMenuPopupElement} aPopup - The popup to initialize on.
+ */
+ initMenu(aPopup) {
+ this.menu = aPopup;
+
+ // Get contextual info.
+ this.setTarget();
+
+ this.isTextSelected = this.isTextSelection();
+ this.isContentSelected = this.isContentSelection();
+
+ // Initialize (disable/remove) menu items.
+ // Open/Save/Send link depends on whether we're in a link.
+ var shouldShow = this.onSaveableLink;
+ this.showItem("context-openlink", shouldShow);
+ this.showItem("context-sep-open", shouldShow);
+ this.showItem("context-savelink", shouldShow);
+
+ // Copy depends on whether there is selected text.
+ // Enabling this context menu item is now done through the global
+ // command updating system
+ goUpdateGlobalEditMenuItems();
+
+ this.showItem("context-copy", this.isContentSelected);
+ this.showItem("context-selectall", !this.onLink || this.isContentSelected);
+ if (!this.initedActions) {
+ let actor =
+ nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal?.getActor(
+ "ChatAction"
+ );
+ if (actor?.actions) {
+ this.initActions(actor.actions);
+ } else {
+ this.showItem("context-sep-messageactions", false);
+ }
+ }
+
+ // Copy email link depends on whether we're on an email link.
+ this.showItem("context-copyemail", this.onMailtoLink);
+
+ // Copy link location depends on whether we're on a non-mailto link.
+ this.showItem("context-copylink", this.onLink && !this.onMailtoLink);
+ this.showItem(
+ "context-sep-copylink",
+ this.onLink && this.isContentSelected
+ );
+ },
+
+ /**
+ * Adds the given message actions to the context menu.
+ *
+ * @param {Array<string>} actions - Array containing the labels for the
+ * available actions.
+ */
+ initActions(actions) {
+ this.showItem("context-sep-messageactions", actions.length > 0);
+
+ // Display action menu items.
+ let sep = document.getElementById("context-sep-messageactions");
+ for (let [index, label] of actions.entries()) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", label);
+ menuitem.addEventListener("command", () => {
+ nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal
+ ?.getActor("ChatAction")
+ .sendAsyncMessage("ChatAction:Run", { index });
+ });
+ sep.parentNode.appendChild(menuitem);
+ }
+ this.initedActions = true;
+ },
+
+ // Set various context menu attributes based on the state of the world.
+ setTarget() {
+ // Initialize contextual info.
+ this.onLink = nsContextMenu.contentData.context.onLink;
+ this.linkURL = nsContextMenu.contentData.context.linkURL;
+ this.linkURI = this.getLinkURI();
+ this.linkProtocol = nsContextMenu.contentData.context.linkProtocol;
+ this.linkText = nsContextMenu.contentData.context.linkTextStr;
+ this.onMailtoLink = nsContextMenu.contentData.context.onMailtoLink;
+ this.onSaveableLink = nsContextMenu.contentData.context.onSaveableLink;
+ },
+
+ // Open linked-to URL in a new window.
+ openLink(aURI) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(aURI || this.linkURI, nsContextMenu.contentData.principal);
+ },
+
+ // Generate email address and put it on clipboard.
+ copyEmail() {
+ // Copy the comma-separated list of email addresses only.
+ // There are other ways of embedding email addresses in a mailto:
+ // link, but such complex parsing is beyond us.
+ var url = this.linkURL;
+ var qmark = url.indexOf("?");
+ var addresses;
+
+ // 7 == length of "mailto:"
+ addresses = qmark > 7 ? url.substring(7, qmark) : url.substr(7);
+
+ // Let's try to unescape it using a character set
+ // in case the address is not ASCII.
+ try {
+ var characterSet = this.target.ownerDocument.characterSet;
+ addresses = Services.textToSubURI.unEscapeURIForUI(
+ characterSet,
+ addresses
+ );
+ } catch (ex) {
+ // Do nothing.
+ }
+
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(addresses);
+ },
+
+ // ---------
+ // Utilities
+
+ // Show/hide one item (specified via name or the item element itself).
+ showItem(aItemOrId, aShow) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ if (item) {
+ item.hidden = !aShow;
+ }
+ },
+
+ // Temporary workaround for DOM api not yet implemented by XUL nodes.
+ cloneNode(aItem) {
+ // Create another element like the one we're cloning.
+ var node = document.createXULElement(aItem.tagName);
+
+ // Copy attributes from argument item to the new one.
+ var attrs = aItem.attributes;
+ for (var i = 0; i < attrs.length; i++) {
+ var attr = attrs.item(i);
+ node.setAttribute(attr.nodeName, attr.nodeValue);
+ }
+
+ // Voila!
+ return node;
+ },
+
+ getLinkURI() {
+ try {
+ return Services.io.newURI(this.linkURL);
+ } catch (ex) {
+ // e.g. empty URL string
+ }
+
+ return null;
+ },
+
+ // Get selected text. Only display the first 15 chars.
+ isTextSelection() {
+ // Get 16 characters, so that we can trim the selection if it's greater
+ // than 15 chars
+ var selectedText = getBrowserSelection(16);
+
+ if (!selectedText) {
+ return false;
+ }
+
+ if (selectedText.length > 15) {
+ selectedText = selectedText.substr(0, 15) + this.ellipsis;
+ }
+
+ return true;
+ },
+
+ // Returns true if anything is selected.
+ isContentSelection() {
+ return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed;
+ },
+};
+
+/**
+ * Gets the selected text in the active browser. Leading and trailing
+ * whitespace is removed, and consecutive whitespace is replaced by a single
+ * space. A maximum of 150 characters will be returned, regardless of the value
+ * of aCharLen.
+ *
+ * @param aCharLen
+ * The maximum number of characters to return.
+ */
+function getBrowserSelection(aCharLen) {
+ // selections of more than 150 characters aren't useful
+ const kMaxSelectionLen = 150;
+ const charLen = Math.min(aCharLen || kMaxSelectionLen, kMaxSelectionLen);
+
+ var focusedWindow = document.commandDispatcher.focusedWindow;
+ var selection = focusedWindow.getSelection().toString();
+
+ if (selection) {
+ if (selection.length > charLen) {
+ // only use the first charLen important chars. see bug 221361
+ var pattern = new RegExp("^(?:\\s*.){0," + charLen + "}");
+ pattern.test(selection);
+ selection = RegExp.lastMatch;
+ }
+
+ selection = selection.trim().replace(/\s+/g, " ");
+
+ if (selection.length > charLen) {
+ selection = selection.substr(0, charLen);
+ }
+ }
+ return selection;
+}
diff --git a/comm/mail/components/im/content/imStatusSelector.js b/comm/mail/components/im/content/imStatusSelector.js
new file mode 100644
index 0000000000..69bbc2776a
--- /dev/null
+++ b/comm/mail/components/im/content/imStatusSelector.js
@@ -0,0 +1,383 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { Status } = ChromeUtils.importESModule(
+ "resource:///modules/imStatusUtils.sys.mjs"
+);
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var statusSelector = {
+ observe(aSubject, aTopic, aMsg) {
+ if (aTopic == "status-changed") {
+ this.displayCurrentStatus();
+ } else if (aTopic == "user-icon-changed") {
+ this.displayUserIcon();
+ } else if (aTopic == "user-display-name-changed") {
+ this.displayUserDisplayName();
+ }
+ },
+
+ displayUserIcon() {
+ let icon = IMServices.core.globalUserStatus.getUserIcon();
+ ChatIcons.setUserIconSrc(
+ document.getElementById("userIcon"),
+ icon?.spec,
+ true
+ );
+ },
+
+ displayUserDisplayName() {
+ let displayName = IMServices.core.globalUserStatus.displayName;
+ let elt = document.getElementById("displayName");
+ if (displayName) {
+ elt.removeAttribute("usingDefault");
+ } else {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ displayName = bundle.GetStringFromName("displayNameEmptyText");
+ elt.setAttribute("usingDefault", displayName);
+ }
+ elt.setAttribute("value", displayName);
+ },
+
+ displayStatusType(aStatusType) {
+ document
+ .getElementById("statusMessageLabel")
+ .setAttribute("statusType", aStatusType);
+ let statusString = Status.toLabel(aStatusType);
+ let statusTypeIcon = document.getElementById("statusTypeIcon");
+ statusTypeIcon.setAttribute("status", aStatusType);
+ statusTypeIcon.setAttribute("tooltiptext", statusString);
+ return statusString;
+ },
+
+ displayCurrentStatus() {
+ let us = IMServices.core.globalUserStatus;
+ let status = Status.toAttribute(us.statusType);
+ let message = status == "offline" ? "" : us.statusText;
+ let statusMessage = document.getElementById("statusMessageLabel");
+ if (!statusMessage) {
+ // Chat toolbar not in the DOM yet
+ return;
+ }
+ if (message) {
+ statusMessage.removeAttribute("usingDefault");
+ } else {
+ let statusString = this.displayStatusType(status);
+ statusMessage.setAttribute("usingDefault", statusString);
+ message = statusString;
+ }
+ statusMessage.setAttribute("value", message);
+ statusMessage.setAttribute("tooltiptext", message);
+ },
+
+ editStatus(aEvent) {
+ let status = aEvent.target.getAttribute("status");
+ if (status == "offline") {
+ IMServices.core.globalUserStatus.setStatus(
+ Ci.imIStatusInfo.STATUS_OFFLINE,
+ ""
+ );
+ } else if (status) {
+ this.startEditStatus(status);
+ }
+ },
+
+ startEditStatus(aStatusType) {
+ let currentStatusType = document
+ .getElementById("statusTypeIcon")
+ .getAttribute("status");
+ if (aStatusType != currentStatusType) {
+ this._statusTypeBeforeEditing = currentStatusType;
+ this._statusTypeEditing = aStatusType;
+ this.displayStatusType(aStatusType);
+ }
+ this.statusMessageClick();
+ },
+
+ statusMessageClick() {
+ let statusMessage = document.getElementById("statusMessageLabel");
+ let statusMessageInput = document.getElementById("statusMessageInput");
+ statusMessage.setAttribute("hidden", "true");
+ statusMessageInput.removeAttribute("hidden");
+ let statusType = document
+ .getElementById("statusTypeIcon")
+ .getAttribute("status");
+ if (statusType == "offline" || statusMessage.disabled) {
+ return;
+ }
+
+ if (!statusMessageInput.hasAttribute("editing")) {
+ statusMessageInput.setAttribute("editing", "true");
+ statusMessageInput.addEventListener("blur", event => {
+ this.finishEditStatusMessage(true);
+ });
+ if (statusMessage.hasAttribute("usingDefault")) {
+ if (
+ "_statusTypeBeforeEditing" in this &&
+ this._statusTypeBeforeEditing == "offline"
+ ) {
+ statusMessageInput.setAttribute(
+ "value",
+ IMServices.core.globalUserStatus.statusText
+ );
+ } else {
+ statusMessageInput.removeAttribute("value");
+ }
+ } else {
+ statusMessageInput.setAttribute(
+ "value",
+ statusMessage.getAttribute("value")
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
+ statusMessageInput.setAttribute("spellcheck", "true");
+ } else {
+ statusMessageInput.removeAttribute("spellcheck");
+ }
+
+ // force binding attachment by forcing layout
+ statusMessageInput.getBoundingClientRect();
+ statusMessageInput.select();
+ }
+
+ this.statusMessageRefreshTimer();
+ },
+
+ statusMessageRefreshTimer() {
+ const timeBeforeAutoValidate = 20 * 1000;
+ if ("_stopEditStatusTimeout" in this) {
+ clearTimeout(this._stopEditStatusTimeout);
+ }
+ this._stopEditStatusTimeout = setTimeout(
+ this.finishEditStatusMessage,
+ timeBeforeAutoValidate,
+ true
+ );
+ },
+
+ statusMessageKeyPress(aEvent) {
+ if (!this.hasAttribute("editing")) {
+ if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ let button = document.getElementById("statusTypeIcon");
+ document.getElementById("setStatusTypeMenupopup").openPopup(button);
+ }
+ return;
+ }
+
+ switch (aEvent.keyCode) {
+ case aEvent.DOM_VK_RETURN:
+ statusSelector.finishEditStatusMessage(true);
+ break;
+
+ case aEvent.DOM_VK_ESCAPE:
+ statusSelector.finishEditStatusMessage(false);
+ break;
+
+ default:
+ statusSelector.statusMessageRefreshTimer();
+ }
+ },
+
+ finishEditStatusMessage(aSave) {
+ clearTimeout(this._stopEditStatusTimeout);
+ delete this._stopEditStatusTimeout;
+ let statusMessage = document.getElementById("statusMessageLabel");
+ let statusMessageInput = document.getElementById("statusMessageInput");
+ statusMessage.removeAttribute("hidden");
+ statusMessageInput.toggleAttribute("hidden", "true");
+ if (aSave) {
+ let newStatus = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ if ("_statusTypeEditing" in this) {
+ let statusType = this._statusTypeEditing;
+ if (statusType == "available") {
+ newStatus = Ci.imIStatusInfo.STATUS_AVAILABLE;
+ } else if (statusType == "unavailable") {
+ newStatus = Ci.imIStatusInfo.STATUS_UNAVAILABLE;
+ } else if (statusType == "offline") {
+ newStatus = Ci.imIStatusInfo.STATUS_OFFLINE;
+ }
+ delete this._statusTypeBeforeEditing;
+ delete this._statusTypeEditing;
+ }
+ // apply the new status only if it is different from the current one
+ if (
+ newStatus != Ci.imIStatusInfo.STATUS_UNKNOWN ||
+ statusMessageInput.value != statusMessageInput.getAttribute("value")
+ ) {
+ IMServices.core.globalUserStatus.setStatus(
+ newStatus,
+ statusMessageInput.value
+ );
+ }
+ } else if ("_statusTypeBeforeEditing" in this) {
+ this.displayStatusType(this._statusTypeBeforeEditing);
+ delete this._statusTypeBeforeEditing;
+ delete this._statusTypeEditing;
+ }
+
+ if (statusMessage.hasAttribute("usingDefault")) {
+ statusMessage.setAttribute(
+ "value",
+ statusMessage.getAttribute("usingDefault")
+ );
+ }
+
+ statusMessageInput.removeAttribute("editing");
+ statusMessageInput.removeEventListener("blur", event => {
+ this.finishEditStatusMessage(true);
+ });
+
+ // We need to put the focus back on the label after the textbox
+ // binding has been detached, otherwise the focus gets lost (it's
+ // on none of the elements in the document), but before that we
+ // need to flush the layout.
+ statusMessageInput.getBoundingClientRect();
+ statusMessageInput.focus();
+ },
+
+ userIconClick() {
+ const nsIFilePicker = Ci.nsIFilePicker;
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ fp.init(
+ window,
+ bundle.GetStringFromName("userIconFilePickerTitle"),
+ nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(nsIFilePicker.filterImages);
+ fp.open(rv => {
+ if (rv != nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+ IMServices.core.globalUserStatus.setUserIcon(fp.file);
+ });
+ },
+
+ displayNameClick() {
+ let displayName = document.getElementById("displayName");
+ let displayNameInput = document.getElementById("displayNameInput");
+ displayName.setAttribute("hidden", "true");
+ displayNameInput.removeAttribute("hidden");
+ if (!displayNameInput.hasAttribute("editing")) {
+ displayNameInput.setAttribute("editing", "true");
+ if (displayName.hasAttribute("usingDefault")) {
+ displayNameInput.removeAttribute("value");
+ } else {
+ displayNameInput.setAttribute(
+ "value",
+ displayName.getAttribute("value")
+ );
+ }
+ displayNameInput.addEventListener("keypress", this.displayNameKeyPress);
+ displayNameInput.addEventListener("blur", event => {
+ this.finishEditDisplayName(true);
+ });
+ // force binding attachment by forcing layout
+ displayNameInput.getBoundingClientRect();
+ displayNameInput.select();
+ }
+
+ this.displayNameRefreshTimer();
+ },
+
+ _stopEditDisplayNameTimeout: 0,
+ displayNameRefreshTimer() {
+ const timeBeforeAutoValidate = 20 * 1000;
+ clearTimeout(this._stopEditDisplayNameTimeout);
+ this._stopEditDisplayNameTimeout = setTimeout(
+ this.finishEditDisplayName,
+ timeBeforeAutoValidate,
+ true
+ );
+ },
+
+ displayNameKeyPress(aEvent) {
+ switch (aEvent.keyCode) {
+ case aEvent.DOM_VK_RETURN:
+ statusSelector.finishEditDisplayName(true);
+ break;
+
+ case aEvent.DOM_VK_ESCAPE:
+ statusSelector.finishEditDisplayName(false);
+ break;
+
+ default:
+ statusSelector.displayNameRefreshTimer();
+ }
+ },
+
+ finishEditDisplayName(aSave) {
+ clearTimeout(this._stopEditDisplayNameTimeout);
+ let displayName = document.getElementById("displayName");
+ let displayNameInput = document.getElementById("displayNameInput");
+ displayName.removeAttribute("hidden");
+ displayNameInput.toggleAttribute("hidden", "true");
+ // Apply the new display name only if it is different from the current one.
+ if (
+ aSave &&
+ displayNameInput.value != displayNameInput.getAttribute("value")
+ ) {
+ IMServices.core.globalUserStatus.displayName = displayNameInput.value;
+ } else if (displayName.hasAttribute("usingDefault")) {
+ displayName.setAttribute(
+ "value",
+ displayName.getAttribute("usingDefault")
+ );
+ }
+
+ displayNameInput.removeAttribute("editing");
+ displayNameInput.removeEventListener("keypress", this.displayNameKeyPress);
+ displayNameInput.removeEventListener("blur", event => {
+ this.finishEditDisplayName(true);
+ });
+ },
+
+ init() {
+ let events = ["status-changed"];
+ statusSelector.displayCurrentStatus();
+
+ if (document.getElementById("displayName")) {
+ events.push("user-display-name-changed");
+ statusSelector.displayUserDisplayName();
+ }
+
+ if (document.getElementById("userIcon")) {
+ events.push("user-icon-changed");
+ statusSelector.displayUserIcon();
+ }
+
+ let statusMessage = document.getElementById("statusMessageLabel");
+ let statusMessageInput = document.getElementById("statusMessageInput");
+ if (statusMessage && statusMessageInput) {
+ statusMessage.addEventListener("keypress", this.statusMessageKeyPress);
+ statusMessageInput.addEventListener(
+ "keypress",
+ this.statusMessageKeyPress
+ );
+ }
+
+ for (let event of events) {
+ Services.obs.addObserver(statusSelector, event);
+ }
+ statusSelector._events = events;
+
+ window.addEventListener("unload", statusSelector.unload);
+ },
+
+ unload() {
+ for (let event of statusSelector._events) {
+ Services.obs.removeObserver(statusSelector, event);
+ }
+ },
+};
diff --git a/comm/mail/components/im/content/joinchat.js b/comm/mail/components/im/content/joinchat.js
new file mode 100644
index 0000000000..ae4029eb5a
--- /dev/null
+++ b/comm/mail/components/im/content/joinchat.js
@@ -0,0 +1,195 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+var autoJoinPref = "autoJoin";
+
+var joinChat = {
+ onload() {
+ var accountList = document.getElementById("accountlist");
+ for (let acc of IMServices.accounts.getAccounts()) {
+ if (!acc.connected || !acc.canJoinChat) {
+ continue;
+ }
+ var proto = acc.protocol;
+ var item = accountList.appendItem(acc.name, acc.id, proto.name);
+ item.setAttribute("image", ChatIcons.getProtocolIconURI(proto));
+ item.setAttribute("class", "menuitem-iconic");
+ item.account = acc;
+ }
+ if (!accountList.itemCount) {
+ document
+ .getElementById("joinChatDialog")
+ .querySelector("dialog")
+ .cancelDialog();
+ throw new Error("No connected MUC enabled account!");
+ }
+ accountList.selectedIndex = 0;
+ },
+
+ onAccountSelect() {
+ let joinChatGrid = document.getElementById("joinChatGrid");
+ while (joinChatGrid.children.length > 3) {
+ // leave the first 3 cols
+ joinChatGrid.lastChild.remove();
+ }
+
+ let acc = document.getElementById("accountlist").selectedItem.account;
+ let defaultValues = acc.getChatRoomDefaultFieldValues();
+ joinChat._values = defaultValues;
+ joinChat._fields = [];
+ joinChat._account = acc;
+
+ let protoId = acc.protocol.id;
+ document.getElementById("autojoin").hidden = !(
+ protoId == "prpl-irc" ||
+ protoId == "prpl-jabber" ||
+ protoId == "prpl-gtalk"
+ );
+
+ for (let field of acc.getChatRoomFields()) {
+ let div1 = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ let label = document.createXULElement("label");
+ let text = field.label;
+ let match = /_(.)/.exec(text);
+ if (match) {
+ label.setAttribute("accesskey", match[1]);
+ text = text.replace(/_/, "");
+ }
+ label.setAttribute("value", text);
+ label.setAttribute("control", "field-" + field.identifier);
+ label.setAttribute("id", "field-" + field.identifier + "-label");
+ div1.appendChild(label);
+ joinChatGrid.appendChild(div1);
+
+ let div2 = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ let input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ input.classList.add("input-inline");
+ input.setAttribute("id", "field-" + field.identifier);
+ input.setAttribute(
+ "aria-labelledby",
+ "field-" + field.identifier + "-label"
+ );
+ let val = defaultValues.getValue(field.identifier);
+ if (val) {
+ input.setAttribute("value", val);
+ }
+ if (field.type == Ci.prplIChatRoomField.TYPE_PASSWORD) {
+ input.setAttribute("type", "password");
+ } else if (field.type == Ci.prplIChatRoomField.TYPE_INT) {
+ input.setAttribute("type", "number");
+ input.setAttribute("min", field.min);
+ input.setAttribute("max", field.max);
+ } else {
+ input.setAttribute("type", "text");
+ }
+ div2.appendChild(input);
+ joinChatGrid.appendChild(div2);
+
+ let div3 = document.querySelector(".optional-col").cloneNode(true);
+ div3.classList.toggle("required", field.required);
+ joinChatGrid.appendChild(div3);
+
+ joinChat._fields.push({ field, input });
+ }
+
+ window.sizeToContent();
+ },
+
+ join() {
+ let values = joinChat._values;
+ for (let field of joinChat._fields) {
+ let val = field.input.value.trim();
+ if (!val && field.field.required) {
+ field.input.focus();
+ // FIXME: why isn't the return false enough?
+ throw new Error("Some required fields are empty!");
+ // return false;
+ }
+ if (val) {
+ values.setValue(field.field.identifier, val);
+ }
+ }
+ let account = joinChat._account;
+ account.joinChat(values);
+
+ let protoId = account.protocol.id;
+ if (
+ protoId != "prpl-irc" &&
+ protoId != "prpl-jabber" &&
+ protoId != "prpl-gtalk"
+ ) {
+ return;
+ }
+
+ let name;
+ if (protoId == "prpl-irc") {
+ name = values.getValue("channel");
+ } else {
+ name = values.getValue("room") + "@" + values.getValue("server");
+ }
+
+ let conv = IMServices.conversations.getConversationByNameAndAccount(
+ name,
+ account,
+ true
+ );
+ if (conv) {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mailWindow) {
+ mailWindow.focus();
+ let tabmail = mailWindow.document.getElementById("tabmail");
+ tabmail.openTab("chat", { convType: "focus", conv });
+ }
+ }
+
+ if (document.getElementById("autojoin").checked) {
+ // "nick" for JS-XMPP, "handle" for libpurple prpls.
+ let nick = values.getValue("nick") || values.getValue("handle");
+ if (nick) {
+ name += "/" + nick;
+ }
+
+ let prefBranch = Services.prefs.getBranch(
+ "messenger.account." + account.id + "."
+ );
+ let autojoin = [];
+ if (prefBranch.prefHasUserValue(autoJoinPref)) {
+ let prefValue = prefBranch.getStringPref(autoJoinPref);
+ if (prefValue) {
+ autojoin = prefValue.split(",");
+ }
+ }
+
+ if (!autojoin.includes(name)) {
+ autojoin.push(name);
+ prefBranch.setStringPref(autoJoinPref, autojoin.join(","));
+ }
+ }
+ },
+};
+
+document.addEventListener("dialogaccept", joinChat.join);
+
+window.addEventListener("DOMContentLoaded", event => {
+ joinChat.onload();
+});
+window.addEventListener("load", event => {
+ window.sizeToContent();
+});
diff --git a/comm/mail/components/im/content/joinchat.xhtml b/comm/mail/components/im/content/joinchat.xhtml
new file mode 100644
index 0000000000..8bd5753e91
--- /dev/null
+++ b/comm/mail/components/im/content/joinchat.xhtml
@@ -0,0 +1,58 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/joinchat.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE html SYSTEM "chrome://messenger/locale/joinChat.dtd">
+
+<html
+ id="joinChatDialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ scrolling="false"
+>
+ <head>
+ <title>&joinChatWindow.title;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/joinchat.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog buttons="accept,cancel">
+ <div id="joinChatGrid">
+ <div>
+ <xul:label value="&account.label;" control="accountlist" />
+ </div>
+ <div>
+ <xul:menulist
+ id="accountlist"
+ onselect="joinChat.onAccountSelect();"
+ />
+ </div>
+ <div class="optional-col required">&optional.label;</div>
+ </div>
+ <xul:hbox>
+ <xul:checkbox
+ id="autojoin"
+ label="&autojoin.label;"
+ accesskey="&autojoin.accesskey;"
+ />
+ </xul:hbox>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/im/content/toolbarbutton-badge-button.js b/comm/mail/components/im/content/toolbarbutton-badge-button.js
new file mode 100644
index 0000000000..def96faf27
--- /dev/null
+++ b/comm/mail/components/im/content/toolbarbutton-badge-button.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozBadgebutton widget is used to display a chat toolbar button in
+ * the main Toolbox in the messenger window. It displays icon and label
+ * for the button. It also shows a badge on top of the chat icon with a number.
+ * That number is the count of unread messages in the chat.
+ *
+ * @augments MozToolbarbutton
+ */
+ class MozBadgebutton extends customElements.get("toolbarbutton") {
+ static get inheritedAttributes() {
+ return {
+ ".toolbarbutton-icon": "src=image",
+ ".toolbarbutton-text": "value=label,accesskey,crop",
+ };
+ }
+
+ static get markup() {
+ return `
+ <stack>
+ <html:img class="toolbarbutton-icon" alt="" />
+ <html:span class="badgeButton-badge" hidden="hidden"></html:span>
+ </stack>
+ <label class="toolbarbutton-text" crop="end" flex="1"></label>
+ `;
+ }
+
+ /**
+ * toolbarbutton overwrites the fragment getter from MozXULElement.
+ */
+ static get fragment() {
+ return Reflect.get(MozXULElement, "fragment", this);
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+ this.setAttribute("is", "toolbarbutton-badge-button");
+ this.appendChild(this.constructor.fragment);
+
+ this._badgeCount = 0;
+ this.initializeAttributeInheritance();
+ }
+
+ set badgeCount(count) {
+ this._badgeCount = count;
+ let badge = this.querySelector(".badgeButton-badge");
+ badge.textContent = count;
+ badge.hidden = count == 0;
+ }
+
+ get badgeCount() {
+ return this._badgeCount;
+ }
+ }
+
+ customElements.define("toolbarbutton-badge-button", MozBadgebutton, {
+ extends: "toolbarbutton",
+ });
+}
diff --git a/comm/mail/components/im/content/verify.js b/comm/mail/components/im/content/verify.js
new file mode 100644
index 0000000000..fbe39d6a50
--- /dev/null
+++ b/comm/mail/components/im/content/verify.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var verifySession = {
+ onload() {
+ this.sessionVerification =
+ window.arguments[0].wrappedJSObject || window.arguments[0];
+ if (
+ this.sessionVerification.challengeType !==
+ Ci.imISessionVerification.CHALLENGE_TEXT
+ ) {
+ throw new Error("Unsupported challenge type");
+ }
+ document.l10n.setAttributes(
+ document.querySelector("title"),
+ "verify-window-subject-title",
+ {
+ subject: this.sessionVerification.subject,
+ }
+ );
+ document.getElementById("challenge").textContent =
+ this.sessionVerification.challenge;
+ if (this.sessionVerification.challengeDescription) {
+ let description = document.getElementById("challengeDescription");
+ description.hidden = false;
+ description.textContent = this.sessionVerification.challengeDescription;
+ }
+ document.addEventListener("dialogaccept", () => {
+ this.sessionVerification.submitResponse(true);
+ });
+ document.addEventListener("dialogextra2", () => {
+ this.sessionVerification.submitResponse(false);
+ document
+ .getElementById("verifySessionDialog")
+ .querySelector("dialog")
+ .acceptDialog();
+ });
+ document.addEventListener("dialogcancel", () => {
+ this.sessionVerification.cancel();
+ });
+ this.sessionVerification.completePromise.catch(() => {
+ document
+ .getElementById("verifySessionDialog")
+ .querySelector("dialog")
+ .cancelDialog();
+ });
+ },
+};
+
+window.addEventListener("load", event => {
+ verifySession.onload();
+});
diff --git a/comm/mail/components/im/content/verify.xhtml b/comm/mail/components/im/content/verify.xhtml
new file mode 100644
index 0000000000..930ae81e5d
--- /dev/null
+++ b/comm/mail/components/im/content/verify.xhtml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<html
+ id="verifySessionDialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="verify-window-title"></title>
+ <link rel="localization" href="messenger/chat-verifySession.ftl" />
+ <link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/verifychat.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/shared/grid-layout.css"
+ />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/chat/verify.js"
+ ></script>
+ </head>
+ <body>
+ <xul:dialog
+ buttons="accept,cancel,extra2"
+ data-l10n-id="verify-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept, buttonlabelextra2, buttonaccesskeyextra2"
+ >
+ <p data-l10n-id="challenge-label"></p>
+ <p id="challengePresentation">
+ <span id="challenge"></span>
+ <!-- Describes the text content of #challenge in an alternative way.
+ - E.g. if #challenge is a sequence of emojis, then
+ - #challengeDescription would be a sequence of emoji names. -->
+ <span id="challengeDescription" role="note" hidden="hidden"></span>
+ </p>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/im/jar.mn b/comm/mail/components/im/jar.mn
new file mode 100644
index 0000000000..98b7735afc
--- /dev/null
+++ b/comm/mail/components/im/jar.mn
@@ -0,0 +1,199 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/chat/chat-messenger.js (content/chat-messenger.js)
+ content/messenger/am-im.js (content/am-im.js)
+ content/messenger/am-im.xhtml (content/am-im.xhtml)
+ content/messenger/chat/addbuddy.js (content/addbuddy.js)
+ content/messenger/chat/addbuddy.xhtml (content/addbuddy.xhtml)
+ content/messenger/chat/joinchat.js (content/joinchat.js)
+ content/messenger/chat/joinchat.xhtml (content/joinchat.xhtml)
+ content/messenger/chat/imAccounts.js (content/imAccounts.js)
+ content/messenger/chat/imAccounts.xhtml (content/imAccounts.xhtml)
+ content/messenger/chat/imAccountWizard.xhtml (content/imAccountWizard.xhtml)
+ content/messenger/chat/imAccountWizard.js (content/imAccountWizard.js)
+ content/messenger/chat/imContextMenu.js (content/imContextMenu.js)
+ content/messenger/chat/chat-conversation.js (content/chat-conversation.js)
+ content/messenger/chat/imStatusSelector.js (content/imStatusSelector.js)
+ content/messenger/chat/chat-contact.js (content/chat-contact.js)
+ content/messenger/chat/chat-group.js (content/chat-group.js)
+ content/messenger/chat/chat-imconv.js (content/chat-imconv.js)
+ content/messenger/chat/chat-conversation-info.js (content/chat-conversation-info.js)
+ content/messenger/chat/toolbarbutton-badge-button.js (content/toolbarbutton-badge-button.js)
+ content/messenger/chat/verify.js (content/verify.js)
+ content/messenger/chat/verify.xhtml (content/verify.xhtml)
+% skin messenger-messagestyles classic/1.0 %skin/classic/messenger/messages/
+ skin/classic/messenger/messages/mail/inline.js (messages/mail/inline.js)
+ skin/classic/messenger/messages/mail/Incoming/buddy_icon.svg (messages/mail/Incoming/buddy_icon.svg)
+ skin/classic/messenger/messages/mail/Outgoing/buddy_icon.svg (messages/mail/Incoming/buddy_icon.svg)
+ skin/classic/messenger/messages/mail/Incoming/Content.html (messages/mail/Incoming/Content.html)
+ skin/classic/messenger/messages/mail/Incoming/Context.html (messages/mail/Incoming/Context.html)
+ skin/classic/messenger/messages/mail/Incoming/NextContent.html (messages/mail/Incoming/NextContent.html)
+ skin/classic/messenger/messages/mail/Incoming/NextContext.html (messages/mail/Incoming/NextContext.html)
+ skin/classic/messenger/messages/mail/Outgoing/Content.html (messages/mail/Outgoing/Content.html)
+ skin/classic/messenger/messages/mail/Outgoing/Context.html (messages/mail/Outgoing/Context.html)
+ skin/classic/messenger/messages/mail/Outgoing/NextContent.html (messages/mail/Outgoing/NextContent.html)
+ skin/classic/messenger/messages/mail/Outgoing/NextContext.html (messages/mail/Outgoing/NextContext.html)
+ skin/classic/messenger/messages/mail/Footer.html (messages/mail/Footer.html)
+ skin/classic/messenger/messages/mail/Header.html (messages/mail/Header.html)
+ skin/classic/messenger/messages/mail/Info.plist (messages/mail/Info.plist)
+ skin/classic/messenger/messages/mail/main.css (messages/mail/main.css)
+ skin/classic/messenger/messages/mail/NextStatus.html (messages/mail/NextStatus.html)
+ skin/classic/messenger/messages/mail/Status.html (messages/mail/Status.html)
+ skin/classic/messenger/messages/mail/Variants/Dark.css (messages/mail/Variants/Dark.css)
+ skin/classic/messenger/messages/mail/Variants/Light.css (messages/mail/Variants/Light.css)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_0.png (messages/bubbles/Bitmaps/indicator_0.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_0_alt.png (messages/bubbles/Bitmaps/indicator_0_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_10.png (messages/bubbles/Bitmaps/indicator_10.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_100.png (messages/bubbles/Bitmaps/indicator_100.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_100_alt.png (messages/bubbles/Bitmaps/indicator_100_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_10_alt.png (messages/bubbles/Bitmaps/indicator_10_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_110.png (messages/bubbles/Bitmaps/indicator_110.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_110_alt.png (messages/bubbles/Bitmaps/indicator_110_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_120.png (messages/bubbles/Bitmaps/indicator_120.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_120_alt.png (messages/bubbles/Bitmaps/indicator_120_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_130.png (messages/bubbles/Bitmaps/indicator_130.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_130_alt.png (messages/bubbles/Bitmaps/indicator_130_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_140.png (messages/bubbles/Bitmaps/indicator_140.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_140_alt.png (messages/bubbles/Bitmaps/indicator_140_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_150.png (messages/bubbles/Bitmaps/indicator_150.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_150_alt.png (messages/bubbles/Bitmaps/indicator_150_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_160.png (messages/bubbles/Bitmaps/indicator_160.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_160_alt.png (messages/bubbles/Bitmaps/indicator_160_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_170.png (messages/bubbles/Bitmaps/indicator_170.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_170_alt.png (messages/bubbles/Bitmaps/indicator_170_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_180.png (messages/bubbles/Bitmaps/indicator_180.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_180_alt.png (messages/bubbles/Bitmaps/indicator_180_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_190.png (messages/bubbles/Bitmaps/indicator_190.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_190_alt.png (messages/bubbles/Bitmaps/indicator_190_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_20.png (messages/bubbles/Bitmaps/indicator_20.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_200.png (messages/bubbles/Bitmaps/indicator_200.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_200_alt.png (messages/bubbles/Bitmaps/indicator_200_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_20_alt.png (messages/bubbles/Bitmaps/indicator_20_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_210.png (messages/bubbles/Bitmaps/indicator_210.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_210_alt.png (messages/bubbles/Bitmaps/indicator_210_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_220.png (messages/bubbles/Bitmaps/indicator_220.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_220_alt.png (messages/bubbles/Bitmaps/indicator_220_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_230.png (messages/bubbles/Bitmaps/indicator_230.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_230_alt.png (messages/bubbles/Bitmaps/indicator_230_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_240.png (messages/bubbles/Bitmaps/indicator_240.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_240_alt.png (messages/bubbles/Bitmaps/indicator_240_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_250.png (messages/bubbles/Bitmaps/indicator_250.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_250_alt.png (messages/bubbles/Bitmaps/indicator_250_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_260.png (messages/bubbles/Bitmaps/indicator_260.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_260_alt.png (messages/bubbles/Bitmaps/indicator_260_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_270.png (messages/bubbles/Bitmaps/indicator_270.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_270_alt.png (messages/bubbles/Bitmaps/indicator_270_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_280.png (messages/bubbles/Bitmaps/indicator_280.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_280_alt.png (messages/bubbles/Bitmaps/indicator_280_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_290.png (messages/bubbles/Bitmaps/indicator_290.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_290_alt.png (messages/bubbles/Bitmaps/indicator_290_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_30.png (messages/bubbles/Bitmaps/indicator_30.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_300.png (messages/bubbles/Bitmaps/indicator_300.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_300_alt.png (messages/bubbles/Bitmaps/indicator_300_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_30_alt.png (messages/bubbles/Bitmaps/indicator_30_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_310.png (messages/bubbles/Bitmaps/indicator_310.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_310_alt.png (messages/bubbles/Bitmaps/indicator_310_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_320.png (messages/bubbles/Bitmaps/indicator_320.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_320_alt.png (messages/bubbles/Bitmaps/indicator_320_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_330.png (messages/bubbles/Bitmaps/indicator_330.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_330_alt.png (messages/bubbles/Bitmaps/indicator_330_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_340.png (messages/bubbles/Bitmaps/indicator_340.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_340_alt.png (messages/bubbles/Bitmaps/indicator_340_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_350.png (messages/bubbles/Bitmaps/indicator_350.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_350_alt.png (messages/bubbles/Bitmaps/indicator_350_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_40.png (messages/bubbles/Bitmaps/indicator_40.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_40_alt.png (messages/bubbles/Bitmaps/indicator_40_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_50.png (messages/bubbles/Bitmaps/indicator_50.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_50_alt.png (messages/bubbles/Bitmaps/indicator_50_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_60.png (messages/bubbles/Bitmaps/indicator_60.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_60_alt.png (messages/bubbles/Bitmaps/indicator_60_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_70.png (messages/bubbles/Bitmaps/indicator_70.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_70_alt.png (messages/bubbles/Bitmaps/indicator_70_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_80.png (messages/bubbles/Bitmaps/indicator_80.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_80_alt.png (messages/bubbles/Bitmaps/indicator_80_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_90.png (messages/bubbles/Bitmaps/indicator_90.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_90_alt.png (messages/bubbles/Bitmaps/indicator_90_alt.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/indicator_grey.png (messages/bubbles/Bitmaps/indicator_grey.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/minus-hover.png (messages/bubbles/Bitmaps/minus-hover.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/minus.png (messages/bubbles/Bitmaps/minus.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/plus-hover.png (messages/bubbles/Bitmaps/plus-hover.png)
+ skin/classic/messenger/messages/bubbles/Bitmaps/plus.png (messages/bubbles/Bitmaps/plus.png)
+ skin/classic/messenger/messages/bubbles/Footer.html (messages/bubbles/Footer.html)
+ skin/classic/messenger/messages/bubbles/inline.js (messages/bubbles/inline.js)
+ skin/classic/messenger/messages/bubbles/Incoming/Content.html (messages/bubbles/Incoming/Content.html)
+ skin/classic/messenger/messages/bubbles/Incoming/Context.html (messages/bubbles/Incoming/Context.html)
+ skin/classic/messenger/messages/bubbles/Incoming/NextContent.html (messages/bubbles/Incoming/NextContent.html)
+ skin/classic/messenger/messages/bubbles/Info.plist (messages/bubbles/Info.plist)
+ skin/classic/messenger/messages/bubbles/main.css (messages/bubbles/main.css)
+ skin/classic/messenger/messages/bubbles/NextStatus.html (messages/bubbles/NextStatus.html)
+ skin/classic/messenger/messages/bubbles/Status.html (messages/bubbles/Status.html)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Green_Alternating.css (messages/bubbles/Variants/Blue_-_Green_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Green.css (messages/bubbles/Variants/Blue_-_Green.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Pink_Alternating.css (messages/bubbles/Variants/Blue_-_Pink_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Pink.css (messages/bubbles/Variants/Blue_-_Pink.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Red_Alternating.css (messages/bubbles/Variants/Blue_-_Red_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Blue_-_Red.css (messages/bubbles/Variants/Blue_-_Red.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Blue_Alternating.css (messages/bubbles/Variants/Green_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Blue.css (messages/bubbles/Variants/Green_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Purple_Alternating.css (messages/bubbles/Variants/Green_-_Purple_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Purple.css (messages/bubbles/Variants/Green_-_Purple.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Red_Alternating.css (messages/bubbles/Variants/Green_-_Red_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Green_-_Red.css (messages/bubbles/Variants/Green_-_Red.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Blue_Alternating.css (messages/bubbles/Variants/Grey_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Blue.css (messages/bubbles/Variants/Grey_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Pink_Alternating.css (messages/bubbles/Variants/Grey_-_Pink_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Pink.css (messages/bubbles/Variants/Grey_-_Pink.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Purple_Alternating.css (messages/bubbles/Variants/Grey_-_Purple_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Purple.css (messages/bubbles/Variants/Grey_-_Purple.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Red_Alternating.css (messages/bubbles/Variants/Grey_-_Red_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Grey_-_Red.css (messages/bubbles/Variants/Grey_-_Red.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Blue_Alternating.css (messages/bubbles/Variants/Pink_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Blue.css (messages/bubbles/Variants/Pink_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Purple_Alternating.css (messages/bubbles/Variants/Pink_-_Purple_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Pink_-_Purple.css (messages/bubbles/Variants/Pink_-_Purple.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Green_Alternating.css (messages/bubbles/Variants/Purple_-_Green_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Green.css (messages/bubbles/Variants/Purple_-_Green.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Pink_Alternating.css (messages/bubbles/Variants/Purple_-_Pink_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Purple_-_Pink.css (messages/bubbles/Variants/Purple_-_Pink.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Blue_Alternating.css (messages/bubbles/Variants/Red_-_Blue_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Blue.css (messages/bubbles/Variants/Red_-_Blue.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Green_Alternating.css (messages/bubbles/Variants/Red_-_Green_Alternating.css)
+ skin/classic/messenger/messages/bubbles/Variants/Red_-_Green.css (messages/bubbles/Variants/Red_-_Green.css)
+ skin/classic/messenger/messages/dark/inline.js (messages/dark/inline.js)
+ skin/classic/messenger/messages/dark/Incoming/Content.html (messages/dark/Incoming/Content.html)
+ skin/classic/messenger/messages/dark/Incoming/Context.html (messages/dark/Incoming/Context.html)
+ skin/classic/messenger/messages/dark/Incoming/NextContent.html (messages/dark/Incoming/NextContent.html)
+ skin/classic/messenger/messages/dark/Incoming/NextContext.html (messages/dark/Incoming/NextContext.html)
+ skin/classic/messenger/messages/dark/Info.plist (messages/dark/Info.plist)
+ skin/classic/messenger/messages/dark/main.css (messages/dark/main.css)
+ skin/classic/messenger/messages/dark/Status.html (messages/dark/Status.html)
+ skin/classic/messenger/messages/dark/Variants/Blue.css (messages/dark/Variants/Blue.css)
+ skin/classic/messenger/messages/dark/Variants/Green.css (messages/dark/Variants/Green.css)
+ skin/classic/messenger/messages/dark/Variants/Purple.css (messages/dark/Variants/Purple.css)
+ skin/classic/messenger/messages/dark/Variants/Red.css (messages/dark/Variants/Red.css)
+ skin/classic/messenger/messages/dark/Variants/Yellow.css (messages/dark/Variants/Yellow.css)
+ skin/classic/messenger/messages/papersheets/Bitmaps/information.png (messages/papersheets/Bitmaps/information.png)
+ skin/classic/messenger/messages/papersheets/Bitmaps/minus.png (messages/papersheets/Bitmaps/minus.png)
+ skin/classic/messenger/messages/papersheets/Bitmaps/plus.png (messages/papersheets/Bitmaps/plus.png)
+ skin/classic/messenger/messages/papersheets/inline.js (messages/papersheets/inline.js)
+ skin/classic/messenger/messages/papersheets/Incoming/Content.html (messages/papersheets/Incoming/Content.html)
+ skin/classic/messenger/messages/papersheets/Incoming/Context.html (messages/papersheets/Incoming/Context.html)
+ skin/classic/messenger/messages/papersheets/Incoming/NextContent.html (messages/papersheets/Incoming/NextContent.html)
+ skin/classic/messenger/messages/papersheets/Info.plist (messages/papersheets/Info.plist)
+ skin/classic/messenger/messages/papersheets/main.css (messages/papersheets/main.css)
+ skin/classic/messenger/messages/papersheets/NextStatus.html (messages/papersheets/NextStatus.html)
+ skin/classic/messenger/messages/papersheets/Status.html (messages/papersheets/Status.html)
+ skin/classic/messenger/messages/papersheets/Variants/White.css (messages/papersheets/Variants/White.css)
+ skin/classic/messenger/messages/simple/Incoming/Content.html (messages/simple/Incoming/Content.html)
+ skin/classic/messenger/messages/simple/Incoming/Context.html (messages/simple/Incoming/Context.html)
+ skin/classic/messenger/messages/simple/Incoming/NextContext.html (messages/simple/Incoming/NextContext.html)
+ skin/classic/messenger/messages/simple/Info.plist (messages/simple/Info.plist)
+ skin/classic/messenger/messages/simple/main.css (messages/simple/main.css)
+ skin/classic/messenger/messages/simple/Status.html (messages/simple/Status.html)
+ skin/classic/messenger/messages/simple/Variants/Normal.css (messages/simple/Variants/Normal.css)
+ skin/classic/messenger/messages/simple/Variants/Dark.css (messages/simple/Variants/Dark.css)
+% skin messenger-emoticons classic/1.0 %skin/classic/messenger/smileys/
+ skin/classic/messenger/smileys/theme.json (smileys/theme.json)
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png
new file mode 100644
index 0000000000..eb0051de34
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png
new file mode 100644
index 0000000000..9c5890b792
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png
new file mode 100644
index 0000000000..17295f5474
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png
new file mode 100644
index 0000000000..fc54959c86
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png
new file mode 100644
index 0000000000..218351534b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png
new file mode 100644
index 0000000000..4692e1cf92
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png
new file mode 100644
index 0000000000..bbd8c91b10
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png
new file mode 100644
index 0000000000..be6c4b2b08
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png
new file mode 100644
index 0000000000..de40ea9eba
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png
new file mode 100644
index 0000000000..d95237d37c
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png
new file mode 100644
index 0000000000..d6360fb7bd
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png
new file mode 100644
index 0000000000..5c10415912
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png
new file mode 100644
index 0000000000..2bc8b95efa
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png
new file mode 100644
index 0000000000..a0d8e59ce9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png
new file mode 100644
index 0000000000..572333b2f6
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png
new file mode 100644
index 0000000000..f1e1740e91
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png
new file mode 100644
index 0000000000..f2ff22beae
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png
new file mode 100644
index 0000000000..ba4118844e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png
new file mode 100644
index 0000000000..391439be42
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png
new file mode 100644
index 0000000000..b3b2683090
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png
new file mode 100644
index 0000000000..b59ffae9b6
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png
new file mode 100644
index 0000000000..1a08183e18
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png
new file mode 100644
index 0000000000..8df7a9d569
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png
new file mode 100644
index 0000000000..327ed9be66
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png
new file mode 100644
index 0000000000..f5b2d08f2a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png
new file mode 100644
index 0000000000..fd5baf149f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png
new file mode 100644
index 0000000000..a03b2d7a29
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png
new file mode 100644
index 0000000000..2dbb2241a2
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png
new file mode 100644
index 0000000000..8505ef0de8
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png
new file mode 100644
index 0000000000..18e3fac3af
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png
new file mode 100644
index 0000000000..02f82c3972
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png
new file mode 100644
index 0000000000..d14afacf6d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png
new file mode 100644
index 0000000000..f9fb364e28
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png
new file mode 100644
index 0000000000..13388613e5
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png
new file mode 100644
index 0000000000..8bb8757871
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png
new file mode 100644
index 0000000000..bd70b8d77a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png
new file mode 100644
index 0000000000..b55967823f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png
new file mode 100644
index 0000000000..2b239c315b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png
new file mode 100644
index 0000000000..f9c0cee4fe
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png
new file mode 100644
index 0000000000..56839321e2
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png
new file mode 100644
index 0000000000..cec2e2817e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png
new file mode 100644
index 0000000000..ffcbe04eb8
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png
new file mode 100644
index 0000000000..a2e01b5dfa
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png
new file mode 100644
index 0000000000..6cf6949f78
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png
new file mode 100644
index 0000000000..b4acbf8631
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png
new file mode 100644
index 0000000000..0652f280ef
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png
new file mode 100644
index 0000000000..86b9ea0206
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png
new file mode 100644
index 0000000000..36788859bf
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png
new file mode 100644
index 0000000000..45e61fccb0
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png
new file mode 100644
index 0000000000..efd75314fa
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png
new file mode 100644
index 0000000000..69f590d967
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png
new file mode 100644
index 0000000000..77a2469399
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png
new file mode 100644
index 0000000000..9ad18a0dea
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png
new file mode 100644
index 0000000000..0e7a2e35c0
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png
new file mode 100644
index 0000000000..516e309aec
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png
new file mode 100644
index 0000000000..9981a24814
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png
new file mode 100644
index 0000000000..60cc155e03
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png
new file mode 100644
index 0000000000..cb2860cf66
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png
new file mode 100644
index 0000000000..cc5a303a75
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png
new file mode 100644
index 0000000000..dd0ef8da8a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png
new file mode 100644
index 0000000000..15f010224b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png
new file mode 100644
index 0000000000..8d40d43293
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png
new file mode 100644
index 0000000000..7281760571
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png
new file mode 100644
index 0000000000..bb4cc9044e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png
new file mode 100644
index 0000000000..f7d05aae55
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png
new file mode 100644
index 0000000000..a939ea98b9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png
new file mode 100644
index 0000000000..823cd4f2b0
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png
new file mode 100644
index 0000000000..85b1781135
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png
new file mode 100644
index 0000000000..0cbff3ee35
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png
new file mode 100644
index 0000000000..e51a56935c
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png
new file mode 100644
index 0000000000..758a8f95e3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png
new file mode 100644
index 0000000000..5e41f98397
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png
new file mode 100644
index 0000000000..b3c8e68eba
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png b/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png
new file mode 100644
index 0000000000..93a69cc789
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png b/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png
new file mode 100644
index 0000000000..72107d151f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png b/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png
new file mode 100644
index 0000000000..4509b17c0e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png b/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png
new file mode 100644
index 0000000000..eaf364177d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/bubbles/Footer.html b/comm/mail/components/im/messages/bubbles/Footer.html
new file mode 100644
index 0000000000..b024066d50
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Footer.html
@@ -0,0 +1,5 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<p id="lastMessage"/>
diff --git a/comm/mail/components/im/messages/bubbles/Incoming/Content.html b/comm/mail/components/im/messages/bubbles/Incoming/Content.html
new file mode 100644
index 0000000000..f37578f699
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Incoming/Content.html
@@ -0,0 +1,7 @@
+<div class="bubble %messageClasses%" data-senderColor="%senderColor%">
+<div class="indicator">
+<p class="pseudo">%sender%<span class="time"> - %time{%H:%M}%</span></p>
+<p class="%messageClasses%">%message%</p>
+<div id="insert"></div>
+</div>
+</div>
diff --git a/comm/mail/components/im/messages/bubbles/Incoming/Context.html b/comm/mail/components/im/messages/bubbles/Incoming/Context.html
new file mode 100644
index 0000000000..8d29cbefbe
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Incoming/Context.html
@@ -0,0 +1,7 @@
+<div class="bubble context %messageClasses%" data-senderColor="%senderColor%">
+<div class="indicator">
+<p class="pseudo">%sender%<span class="time"> - %time{%H:%M}%</span></p>
+<p class="%messageClasses%">%message%</p>
+<div id="insert"></div>
+</div>
+</div>
diff --git a/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html b/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html
new file mode 100644
index 0000000000..3c8aa904ba
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html
@@ -0,0 +1,3 @@
+<hr/>
+<p class="%messageClasses%">%message%</p>
+<div id="insert"></div>
diff --git a/comm/mail/components/im/messages/bubbles/Info.plist b/comm/mail/components/im/messages/bubbles/Info.plist
new file mode 100644
index 0000000000..0b26e9413b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Info.plist
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%sender% %message%</string>
+
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird Bubbles Message Style</string>
+
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.bubbles.message.style</string>
+
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+
+ <key>CFBundleName</key>
+ <string>Bubbles</string>
+
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+
+ <key>DefaultVariant</key>
+ <string>Blue_-_Red_Alternating</string>
+
+ <key>DisableCustomBackground</key>
+ <false/>
+
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/bubbles/NextStatus.html b/comm/mail/components/im/messages/bubbles/NextStatus.html
new file mode 100644
index 0000000000..5aa62afb78
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/NextStatus.html
@@ -0,0 +1,3 @@
+<hr/>
+<p class="%messageClasses%">%time% - %message%</p>
+<div id="insert"></div>
diff --git a/comm/mail/components/im/messages/bubbles/Status.html b/comm/mail/components/im/messages/bubbles/Status.html
new file mode 100644
index 0000000000..5e5c927b47
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Status.html
@@ -0,0 +1,4 @@
+<div class="bubble %messageClasses%">
+<p class="%messageClasses%">%time% - %message%</p>
+<div id="insert"></div>
+</div>
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css
new file mode 100644
index 0000000000..456b4054ed
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css
new file mode 100644
index 0000000000..8b67d64b38
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css
new file mode 100644
index 0000000000..82c84545e9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css
new file mode 100644
index 0000000000..813af66880
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css
new file mode 100644
index 0000000000..77e5082b15
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css
new file mode 100644
index 0000000000..9e91c0c21d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css
new file mode 100644
index 0000000000..336e241aea
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css
new file mode 100644
index 0000000000..1f9ab284e3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css
new file mode 100644
index 0000000000..90a2fcb51d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css
new file mode 100644
index 0000000000..a3b835b49b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css
new file mode 100644
index 0000000000..30186fa0cd
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css
new file mode 100644
index 0000000000..ba999760b9
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css
new file mode 100644
index 0000000000..f2b1f89b62
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css
new file mode 100644
index 0000000000..f1c10ff4a4
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css
new file mode 100644
index 0000000000..84a8b04754
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css
new file mode 100644
index 0000000000..974e7b1698
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css
new file mode 100644
index 0000000000..7051e00d86
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css
new file mode 100644
index 0000000000..601158153c
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css
new file mode 100644
index 0000000000..81eaacf886
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css
new file mode 100644
index 0000000000..7c6c5ae5ef
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(240, 20%, 97%);
+ border-color: hsl(240, 20%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(240, 20%, 75%);
+ background-color: hsl(240, 20%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_grey.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css
new file mode 100644
index 0000000000..70568ca0d5
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css
new file mode 100644
index 0000000000..605b051393
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css
new file mode 100644
index 0000000000..f04b8bd51d
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css
new file mode 100644
index 0000000000..eb814bdcd3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css
new file mode 100644
index 0000000000..3122ad8df3
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css
new file mode 100644
index 0000000000..dfd40e6335
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css
new file mode 100644
index 0000000000..beea02943e
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_320.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css
new file mode 100644
index 0000000000..869ee36eb8
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(270, 100%, 97%);
+ border-color: hsl(270, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(270, 100%, 75%);
+ background-color: hsl(270, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_270.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(320, 100%, 97%);
+ border-color: hsl(320, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(320, 100%, 75%);
+ background-color: hsl(320, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css
new file mode 100644
index 0000000000..2fbe69c40b
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_240.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css
new file mode 100644
index 0000000000..e0337a8d7f
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(240, 100%, 97%);
+ border-color: hsl(240, 100%, 80%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(240, 100%, 75%);
+ background-color: hsl(240, 100%, 94%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css
new file mode 100644
index 0000000000..cae44aa14a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_120.png') no-repeat center left;
+}
diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css
new file mode 100644
index 0000000000..0cbe20430a
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.bubble.outgoing {
+ background-color: hsl(0, 100%, 97%);
+ border-color: hsl(0, 100%, 80%);
+}
+
+.outgoing > .indicator > .pseudo {
+ color: hsl(0, 100%, 75%);
+ background-color: hsl(0, 100%, 94%);
+}
+
+.outgoing > .indicator {
+ margin-left: -17px;
+ padding-left: 32px;
+ background: url('../Bitmaps/indicator_0.png') no-repeat center left;
+}
+
+
+.bubble.incoming {
+ background-color: hsl(120, 100%, 97%);
+ border-color: hsl(120, 100%, 70%);
+}
+
+.incoming > .indicator > .pseudo {
+ color: hsl(120, 100%, 45%);
+ background-color: hsl(120, 100%, 92%);
+}
+
+.incoming > .indicator {
+ margin-right: -19px;
+ padding-right: 34px;
+ background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right;
+}
diff --git a/comm/mail/components/im/messages/bubbles/inline.js b/comm/mail/components/im/messages/bubbles/inline.js
new file mode 100644
index 0000000000..11bdec3f29
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/inline.js
@@ -0,0 +1,330 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// See chat/content/conversation-browser.js _exposeMethodsToContent
+/* globals convScrollEnabled, scrollToElement */
+
+/* [pseudo_color, pseudo_background, bubble_borders] */
+const elements_lightness = [
+ [75, 94, 80],
+ [75, 94, 80],
+ [70, 93, 75],
+ [65, 92, 70],
+ [55, 90, 65],
+ [48, 90, 60],
+ [44, 86, 50],
+ [44, 88, 60],
+ [45, 88, 70],
+ [45, 90, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [45, 92, 70],
+ [60, 92, 70],
+ [70, 93, 75],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+ [75, 94, 80],
+];
+
+const bubble_background = "hsl(#, 100%, 97%)";
+const bubble_borders = "hsl(#, 100%, #%)";
+const pseudo_color = "hsl(#, 100%, #%)";
+const pseudo_background = "hsl(#, 100%, #%)";
+
+var alternating = null;
+
+function setColors(target) {
+ var senderColor = target.getAttribute("data-senderColor");
+
+ if (!senderColor) {
+ return;
+ }
+
+ var regexp =
+ /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/;
+ var parsed = regexp.exec(senderColor);
+
+ if (!parsed) {
+ return;
+ }
+
+ var senderHue = (Math.round(parsed[1] / 10) * 10) % 360;
+ var lightness = elements_lightness[senderHue / 10];
+
+ target.style.backgroundColor = bubble_background.replace("#", senderHue);
+ target.style.borderColor = bubble_borders
+ .replace("#", senderHue)
+ .replace("#", lightness[2]);
+
+ var pseudo = target.getElementsByClassName("pseudo")[0];
+ pseudo.style.color = pseudo_color
+ .replace("#", senderHue)
+ .replace("#", lightness[0]);
+ pseudo.style.backgroundColor = pseudo_background
+ .replace("#", senderHue)
+ .replace("#", lightness[1]);
+
+ var div_indicator = target.getElementsByClassName("indicator")[0];
+ var imageURL = "url('Bitmaps/indicator_" + senderHue;
+ if (target.classList.contains("incoming")) {
+ // getComputedStyle is prohibitively expensive, and we need it only to
+ // know if we are using an alternating variant, so we cache the result.
+ if (alternating === null) {
+ alternating = document.defaultView
+ .getComputedStyle(div_indicator)
+ .backgroundImage.endsWith('_alt.png")')
+ ? "_alt"
+ : "";
+ }
+ imageURL += alternating;
+ }
+ div_indicator.style.backgroundImage = imageURL + ".png')";
+}
+
+function prettyPrintTime(aValue, aNoSeconds) {
+ if (aValue < 60 && aNoSeconds) {
+ return "";
+ }
+
+ if (aNoSeconds) {
+ aValue -= aValue % 60;
+ }
+
+ let valuesAndUnits = window.convertTimeUnits(aValue);
+ if (!valuesAndUnits[2]) {
+ valuesAndUnits.splice(2, 2);
+ }
+ return valuesAndUnits.join(" ");
+}
+
+// The "shadow" constant is the minimum acceptable margin-bottom for a bubble
+// with a shadow, and the minimum spacing between the bubbles of two messages
+// arriving in the same second. It should match the value of margin-bottom and
+// box-shadow-bottom for the "bubble" class.
+const shadow = 3;
+const coef = 3;
+const timebeforetextdisplay = 5 * 60;
+const kRulerMarginTop = 11;
+
+const kMsPerMinute = 60 * 1000;
+const kMsPerHour = 60 * kMsPerMinute;
+const kMsPerDay = 24 * kMsPerHour;
+
+function computeSpace(aInterval) {
+ return Math.round(coef * Math.log(aInterval + 1));
+}
+
+var lastMessageTimeout;
+var lastMessageTimeoutTime = -1;
+
+/* This function takes care of updating the amount of whitespace
+ * between the last message and the bottom of the conversation area.
+ * When the last message is more than timebeforetextdisplay old, we display
+ * the time in text. To avoid blinking Mac scrollbar and visual distractions
+ * for some very sensitive users, we update the whitespace only when a new
+ * message is displayed or when the user switches between tabs. While the
+ * conversation is visible, this function is called by timers, but we will
+ * only update the time displayed in text (this behavior is obtained by
+ * setting the aUpdateTextOnly parameter to true; otherwise it is omitted).
+ */
+function handleLastMessage(aUpdateTextOnly) {
+ if (window.messageInsertPending) {
+ return;
+ }
+
+ var intervalInMs = Date.now() - lastMsgTime * 1000;
+ var interval = Math.round(intervalInMs / 1000);
+ var p = document.getElementById("lastMessage");
+ var margin;
+ if (!aUpdateTextOnly) {
+ // Impose a minimum to ensure the last bubble doesn't touch the editbox.
+ margin = computeSpace(Math.max(intervalInMs, 5000) / 1000);
+ }
+ var text = "";
+ if (interval >= timebeforetextdisplay) {
+ if (!aUpdateTextOnly) {
+ p.style.lineHeight = margin + shadow + "px";
+ }
+ p.setAttribute("class", "interval");
+ text = prettyPrintTime(interval, true);
+ margin = 0;
+ }
+ p.textContent = text;
+ if (!aUpdateTextOnly) {
+ p.style.marginTop = margin - shadow + "px";
+ if (convScrollEnabled()) {
+ scrollToElement(p);
+ }
+ }
+
+ var next = timebeforetextdisplay * 1000 - intervalInMs;
+ if (next <= 0) {
+ if (intervalInMs > kMsPerDay) {
+ next = kMsPerHour - (intervalInMs % kMsPerHour);
+ } else {
+ next = kMsPerMinute - (intervalInMs % kMsPerMinute);
+ }
+ aUpdateTextOnly = true;
+ }
+
+ // The setTimeout callbacks are frequently called a few ms early,
+ // but our code prefers being called a little late, so add 20ms.
+ lastMessageTimeoutTime = next + 20;
+ lastMessageTimeout = setTimeout(
+ handleLastMessage,
+ lastMessageTimeoutTime,
+ aUpdateTextOnly
+ );
+}
+
+var lastMsgTime = 0;
+function updateLastMsgTime(aMsgTime) {
+ if (aMsgTime > lastMsgTime) {
+ lastMsgTime = aMsgTime;
+ }
+
+ if (lastMsgTime && lastMessageTimeoutTime != 0 && !document.hidden) {
+ clearTimeout(lastMessageTimeout);
+ setTimeout(handleLastMessage, 0);
+ lastMessageTimeoutTime = 0;
+ }
+}
+
+function visibilityChanged() {
+ if (document.hidden) {
+ clearTimeout(lastMessageTimeout);
+ lastMessageTimeoutTime = -1;
+ } else if (lastMsgTime) {
+ handleLastMessage();
+ }
+}
+
+function checkNewText(target) {
+ var nicks = target.getElementsByClassName("ib-nick");
+ for (var i = 0; i < nicks.length; ++i) {
+ var nick = nicks[i];
+ if (nick.hasAttribute("data-left")) {
+ continue;
+ }
+ var hue = nick.getAttribute("data-nickColor");
+ var senderHue = (Math.round(hue / 10) * 10) % 360;
+ var lightness = elements_lightness[senderHue / 10];
+ nick.style.backgroundColor = pseudo_background
+ .replace("#", senderHue)
+ .replace("#", lightness[1]);
+ nick.style.color = pseudo_color
+ .replace("#", senderHue)
+ .replace("#", lightness[0]);
+ nick.style.borderColor = bubble_borders
+ .replace("#", senderHue)
+ .replace("#", lightness[2]);
+ }
+
+ var msgTime = null;
+ if (target._originalMsg) {
+ msgTime = target._originalMsg.time;
+ }
+ if (target.tagName == "DIV" && target.classList.contains("bubble")) {
+ setColors(target);
+
+ var prev = target.previousElementSibling;
+ var shouldSetUnreadRuler = prev && prev.id && prev.id == "unread-ruler";
+ var shouldSetSessionRuler =
+ prev && prev.className && prev.className == "sessionstart-ruler";
+ // We need an extra pixel of margin at the top to make the margins appear
+ // to be of equal size, since the preceding bubble will have a shadow.
+ var rulerMarginBottom = kRulerMarginTop - 1;
+
+ if (lastMsgTime && msgTime >= lastMsgTime) {
+ var interval = msgTime - lastMsgTime;
+ var margin = computeSpace(interval);
+ let isTimetext = interval >= timebeforetextdisplay;
+ if (isTimetext) {
+ let p = document.createElement("p");
+ p.className = "interval";
+ if (shouldSetSessionRuler) {
+ // Hide the hr and style the time text accordingly instead.
+ prev.classList.remove("sessionstart-ruler");
+ prev.style.border = "none";
+ p.classList.add("sessionstart-ruler");
+ margin += 6;
+ prev = p;
+ }
+ p.style.lineHeight = margin + shadow + "px";
+ p.style.marginTop = -shadow + "px";
+ p.textContent = prettyPrintTime(interval);
+ target.parentNode.insertBefore(p, target);
+ margin = 0;
+ }
+ target.style.marginTop = margin + "px";
+ if (shouldSetUnreadRuler || shouldSetSessionRuler) {
+ if (margin > rulerMarginBottom) {
+ // Set the unread ruler margin so it is constant after margin collapse.
+ // See https://developer.mozilla.org/en/CSS/margin_collapsing
+ rulerMarginBottom -= margin;
+ }
+ if (isTimetext && shouldSetUnreadRuler) {
+ // If a text display follows, use the minimum bubble margin after the
+ // ruler, taking account of the absence of a shadow on the ruler.
+ rulerMarginBottom = shadow - 1;
+ }
+ }
+ }
+ if (shouldSetUnreadRuler || shouldSetSessionRuler) {
+ prev.style.marginBottom = rulerMarginBottom + "px";
+ prev.style.marginTop = kRulerMarginTop + "px";
+ }
+ } else if (target.tagName == "P" && target.className == "event") {
+ let parent = target.parentNode;
+ // We need to start a group with this element if there are at least 4
+ // system messages and they aren't already grouped.
+ if (!parent?.grouped && parent?.querySelector("p.event:nth-of-type(4)")) {
+ let p = document.createElement("p");
+ p.className = "eventToggle";
+ p.addEventListener("click", event =>
+ event.target.parentNode.classList.toggle("hide-children")
+ );
+ parent.insertBefore(p, parent.querySelector("p.event:nth-of-type(2)"));
+ parent.classList.add("hide-children");
+ parent.grouped = true;
+ }
+ }
+
+ if (msgTime) {
+ updateLastMsgTime(msgTime);
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
+
+document.addEventListener("visibilitychange", visibilityChanged);
diff --git a/comm/mail/components/im/messages/bubbles/main.css b/comm/mail/components/im/messages/bubbles/main.css
new file mode 100644
index 0000000000..84e8c7b8d6
--- /dev/null
+++ b/comm/mail/components/im/messages/bubbles/main.css
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+body {
+ margin: 0;
+ padding: 0;
+ background: -moz-linear-gradient(top, -moz-dialog, -moz-default-background-color) fixed;
+ color: #000;
+}
+
+p {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+}
+
+.bubble {
+ margin: 20px 20px 3px;
+ padding: 0;
+ border-width: 2px;
+ border-style: solid;
+ border-radius: 10px;
+ box-shadow: rgba(0, 0, 0, 0.3) 1px 1px 3px;
+}
+
+#ibcontent:not(.log) > #Chat > .bubble:not(.context,.event) {
+ -moz-animation-duration: 0.5s;
+ -moz-animation-name: fadein;
+ -moz-animation-iteration-count: 1;
+}
+
+@-moz-keyframes fadein {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1.0;
+ }
+}
+
+.bubble.context:not(:hover) {
+ filter: saturate(40%);
+}
+
+.indicator {
+ margin: 0;
+ padding: 9px 15px 10px 15px;
+}
+
+.bubble.event {
+ padding: 4px 15px 4px 15px;
+ background-color: hsl(0, 0%, 99%);
+ border-color: hsl(0, 0%, 85%);
+ box-shadow: rgba(0, 0, 0, 0.1) 1px 1px 3px;
+}
+
+.pseudo {
+ display: inline-block;
+ font-size: smaller;
+ font-weight: bold;
+ margin: -9px 0px 3px -15px;
+ padding: 0px 15px 1px 15px;
+ /* border-top-left-radius = (border-radius - border-width) of div.bubble,
+ see bug 1775 for an explanation */
+ border-top-left-radius: 8px;
+ border-bottom-right-radius: 10px;
+}
+
+.pseudo > .time {
+ display: none;
+}
+
+.bubble:hover > .indicator > .pseudo > .time {
+ display: inline;
+}
+
+.bubble > .indicator > hr,
+.bubble > hr {
+ margin: 3px 0px 1px 0px;
+ height: 2px;
+ border-style: none;
+ border-top: 1px solid rgba(0, 0, 0, 0.07);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.5);
+}
+
+.interval, #lastMessage {
+ text-align: center;
+ color: hsl(0, 0%, 60%);
+}
+
+#lastMessage {
+ line-height: 20px;
+}
+
+#ibcontent.log > #lastMessage {
+ display: none;
+}
+
+p.nick {
+ font-weight: bold;
+}
+
+p.action {
+ font-style: italic;
+}
+
+p.action::before {
+ content: "*** ";
+}
+
+p.event {
+ color: hsl(0, 0%, 60%);
+}
+
+p.event *:any-link:not(:hover) {
+ color: hsl(0, 0%, 60%);
+ text-decoration: none;
+}
+
+p.event *:any-link:hover {
+ color: hsl(0, 0%, 25%);
+}
+
+#Chat {
+ white-space: normal;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+#unread-ruler {
+ border-top: 1px solid rgba(0, 0, 0, 0.16) !important;
+ border-bottom: 1px solid rgb(255,255,255) !important;
+}
+
+.sessionstart-ruler {
+ margin: 0;
+ width: 100%;
+ border: none;
+ min-height: 13px;
+ background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(0,0,0,0.18));
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::after {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 10px;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-start: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+/* used by javascript */
+.eventToggle {
+ cursor: pointer;
+ min-height: 20px;
+ margin-left: -24px;
+ padding-left: 24px;
+ background: url('Bitmaps/minus.png') no-repeat left top;
+ margin-bottom: -20px;
+ width: 0;
+}
+
+.eventToggle:hover {
+ background-image: url('Bitmaps/minus-hover.png');
+}
+
+.hide-children > .eventToggle {
+ width: 100%;
+ margin-bottom: -3px;
+ background-image: url('Bitmaps/plus.png');
+}
+
+.hide-children > .eventToggle:hover {
+ background-image: url('Bitmaps/plus-hover.png');
+}
+
+.hide-children > .eventToggle::after {
+ content: "\2026"; /* &hellip; */
+ color: hsl(0, 0%, 60%);
+}
+
+.hide-children > :is(p.event,hr):not(:first-of-type,:last-of-type,.no-collapse) {
+ display: none;
+}
+
+.ib-nick {
+ font-size: smaller;
+ border: 1px solid;
+ border-radius: 6px;
+ padding: 0 0.3em;
+}
+
+.ib-nick[left] {
+ color: hsl(0, 0%, 60%);
+ background-color: hsl(0, 0%, 99%);
+ border-color: hsl(0, 0%, 85%);
+}
diff --git a/comm/mail/components/im/messages/dark/Incoming/Content.html b/comm/mail/components/im/messages/dark/Incoming/Content.html
new file mode 100644
index 0000000000..3db2719441
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/Content.html
@@ -0,0 +1,2 @@
+<p class="%messageClasses%" data-senderColor="%senderColor%"><span class="pseudo">%sender%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Incoming/Context.html b/comm/mail/components/im/messages/dark/Incoming/Context.html
new file mode 100644
index 0000000000..0b8c7ec20f
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/Context.html
@@ -0,0 +1,2 @@
+<p class="context %messageClasses%" data-senderColor="%senderColor%"><span class="pseudo">%sender%</span><span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Incoming/NextContent.html b/comm/mail/components/im/messages/dark/Incoming/NextContent.html
new file mode 100644
index 0000000000..c62098d838
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/NextContent.html
@@ -0,0 +1,2 @@
+<p class="%messageClasses%" data-senderColor="%senderColor%"><span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Incoming/NextContext.html b/comm/mail/components/im/messages/dark/Incoming/NextContext.html
new file mode 100644
index 0000000000..d57fd3b1a6
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Incoming/NextContext.html
@@ -0,0 +1,2 @@
+<p class="context %messageClasses%" data-senderColor="%senderColor%"><span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/dark/Info.plist b/comm/mail/components/im/messages/dark/Info.plist
new file mode 100644
index 0000000000..3de1af0f4d
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Info.plist
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%sender% %message%</string>
+
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird Dark Message Style</string>
+
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.dark.message.style</string>
+
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+
+ <key>CFBundleName</key>
+ <string>Dark</string>
+
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+
+ <key>DefaultBackgroundColor</key>
+ <string>000000</string>
+
+ <key>DefaultVariant</key>
+ <string>Blue</string>
+
+ <key>DisableCustomBackground</key>
+ <false/>
+
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/dark/Status.html b/comm/mail/components/im/messages/dark/Status.html
new file mode 100644
index 0000000000..cb3bedf216
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Status.html
@@ -0,0 +1 @@
+<p class="event-messages">%time% - %message%</p>
diff --git a/comm/mail/components/im/messages/dark/Variants/Blue.css b/comm/mail/components/im/messages/dark/Variants/Blue.css
new file mode 100644
index 0000000000..d32a90406f
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Blue.css
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+p.incoming {
+ border-top: 1px solid hsla(215, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(215, 100%, 80%, 0.3), hsla(215, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Green.css b/comm/mail/components/im/messages/dark/Variants/Green.css
new file mode 100644
index 0000000000..d2a8ecca33
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Green.css
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+p.incoming {
+ border-top: 1px solid hsla(150, 80%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(150, 80%, 80%, 0.3), hsla(150, 80%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Purple.css b/comm/mail/components/im/messages/dark/Variants/Purple.css
new file mode 100644
index 0000000000..bf26f8d549
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Purple.css
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+p.incoming {
+ border-top: 1px solid hsla(275, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(275, 100%, 80%, 0.3), hsla(275, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Red.css b/comm/mail/components/im/messages/dark/Variants/Red.css
new file mode 100644
index 0000000000..5bb6dab2ed
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Red.css
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+p.incoming {
+ border-top: 1px solid hsla(0, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(0, 100%, 80%, 0.3), hsla(0, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/Variants/Yellow.css b/comm/mail/components/im/messages/dark/Variants/Yellow.css
new file mode 100644
index 0000000000..aa493bfdc7
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/Variants/Yellow.css
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+p.incoming {
+ border-top: 1px solid hsla(50, 100%, 80%, 0.4);
+ background: -moz-linear-gradient(top, hsla(50, 100%, 80%, 0.3), hsla(50, 100%, 80%, 0.1) 30px);
+}
diff --git a/comm/mail/components/im/messages/dark/inline.js b/comm/mail/components/im/messages/dark/inline.js
new file mode 100644
index 0000000000..71cbd46475
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/inline.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const p_border_top = "1px solid hsla(#, 100%, 80%, 0.4)";
+const p_background =
+ "-moz-linear-gradient(top, hsla(#, 100%, 80%, 0.3), hsla(#, 100%, 80%, 0.1) 30px)";
+const nick_background =
+ "-moz-linear-gradient(top, hsla(#, 100%, 80%, 0.3), hsla(#, 100%, 80%, 0.1) 1em)";
+
+function setColors(target) {
+ var senderColor = target.getAttribute("data-senderColor");
+
+ if (!senderColor) {
+ return;
+ }
+
+ var regexp =
+ /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/;
+ var parsed = regexp.exec(senderColor);
+
+ if (!parsed) {
+ return;
+ }
+
+ var senderHue = parsed[1];
+
+ target.style.borderTop = p_border_top.replace("#", senderHue);
+ target.style.background = p_background.replace(/#/g, senderHue);
+}
+
+function checkNewText(target) {
+ if (target.tagName == "P" && target.className != "event-messages") {
+ setColors(target);
+ }
+
+ var nicks = target.getElementsByClassName("ib-nick");
+ for (var i = 0; i < nicks.length; ++i) {
+ var nick = nicks[i];
+ if (!nick.hasAttribute("data-left")) {
+ nick.style.background = nick_background.replace(
+ /#/g,
+ nick.getAttribute("data-nickColor")
+ );
+ }
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
diff --git a/comm/mail/components/im/messages/dark/main.css b/comm/mail/components/im/messages/dark/main.css
new file mode 100644
index 0000000000..b3f94d9d2c
--- /dev/null
+++ b/comm/mail/components/im/messages/dark/main.css
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+body {
+ margin: 0;
+ padding: 0;
+ background-color: black;
+}
+
+p {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+p.message {
+ margin: 0;
+ padding: 4px 15px 6px 15px;
+ border-bottom: 1px solid black;
+ border-top: 1px solid rgba(255, 255, 255, 0.3);
+ background: -moz-linear-gradient(top, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.07) 30px);
+}
+
+p.context:not(:hover) {
+ opacity: 0.5;
+ color: rgba(255, 255, 255, 1);
+}
+
+span.message-style,
+p.event-messages {
+ font-size: 90%;
+}
+
+p.event-messages {
+ margin: 5px 0px 5px 0px;
+ text-align: center;
+ opacity: 0.4;
+ -moz-transition-property: opacity;
+ -moz-transition-duration: 0.3s;
+}
+
+p.event-messages:hover {
+ opacity: 1;
+}
+
+.message-style {
+ display: block;
+}
+
+.pseudo {
+ margin-bottom: 3px;
+ font-weight: bold;
+ color: white;
+ display: block;
+}
+
+.nick > .message-style {
+ font-weight: bold;
+}
+
+.action > .message-style {
+ font-style: italic;
+}
+
+.action > .message-style::before {
+ content: "*** ";
+}
+
+a,
+a:hover {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+a:active {
+ color: rgba(255, 255, 255, 1);
+}
+
+a:visited {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+#Chat {
+ white-space: normal;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.ib-nick {
+ color: white !important;
+ border-radius: 3px;
+ padding: 0 0.25em;
+}
+
+.ib-nick[left] {
+ color: white !important;
+ background-color: black;
+ opacity: 0.4;
+ -moz-transition-property: opacity;
+ -moz-transition-duration: 0.3s;
+}
+
+.ib-nick[left]:hover {
+ opacity: 1;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::after {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 11px;
+ opacity: 0.5;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-start: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
diff --git a/comm/mail/components/im/messages/mail/Footer.html b/comm/mail/components/im/messages/mail/Footer.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Footer.html
diff --git a/comm/mail/components/im/messages/mail/Header.html b/comm/mail/components/im/messages/mail/Header.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Header.html
diff --git a/comm/mail/components/im/messages/mail/Incoming/Content.html b/comm/mail/components/im/messages/mail/Incoming/Content.html
new file mode 100644
index 0000000000..cfc6270d37
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/Content.html
@@ -0,0 +1 @@
+<div class="%messageClasses%" data-prpl="%service%"><div class="sidebar"><img src="%userIconPath%" alt="" class="usericon"/><div class="date">%time{%H:%M}%</div></div><div class="body"><div class="pseudo" style="%senderColor%">%sender%</div>%message%</div></div>
diff --git a/comm/mail/components/im/messages/mail/Incoming/Context.html b/comm/mail/components/im/messages/mail/Incoming/Context.html
new file mode 100644
index 0000000000..6a297f0fba
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/Context.html
@@ -0,0 +1 @@
+<div class="context %messageClasses%" data-prpl="%service%"><div class="sidebar"><img src="%userIconPath%" alt="" class="usericon"/><div class="date">%time{%H:%M}%</div></div><div class="body"><div class="pseudo" style="%senderColor%">%sender%</div>%message%</div></div>
diff --git a/comm/mail/components/im/messages/mail/Incoming/NextContent.html b/comm/mail/components/im/messages/mail/Incoming/NextContent.html
new file mode 100644
index 0000000000..02c51fd70a
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/NextContent.html
@@ -0,0 +1 @@
+<div class="%messageClasses%" data-prpl="%service%"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body">%message%</div></div>
diff --git a/comm/mail/components/im/messages/mail/Incoming/NextContext.html b/comm/mail/components/im/messages/mail/Incoming/NextContext.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/NextContext.html
diff --git a/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg b/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg
new file mode 100644
index 0000000000..6f9e4e7b93
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
+ <path fill="context-fill" fill-opacity="0.25" d="M2 48v-8c-.06-7.74 15.71-6.56 16.01-11.12.1-1.33.34-1.66-.23-3.08-.98-.65-1.41-2.86-1.52-4.1 0-.97-.95-.24-1.01-1.39-.32-1.5-.46-2.91.14-4.37.55-.47.83.74.83-.13a8.1 8.1 0 01.64-4.52c1.27-4.73 11.16-4.57 13.54.36.7 1.98.61 2.86.76 4.84 0 .84.4-.61.81.1a7.9 7.9 0 01-.1 4.01c-.53 1.95-1.39.16-1.52 1.52-.6 1.24-.32 3.04-1.8 3.73-.46 1.13-.28 1.85-.14 2.99 0 4.38 15.1 4.14 15.59 11.16v7.86"/>
+</svg>
diff --git a/comm/mail/components/im/messages/mail/Info.plist b/comm/mail/components/im/messages/mail/Info.plist
new file mode 100644
index 0000000000..042b7b49bb
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Info.plist
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%message%</string>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleGetInfoString</key>
+ <string>Thunderbird Message Style</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.mozilla.thunderbird.message.style</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+ <key>CFBundleName</key>
+ <string>Minimal</string>
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+ <key>DefaultVariant</key>
+ <string>Light</string>
+ <key>DisableCustomBackground</key>
+ <false/>
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/mail/NextStatus.html b/comm/mail/components/im/messages/mail/NextStatus.html
new file mode 100644
index 0000000000..26dd6fac41
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/NextStatus.html
@@ -0,0 +1 @@
+<div class="event-row"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body"><p class="event-paragraph">%message%</p></div></div><span id="insert"/>
diff --git a/comm/mail/components/im/messages/mail/Outgoing/Content.html b/comm/mail/components/im/messages/mail/Outgoing/Content.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/Content.html
diff --git a/comm/mail/components/im/messages/mail/Outgoing/Context.html b/comm/mail/components/im/messages/mail/Outgoing/Context.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/Context.html
diff --git a/comm/mail/components/im/messages/mail/Outgoing/NextContent.html b/comm/mail/components/im/messages/mail/Outgoing/NextContent.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/NextContent.html
diff --git a/comm/mail/components/im/messages/mail/Outgoing/NextContext.html b/comm/mail/components/im/messages/mail/Outgoing/NextContext.html
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Outgoing/NextContext.html
diff --git a/comm/mail/components/im/messages/mail/Status.html b/comm/mail/components/im/messages/mail/Status.html
new file mode 100644
index 0000000000..a59a34e211
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Status.html
@@ -0,0 +1 @@
+<div aria-live="polite" class="%messageClasses%"><div class="event-row"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body"><p class="event-paragraph">%message%</p></div></div><span id="insert"/></div>
diff --git a/comm/mail/components/im/messages/mail/Variants/Dark.css b/comm/mail/components/im/messages/mail/Variants/Dark.css
new file mode 100644
index 0000000000..63044cc7fa
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Variants/Dark.css
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+body {
+ background-color: #18181a;
+ color: #f9f9fa;
+}
+
+#Chat .event p {
+ color: #999;
+}
+
+#Chat #unread-ruler {
+ border-top: 1px solid #30e60b;
+}
+
+.message:hover,
+.message:focus {
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+.outgoing .pseudo {
+ color: #007cff;
+}
+
+.incoming .pseudo {
+ color: #e5509f;
+}
+
+.date {
+ color: #999;
+}
+
+.ib-sender.message-encrypted::before {
+ fill: #fff;
+}
+
+.context {
+ color: #aeaeaf;
+}
+
+.sessionstart-ruler {
+ border-top: 1px solid #e9e9ea;
+}
+
+.eventToggle {
+ stroke: #fff;
+}
diff --git a/comm/mail/components/im/messages/mail/Variants/Light.css b/comm/mail/components/im/messages/mail/Variants/Light.css
new file mode 100644
index 0000000000..7f1404cf9c
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/Variants/Light.css
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+body {
+ background-color: white;
+ color: black;
+}
+
+#Chat .event p {
+ color: GrayText;
+}
+
+#Chat #unread-ruler {
+ border-top: 1px solid #30e60b;
+}
+
+.message:hover,
+.message:focus {
+ background-color: rgba(0, 0, 0, 0.03);
+}
+
+.outgoing .pseudo {
+ color: #0060DF;
+}
+
+.incoming .pseudo {
+ color: #B5007F;
+}
+
+.date {
+ color: GrayText;
+}
+
+.ib-sender.message-encrypted::before {
+ fill: #000;
+}
+
+.context {
+ color: rgb(91, 91, 91);
+}
+
+.sessionstart-ruler {
+ border-top: 1px solid ThreeDDarkShadow;
+}
+
+.eventToggle {
+ stroke: #000;
+}
diff --git a/comm/mail/components/im/messages/mail/inline.js b/comm/mail/components/im/messages/mail/inline.js
new file mode 100644
index 0000000000..a6e7f72302
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/inline.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function checkNewText(target) {
+ if (target.className == "event-row") {
+ let parent = target.closest(".event");
+ // We need to start a group with this element if there are at least 4
+ // system messages and they aren't already grouped.
+ if (
+ !parent?.grouped &&
+ parent?.querySelector(".event-row:nth-of-type(4)")
+ ) {
+ let toggle = document.createElement("div");
+ toggle.className = "eventToggle";
+ toggle.addEventListener("click", event => {
+ toggle.closest(".event").classList.toggle("hide-children");
+ });
+ parent.insertBefore(
+ toggle,
+ parent.querySelector(".event-row:nth-of-type(2)")
+ );
+ parent.classList.add("hide-children");
+ parent.grouped = true;
+ }
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
diff --git a/comm/mail/components/im/messages/mail/main.css b/comm/mail/components/im/messages/mail/main.css
new file mode 100644
index 0000000000..1989b2e3d3
--- /dev/null
+++ b/comm/mail/components/im/messages/mail/main.css
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#Chat {
+ white-space: normal;
+}
+
+/* The "#chat " is required to override "#Chat *" from conv.css */
+
+.message {
+ display: flex;
+ align-items: flex-start;
+ margin-block: 5px;
+ padding: 5px 6px;
+ border-radius: 4px;
+}
+
+#Chat .event {
+ display: flex;
+ flex-direction: column;
+ margin-left: 0;
+ clear: none;
+ padding-inline: 6px;
+}
+
+.event-row {
+ display: flex;
+ align-items: start;
+}
+
+#Chat .event p {
+ margin: 0;
+ margin-block-end: 5px;
+}
+
+#Chat #unread-ruler {
+ margin: 4px;
+}
+
+.sidebar {
+ display: flex;
+ justify-content: end;
+ margin-inline-end: 10px;
+ margin-block-start: 2px;
+ width: 4.5em;
+ flex-wrap: wrap;
+ text-align: right;
+}
+
+.body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.pseudo {
+ font-size: 0.9em;
+ font-weight: bold;
+ letter-spacing: 0.01em;
+ margin-block-end: 0;
+}
+
+.message.outgoing + .message.outgoing,
+.message.incoming + .message.incoming {
+ margin-block: 0;
+}
+
+.message:not(.action) > .next {
+ visibility: hidden;
+}
+
+.date {
+ font-size: 0.75em;
+ text-transform: uppercase;
+ font-style: normal;
+ font-weight: normal;
+ white-space: nowrap;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::before {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 11px;
+ opacity: 0.5;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-end: 4px;
+ -moz-context-properties: fill;
+}
+
+.usericon {
+ display: none;
+}
+
+.nick {
+ font-weight: bold;
+}
+
+.nick > .pseudo {
+ text-decoration: underline;
+}
+
+.action {
+ font-style: italic;
+}
+
+.context > .pseudo {
+ opacity: 0.7;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.sessionstart-ruler {
+ margin: 8px 0 12px;
+ width: 100%;
+ border: none;
+}
+
+/* used by javascript */
+.eventToggle {
+ background: var(--icon-nav-down-sm) no-repeat left center;
+ margin-bottom: -22px;
+ cursor: pointer;
+ height: 22px;
+ width: 20px;
+ z-index: 1;
+ opacity: 0.5;
+ -moz-context-properties: stroke;
+}
+
+.eventToggle:hover {
+ opacity: 1;
+}
+
+.hide-children > :is(.event-row,hr):not(:first-of-type,:last-of-type,.no-collapse) {
+ display: none;
+}
+
+.hide-children .eventToggle {
+ background: var(--icon-nav-right-sm) no-repeat left center;
+}
+
+.hide-children .eventToggle:-moz-locale-dir(rtl) {
+ background: var(--icon-nav-left-sm) no-repeat right center;
+}
diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/information.png b/comm/mail/components/im/messages/papersheets/Bitmaps/information.png
new file mode 100644
index 0000000000..ff62c80758
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Bitmaps/information.png
Binary files differ
diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png b/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png
new file mode 100644
index 0000000000..f84a080807
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png b/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png
new file mode 100644
index 0000000000..9f5e414f44
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png
Binary files differ
diff --git a/comm/mail/components/im/messages/papersheets/Incoming/Content.html b/comm/mail/components/im/messages/papersheets/Incoming/Content.html
new file mode 100644
index 0000000000..c395055382
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Incoming/Content.html
@@ -0,0 +1,4 @@
+<div class="messages-group %messageClasses%" data-senderColor="%senderColor%">
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="pseudo" style="%senderColor%">%sender%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
+</div>
diff --git a/comm/mail/components/im/messages/papersheets/Incoming/Context.html b/comm/mail/components/im/messages/papersheets/Incoming/Context.html
new file mode 100644
index 0000000000..38c9bc0ee8
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Incoming/Context.html
@@ -0,0 +1,4 @@
+<div class="messages-group context %messageClasses%" data-senderColor="%senderColor%">
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="pseudo" style="%senderColor%">%sender%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
+</div>
diff --git a/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html b/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html
new file mode 100644
index 0000000000..8bba392803
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html
@@ -0,0 +1,3 @@
+<hr/>
+<p class="%messageClasses%"><span class="date date-next">%time%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/papersheets/Info.plist b/comm/mail/components/im/messages/papersheets/Info.plist
new file mode 100644
index 0000000000..420ceb5498
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Info.plist
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%sender% %message%</string>
+
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird PaperSheets Message Style</string>
+
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.papersheets.message.style</string>
+
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+
+ <key>CFBundleName</key>
+ <string>PaperSheets</string>
+
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+
+ <key>DisableCustomBackground</key>
+ <false/>
+
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+
+ <key>ShowsUserIcons</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/papersheets/NextStatus.html b/comm/mail/components/im/messages/papersheets/NextStatus.html
new file mode 100644
index 0000000000..b72b0f30ba
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/NextStatus.html
@@ -0,0 +1,2 @@
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
diff --git a/comm/mail/components/im/messages/papersheets/Status.html b/comm/mail/components/im/messages/papersheets/Status.html
new file mode 100644
index 0000000000..2f1c524a51
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Status.html
@@ -0,0 +1,4 @@
+<div class="messages-group %messageClasses%">
+<p class="%messageClasses%"><span class="date">%time%</span> <span class="message-style">%message%</span></p>
+<div id="insert"/>
+</div>
diff --git a/comm/mail/components/im/messages/papersheets/Variants/White.css b/comm/mail/components/im/messages/papersheets/Variants/White.css
new file mode 100644
index 0000000000..c0221a94fc
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/Variants/White.css
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+div.outgoing {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 255, 1) 15px, rgba(255, 255, 255, 1)) !important;
+}
+
+div.incoming {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 255, 1) 15px, rgba(255, 255, 255, 1)) !important;
+}
+
+
+
+/* used by javascript */
+.outgoing-color {
+ background-color: rgb(255, 255, 255);
+}
+
+.incoming-color {
+ background-color: rgb(255, 255, 255);
+}
diff --git a/comm/mail/components/im/messages/papersheets/inline.js b/comm/mail/components/im/messages/papersheets/inline.js
new file mode 100644
index 0000000000..5c711a34fb
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/inline.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const bg_gradient =
+ "background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, hsla(#, 100%, 98%, 1) 15px, hsla(#, 100%, 98%, 1));";
+const bg_context_gradient =
+ "background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.05) 15px, hsla(#, 20%, 98%, 1) 15px, hsla(#, 20%, 98%, 1));";
+const bg_color = "background-color: hsl(#, 100%, 98%);";
+
+var body = document.getElementById("ibcontent");
+
+function setColors(target) {
+ var senderColor = target.getAttribute("data-senderColor");
+
+ if (senderColor) {
+ var regexp =
+ /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/;
+ var parsed = regexp.exec(senderColor);
+
+ if (parsed) {
+ var senderHue = parsed[1];
+ if (target.classList.contains("context")) {
+ target.setAttribute(
+ "style",
+ bg_context_gradient.replace(/#/g, senderHue)
+ );
+ } else {
+ target.setAttribute("style", bg_gradient.replace(/#/g, senderHue));
+ }
+ }
+ }
+
+ if (body.scrollHeight <= screen.height) {
+ if (senderHue) {
+ body.setAttribute("style", bg_color.replace("#", senderHue));
+ } else if (target.classList.contains("outgoing")) {
+ body.className = "outgoing-color";
+ body.removeAttribute("style");
+ } else if (target.classList.contains("incoming")) {
+ body.className = "incoming-color";
+ body.removeAttribute("style");
+ } else if (target.classList.contains("event")) {
+ body.className = "event-color";
+ body.removeAttribute("style");
+ }
+ }
+}
+
+function checkNewText(target) {
+ if (target.tagName == "DIV") {
+ setColors(target);
+ } else if (target.tagName == "P" && target.className == "event") {
+ let parent = target.parentNode;
+ // We need to start a group with this element if there are at least 3
+ // system messages and they aren't already grouped.
+ if (!parent?.grouped && parent?.querySelector("p.event:nth-of-type(3)")) {
+ var div = document.createElement("div");
+ div.className = "eventToggle";
+ div.addEventListener("click", event =>
+ event.target.parentNode.classList.toggle("hide-children")
+ );
+ parent.insertBefore(div, parent.querySelector("p.event:first-of-type"));
+ parent.classList.add("hide-children");
+ parent.grouped = true;
+ }
+ }
+}
+
+new MutationObserver(function (aMutations) {
+ for (let mutation of aMutations) {
+ for (let node of mutation.addedNodes) {
+ if (node instanceof HTMLElement) {
+ checkNewText(node);
+ }
+ }
+ }
+}).observe(document.getElementById("ibcontent"), {
+ childList: true,
+ subtree: true,
+});
diff --git a/comm/mail/components/im/messages/papersheets/main.css b/comm/mail/components/im/messages/papersheets/main.css
new file mode 100644
index 0000000000..af70637d4f
--- /dev/null
+++ b/comm/mail/components/im/messages/papersheets/main.css
@@ -0,0 +1,208 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+body {
+ margin: 0;
+ padding: 0;
+ color: #000;
+}
+
+p {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+}
+
+div.messages-group {
+ margin: -15px 0 0 0;
+ padding: 18px 5px 20px 5px;
+}
+
+div.outgoing {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(245, 245, 255, 1) 15px, rgba(245, 245, 255, 1));
+}
+
+div.incoming {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 245, 245, 1) 15px, rgba(255, 245, 245, 1));
+}
+
+div.event {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 240, 1) 15px, rgba(255, 255, 240, 1));
+}
+
+div.context+div.event {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.05) 15px, rgba(255, 255, 240, 1) 15px, rgba(255, 255, 240, 1));
+}
+
+div.context:not(:hover) > p {
+ opacity: 0.55;
+}
+
+div.messages-group:last-child {
+ padding-bottom: 10px;
+}
+
+div.messages-group > hr {
+ margin: 3px 50px 0px 20px;
+ background-color: rgba(0, 0, 0, 0.05);
+ height: 1px;
+ border: 0;
+}
+
+span.message-style {
+ margin: 2px 50px 0px 20px;
+ display: block;
+ float: none;
+}
+
+span.date {
+ color: rgba(0, 0, 0, 0.4);
+ font-size: smaller;
+ text-align: right;
+ float: inline-end;
+ display: block;
+}
+
+span.date-next {
+ opacity: 0.4;
+ margin-top: -6px;
+ -moz-transition-property: opacity;
+ -moz-transition-duration: 0.3s;
+}
+
+p:hover > span.date-next {
+ opacity: 1;
+}
+
+span.pseudo {
+ font-weight: bold;
+ float: none;
+ display: block;
+}
+
+p.outgoing > span.pseudo {
+ color: rgb(80,80,200);
+}
+
+p.incoming > span.pseudo {
+ color: rgb(200,80,80);
+}
+
+p.nick > span.message-style {
+ font-weight: bold;
+}
+
+p.action > span.message-style {
+ font-style: italic;
+}
+
+p.action > span.message-style::before {
+ content: "*** ";
+}
+
+p.event {
+ margin-left: 0px;
+ min-height: 16px;
+ background: url('Bitmaps/information.png') no-repeat top left;
+}
+
+p.event > span.message-style {
+ color: rgba(0, 0, 0, 0.4);
+}
+
+#Chat {
+ white-space: normal;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::after {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 11px;
+ opacity: 0.7;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-start: 4px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+/* used by javascript */
+.outgoing-color {
+ background-color: rgb(245, 245, 255);
+}
+
+.incoming-color {
+ background-color: rgb(255, 245, 245);
+}
+
+.event-color {
+ background-color: rgb(255, 255, 240);
+}
+
+.eventToggle {
+ margin-top: -2px;
+ margin-left: -4px;
+ height: 9px;
+ width: 9px;
+ cursor: pointer;
+ background: url('Bitmaps/minus.png') no-repeat left top;
+}
+
+.hide-children > .eventToggle {
+ background-image: url('Bitmaps/plus.png');
+}
+
+.hide-children > p.event:first-of-type > .message-style::after {
+ content: "[\2026]"; /* &hellip; */
+ margin-left: 1em;
+ color: #5a7ac6;
+ font-size: smaller;
+}
+
+.hide-children > p.event:not(:first-of-type,:last-of-type) {
+ display: none;
+}
+
+/* Adapt styles to narrow windows */
+@media all and (max-width: 400px) {
+ div.messages-group > hr {
+ margin-right: 0;
+ }
+
+ span.message-style {
+ margin-right: 0;
+ }
+
+ span.date-next {
+ display: none;
+ }
+}
+
+@media all and (max-width: 200px) {
+ span.date {
+ display: none;
+ }
+}
+
+/* Adapt styles when the window is very low */
+@media all and (max-height: 200px) {
+ div.messages-group {
+ padding-bottom: 8px;
+ }
+
+ div.messages-group:last-child {
+ padding-bottom: 8px;
+ }
+}
diff --git a/comm/mail/components/im/messages/simple/Incoming/Content.html b/comm/mail/components/im/messages/simple/Incoming/Content.html
new file mode 100644
index 0000000000..ed8630393a
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Incoming/Content.html
@@ -0,0 +1 @@
+<p class="%messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Incoming/Context.html b/comm/mail/components/im/messages/simple/Incoming/Context.html
new file mode 100644
index 0000000000..8b0226d610
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Incoming/Context.html
@@ -0,0 +1 @@
+<p class="context %messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Incoming/NextContext.html b/comm/mail/components/im/messages/simple/Incoming/NextContext.html
new file mode 100644
index 0000000000..8b0226d610
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Incoming/NextContext.html
@@ -0,0 +1 @@
+<p class="context %messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Info.plist b/comm/mail/components/im/messages/simple/Info.plist
new file mode 100644
index 0000000000..f32f062d7d
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Info.plist
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>ActionMessageTemplate</key>
+ <string>%message%</string>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleGetInfoString</key>
+ <string>Instantbird Minimal Message Style</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.instantbird.minimal.message.style</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>1.0</string>
+ <key>CFBundleName</key>
+ <string>Minimal</string>
+ <key>CFBundlePackageType</key>
+ <string>AdIM</string>
+ <key>DefaultBackgroundColor</key>
+ <string>FFFFFF</string>
+ <key>DefaultVariant</key>
+ <string>Normal</string>
+ <key>DisableCustomBackground</key>
+ <false/>
+ <key>MessageViewVersion</key>
+ <integer>4</integer>
+ <key>ShowsUserIcons</key>
+ <true/>
+ <key>NoScript</key>
+ <true/>
+</dict>
+</plist>
diff --git a/comm/mail/components/im/messages/simple/Status.html b/comm/mail/components/im/messages/simple/Status.html
new file mode 100644
index 0000000000..ce30b16cec
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Status.html
@@ -0,0 +1 @@
+<p aria-live="polite" class="%messageClasses%"><span class="date">%time%</span>%message%</p>
diff --git a/comm/mail/components/im/messages/simple/Variants/Dark.css b/comm/mail/components/im/messages/simple/Variants/Dark.css
new file mode 100644
index 0000000000..ea5f0b8f5b
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Variants/Dark.css
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+body {
+ background: #222;
+ color: #eee;
+}
+.outgoing .pseudo {
+ color: #7878dc;
+}
+.incoming .pseudo {
+ color: #dc7878;
+}
+.event {
+ color: #828282;
+}
+a {
+ color: #5497ea;
+}
+.context {
+ color: #b2b2b4;
+}
diff --git a/comm/mail/components/im/messages/simple/Variants/Normal.css b/comm/mail/components/im/messages/simple/Variants/Normal.css
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/Variants/Normal.css
diff --git a/comm/mail/components/im/messages/simple/main.css b/comm/mail/components/im/messages/simple/main.css
new file mode 100644
index 0000000000..3baf44d1ab
--- /dev/null
+++ b/comm/mail/components/im/messages/simple/main.css
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#Chat {
+ white-space: normal;
+}
+
+.pseudo {
+ font-weight: bold;
+}
+
+.outgoing .pseudo {
+ color: rgb(80,80,200);
+}
+
+.incoming .pseudo {
+ color: rgb(200,80,80);
+}
+
+.date {
+ font-style: normal;
+ font-weight: normal;
+}
+
+span.date::after {
+ content: " - ";
+}
+
+.action > span.date::after {
+ content: " * ";
+}
+
+span.pseudo::after {
+ content: ": ";
+}
+
+.action > span.pseudo::after {
+ content: " ";
+}
+
+.event > span.pseudo::after {
+ content: none;
+}
+
+.event {
+ color: rgb(170,170,170);
+}
+
+.nick {
+ font-weight: bold;
+}
+
+.action {
+ font-style: italic;
+}
+
+.context {
+ color: rgb(91,91,91);
+}
+
+p.context > .pseudo,
+p.context .ib-nick {
+ opacity: 0.7;
+}
+
+p {
+ margin: 0px auto;
+}
+
+p *:any-link img {
+ margin-bottom: 1px;
+ border-bottom: solid 1px;
+}
+
+.ib-sender.message-encrypted {
+ position: relative;
+}
+
+.ib-sender.message-encrypted::before {
+ position: relative;
+ display: inline-block;
+ content: '';
+ width: 11px;
+ height: 10px;
+ opacity: 0.5;
+ background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center;
+ background-size: contain;
+ margin-inline-end: 4px;
+}
diff --git a/comm/mail/components/im/modules/ChatEncryption.sys.mjs b/comm/mail/components/im/modules/ChatEncryption.sys.mjs
new file mode 100644
index 0000000000..4206b3397d
--- /dev/null
+++ b/comm/mail/components/im/modules/ChatEncryption.sys.mjs
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ OTRUI: "resource:///modules/OTRUI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/otr/otrUI.ftl"], true)
+);
+
+function _str(id) {
+ return lazy.l10n.formatValueSync(id);
+}
+
+const STATE_STRING = {
+ [Ci.prplIConversation.ENCRYPTION_AVAILABLE]: "not-private",
+ [Ci.prplIConversation.ENCRYPTION_ENABLED]: "unverified",
+ [Ci.prplIConversation.ENCRYPTION_TRUSTED]: "private",
+};
+
+export const ChatEncryption = {
+ /**
+ * If OTR is enabled.
+ *
+ * @type {boolean}
+ */
+ get otrEnabled() {
+ if (!this.hasOwnProperty("_otrEnabled")) {
+ this._otrEnabled = Services.prefs.getBoolPref("chat.otr.enable");
+ }
+ return this._otrEnabled;
+ },
+ /**
+ * Check if the given protocol has encryption settings for accounts.
+ *
+ * @param {prplIProtocol} protocol - Protocol to check against.
+ * @returns {boolean} If encryption can be configured.
+ */
+ canConfigureEncryption(protocol) {
+ if (this.otrEnabled && lazy.OTRUI.enabled) {
+ return true;
+ }
+ return protocol.canEncrypt;
+ },
+ /**
+ * Check if the conversation should offer encryption settings.
+ *
+ * @param {prplIConversation} conversation
+ * @returns {boolean}
+ */
+ hasEncryptionActions(conversation) {
+ if (!conversation.isChat && this.otrEnabled && lazy.OTRUI.enabled) {
+ return true;
+ }
+ return (
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ );
+ },
+ /**
+ * Show and initialize the encryption selector in the conversation UI for the
+ * given conversation, if encryption is available.
+ *
+ * @param {DOMDocument} document
+ * @param {imIConversation} conversation
+ */
+ updateEncryptionButton(document, conversation) {
+ if (!this.hasEncryptionActions(conversation)) {
+ this.hideEncryptionButton(document);
+ }
+ if (
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ ) {
+ // OTR is not available if the conversation can natively encrypt
+ document.querySelector(".otr-start").hidden = true;
+ document.querySelector(".otr-end").hidden = true;
+ document.querySelector(".otr-auth").hidden = true;
+ lazy.OTRUI.hideAllOTRNotifications();
+
+ const actionsAvailable =
+ conversation.encryptionState !==
+ Ci.prplIConversation.ENCRYPTION_AVAILABLE;
+
+ document.querySelector(".protocol-encrypt").hidden = false;
+ document.querySelector(".protocol-encrypt").disabled = actionsAvailable;
+ document.querySelector(".encryption-container").hidden = false;
+
+ const trustStringLevel = STATE_STRING[conversation.encryptionState];
+ const otrButton = document.querySelector(".encryption-button");
+ otrButton.setAttribute(
+ "tooltiptext",
+ _str("state-generic-" + trustStringLevel)
+ );
+ otrButton.setAttribute(
+ "label",
+ _str("state-" + trustStringLevel + "-label")
+ );
+ otrButton.className = "encryption-button encryption-" + trustStringLevel;
+ } else if (!conversation.isChat && lazy.OTRUI.enabled) {
+ document.querySelector(".otr-start").hidden = false;
+ document.querySelector(".otr-end").hidden = false;
+ document.querySelector(".otr-auth").hidden = false;
+ lazy.OTRUI.updateOTRButton(conversation);
+ document.querySelector(".protocol-encrypt").hidden = true;
+ } else {
+ this.hideEncryptionButton(document);
+ }
+ },
+ /**
+ * Hide the encryption selector in the converstaion UI.
+ *
+ * @param {DOMDocument} document
+ */
+ hideEncryptionButton(document) {
+ document.querySelector(".encryption-container").hidden = true;
+ if (this.otrEnabled) {
+ lazy.OTRUI.hideOTRButton();
+ }
+ },
+ /**
+ * Verify identity of a participant of buddy.
+ *
+ * @param {DOMWindow} window - Window that the verification dialog attaches to.
+ * @param {prplIAccountBuddy|prplIConvChatBuddy} buddy - Buddy to verify.
+ */
+ verifyIdentity(window, buddy) {
+ if (!buddy.canVerifyIdentity) {
+ Promise.resolve();
+ }
+ buddy
+ .verifyIdentity()
+ .then(sessionVerification => {
+ window.openDialog(
+ "chrome://messenger/content/chat/verify.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ sessionVerification
+ );
+ })
+ .catch(error => {
+ // Only prplIAccountBuddy has a reference to the owner account.
+ if (buddy.account) {
+ buddy.account.prplAccount.wrappedJSObject.ERROR(error);
+ } else {
+ console.error(error);
+ }
+ });
+ },
+};
diff --git a/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs
new file mode 100644
index 0000000000..f97519ddea
--- /dev/null
+++ b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs
@@ -0,0 +1,352 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { Gloda } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+
+/**
+ * How much time boost should a 'score point' amount to? The authoritative,
+ * incontrivertible answer, across all time and space, is a week.
+ * Note that gloda stores conversation timestamps in seconds.
+ */
+// var FUZZSCORE_TIMESTAMP_FACTOR = 60 * 60 * 24 * 7;
+
+// var RANK_USAGE =
+// "glodaRank(matchinfo(imConversationsText), 1.0, 2.0, 2.0, 1.5, 1.5)";
+
+var DASCORE = "imConversations.time";
+// "(((" + RANK_USAGE + ") * " +
+// FUZZSCORE_TIMESTAMP_FACTOR +
+// ") + imConversations.time)";
+
+/**
+ * A new optimization decision we are making is that we do not want to carry
+ * around any data in our ephemeral tables that is not used for whittling the
+ * result set. The idea is that the btree page cache or OS cache is going to
+ * save us from the disk seeks and carrying around the extra data is just going
+ * to be CPU/memory churn that slows us down.
+ *
+ * Additionally, we try and avoid row lookups that would have their results
+ * discarded by the LIMIT. Because of limitations in FTS3 (which might
+ * be addressed in FTS4 by a feature request), we can't avoid the 'imConversations'
+ * lookup since that has the message's date and static notability but we can
+ * defer the 'imConversationsText' lookup.
+ *
+ * This is the access pattern we are after here:
+ * 1) Order the matches with minimized lookup and result storage costs.
+ * - The innermost MATCH does the doclist magic and provides us with
+ * matchinfo() support which does not require content row retrieval
+ * from imConversationsText. Unfortunately, this is not enough to whittle anything
+ * because we still need static interestingness, so...
+ * - Based on the match we retrieve the date and notability for that row from
+ * 'imConversations' using this in conjunction with matchinfo() to provide a score
+ * that we can then use to LIMIT our results.
+ * 2) We reissue the MATCH query so that we will be able to use offsets(), but
+ * we intersect the results of this MATCH against our LIMITed results from
+ * step 1.
+ * - We use 'docid IN (phase 1 query)' to accomplish this because it results in
+ * efficient lookup. If we just use a join, we get O(mn) performance because
+ * a cartesian join ends up being performed where either we end up performing
+ * the fulltext query M times and table scan intersect with the results from
+ * phase 1 or we do the fulltext once but traverse the entire result set from
+ * phase 1 N times.
+ * - We believe that the re-execution of the MATCH query should have no disk
+ * costs because it should still be cached by SQLite or the OS. In the case
+ * where memory is so constrained this is not true our behavior is still
+ * probably preferable than the old way because that would have caused lots
+ * of swapping.
+ * - This part of the query otherwise resembles the basic gloda query but with
+ * the inclusion of the offsets() invocation. The imConversations table lookup
+ * should not involve any disk traffic because the pages should still be
+ * cached (SQLite or OS) from phase 1. The imConversationsText lookup is new, and
+ * this is the major disk-seek reduction optimization we are making. (Since
+ * we avoid this lookup for all of the documents that were excluded by the
+ * LIMIT.) Since offsets() also needs to retrieve the row from imConversationsText
+ * there is a nice synergy there.
+ */
+var NUEVO_FULLTEXT_SQL =
+ "SELECT imConversations.*, imConversationsText.*, offsets(imConversationsText) AS osets " +
+ "FROM imConversationsText, imConversations " +
+ "WHERE" +
+ " imConversationsText MATCH ?1 " +
+ " AND imConversationsText.docid IN (" +
+ "SELECT docid " +
+ "FROM imConversationsText JOIN imConversations ON imConversationsText.docid = imConversations.id " +
+ "WHERE imConversationsText MATCH ?1 " +
+ "ORDER BY " +
+ DASCORE +
+ " DESC " +
+ "LIMIT ?2" +
+ " )" +
+ " AND imConversations.id = imConversationsText.docid";
+
+function identityFunc(x) {
+ return x;
+}
+
+function oneLessMaxZero(x) {
+ if (x <= 1) {
+ return 0;
+ }
+ return x - 1;
+}
+
+function reduceSum(accum, curValue) {
+ return accum + curValue;
+}
+
+/*
+ * Columns are: body, subject, attachment names, author, recipients
+ */
+
+/**
+ * Scores if all search terms match in a column. We bias against author
+ * slightly and recipient a bit more in this case because a search that
+ * entirely matches just on a person should give a mention of that person
+ * in the subject or attachment a fighting chance.
+ * Keep in mind that because of our indexing in the face of address book
+ * contacts (namely, we index the name used in the e-mail as well as the
+ * display name on the address book card associated with the e-mail address)
+ * a contact is going to bias towards matching multiple times.
+ */
+var COLUMN_ALL_MATCH_SCORES = [4, 20, 20, 16, 12];
+/**
+ * Score for each distinct term that matches in the column. This is capped
+ * by COLUMN_ALL_SCORES.
+ */
+var COLUMN_PARTIAL_PER_MATCH_SCORES = [1, 4, 4, 4, 3];
+/**
+ * If a term matches multiple times, what is the marginal score for each
+ * additional match. We count the total number of matches beyond the
+ * first match for each term. In other words, if we have 3 terms which
+ * matched 5, 3, and 0 times, then the total from our perspective is
+ * (5 - 1) + (3 - 1) + 0 = 4 + 2 + 0 = 6. We take the minimum of that value
+ * and the value in COLUMN_MULTIPLE_MATCH_LIMIT and multiply by the value in
+ * COLUMN_MULTIPLE_MATCH_SCORES.
+ */
+var COLUMN_MULTIPLE_MATCH_SCORES = [1, 0, 0, 0, 0];
+var COLUMN_MULTIPLE_MATCH_LIMIT = [10, 0, 0, 0, 0];
+
+/**
+ * Score the message on its offsets (from stashedColumns).
+ */
+function scoreOffsets(aMessage, aContext) {
+ let score = 0;
+
+ let termTemplate = aContext.terms.map(_ => 0);
+ // for each column, a list of the incidence of each term
+ let columnTermIncidence = [
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ termTemplate.concat(),
+ ];
+
+ // we need a friendlyParseInt because otherwise the radix stuff happens
+ // because of the extra arguments map parses. curse you, map!
+ let offsetNums = aContext.stashedColumns[aMessage.id][0]
+ .split(" ")
+ .map(x => parseInt(x));
+ for (let i = 0; i < offsetNums.length; i += 4) {
+ let columnIndex = offsetNums[i];
+ let termIndex = offsetNums[i + 1];
+ columnTermIncidence[columnIndex][termIndex]++;
+ }
+
+ for (let iColumn = 0; iColumn < COLUMN_ALL_MATCH_SCORES.length; iColumn++) {
+ let termIncidence = columnTermIncidence[iColumn];
+ if (termIncidence.every(identityFunc)) {
+ // Bestow all match credit.
+ score += COLUMN_ALL_MATCH_SCORES[iColumn];
+ } else if (termIncidence.some(identityFunc)) {
+ // Bestow partial match credit.
+ score += Math.min(
+ COLUMN_ALL_MATCH_SCORES[iColumn],
+ COLUMN_PARTIAL_PER_MATCH_SCORES[iColumn] *
+ termIncidence.filter(identityFunc).length
+ );
+ }
+ // bestow multiple match credit
+ score +=
+ Math.min(
+ termIncidence.map(oneLessMaxZero).reduce(reduceSum, 0),
+ COLUMN_MULTIPLE_MATCH_LIMIT[iColumn]
+ ) * COLUMN_MULTIPLE_MATCH_SCORES[iColumn];
+ }
+
+ return score;
+}
+
+/**
+ * The searcher basically looks like a query, but is specialized for fulltext
+ * search against imConversations. Most of the explicit specialization involves
+ * crafting a SQL query that attempts to order the matches by likelihood that
+ * the user was looking for it. This is based on full-text matches combined
+ * with an explicit (generic) interest score value placed on the message at
+ * indexing time (TODO). This is followed by using the more generic gloda scoring
+ * mechanism to explicitly score the IM conversations given the search context in
+ * addition to the more generic score adjusting rules.
+ */
+export function GlodaIMSearcher(aListener, aSearchString, aAndTerms) {
+ this.listener = aListener;
+
+ this.searchString = aSearchString;
+ this.fulltextTerms = this.parseSearchString(aSearchString);
+ this.andTerms = aAndTerms != null ? aAndTerms : true;
+
+ this.query = null;
+ this.collection = null;
+
+ this.scores = null;
+}
+
+GlodaIMSearcher.prototype = {
+ /**
+ * Number of messages to retrieve initially.
+ */
+ get retrievalLimit() {
+ return Services.prefs.getIntPref(
+ "mailnews.database.global.search.im.limit"
+ );
+ },
+
+ /**
+ * Parse the string into terms/phrases by finding matching double-quotes.
+ */
+ parseSearchString(aSearchString) {
+ aSearchString = aSearchString.trim();
+ let terms = [];
+
+ /*
+ * Add the term as long as the trim on the way in didn't obliterate it.
+ *
+ * In the future this might have other helper logic; it did once before.
+ */
+ function addTerm(aTerm) {
+ if (aTerm) {
+ terms.push(aTerm);
+ }
+ }
+
+ while (aSearchString) {
+ if (aSearchString.startsWith('"')) {
+ let endIndex = aSearchString.indexOf(aSearchString[0], 1);
+ // eat the quote if it has no friend
+ if (endIndex == -1) {
+ aSearchString = aSearchString.substring(1);
+ continue;
+ }
+
+ addTerm(aSearchString.substring(1, endIndex).trim());
+ aSearchString = aSearchString.substring(endIndex + 1);
+ continue;
+ }
+
+ let spaceIndex = aSearchString.indexOf(" ");
+ if (spaceIndex == -1) {
+ addTerm(aSearchString);
+ break;
+ }
+
+ addTerm(aSearchString.substring(0, spaceIndex));
+ aSearchString = aSearchString.substring(spaceIndex + 1);
+ }
+
+ return terms;
+ },
+
+ buildFulltextQuery() {
+ let query = Gloda.newQuery(Gloda.lookupNoun("im-conversation"), {
+ noMagic: true,
+ explicitSQL: NUEVO_FULLTEXT_SQL,
+ limitClauseAlreadyIncluded: true,
+ // osets is 0-based column number 4 (volatile to column changes)
+ // save the offset column for extra analysis
+ stashColumns: [6],
+ });
+
+ let fulltextQueryString = "";
+
+ for (let [iTerm, term] of this.fulltextTerms.entries()) {
+ if (iTerm) {
+ fulltextQueryString += this.andTerms ? " " : " OR ";
+ }
+
+ // Put our term in quotes. This is needed for the tokenizer to be able
+ // to do useful things. The exception is people clever enough to use
+ // NEAR.
+ if (/^NEAR(\/\d+)?$/.test(term)) {
+ fulltextQueryString += term;
+ } else if (term.length == 1 && term.charCodeAt(0) >= 0x2000) {
+ // This is a single-character CJK search query, so add a wildcard.
+ // Our tokenizer treats anything at/above 0x2000 as CJK for now.
+ fulltextQueryString += term + "*";
+ } else if (
+ (term.length == 2 &&
+ term.charCodeAt(0) >= 0x2000 &&
+ term.charCodeAt(1) >= 0x2000) ||
+ term.length >= 3
+ ) {
+ fulltextQueryString += '"' + term + '"';
+ }
+ }
+
+ query.fulltextMatches(fulltextQueryString);
+ query.limit(this.retrievalLimit);
+
+ return query;
+ },
+
+ getCollection(aListenerOverride, aData) {
+ if (aListenerOverride) {
+ this.listener = aListenerOverride;
+ }
+
+ this.query = this.buildFulltextQuery();
+ this.collection = this.query.getCollection(this, aData);
+ this.completed = false;
+
+ return this.collection;
+ },
+
+ sortBy: "-dascore",
+
+ onItemsAdded(aItems, aCollection) {
+ let newScores = Gloda.scoreNounItems(
+ aItems,
+ {
+ terms: this.fulltextTerms,
+ stashedColumns: aCollection.stashedColumns,
+ },
+ [scoreOffsets]
+ );
+ if (this.scores) {
+ this.scores = this.scores.concat(newScores);
+ } else {
+ this.scores = newScores;
+ }
+
+ if (this.listener) {
+ this.listener.onItemsAdded(aItems, aCollection);
+ }
+ },
+ onItemsModified(aItems, aCollection) {
+ if (this.listener) {
+ this.listener.onItemsModified(aItems, aCollection);
+ }
+ },
+ onItemsRemoved(aItems, aCollection) {
+ if (this.listener) {
+ this.listener.onItemsRemoved(aItems, aCollection);
+ }
+ },
+ onQueryCompleted(aCollection) {
+ this.completed = true;
+ if (this.listener) {
+ this.listener.onQueryCompleted(aCollection);
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/chatHandler.sys.mjs b/comm/mail/components/im/modules/chatHandler.sys.mjs
new file mode 100644
index 0000000000..4b54535aa5
--- /dev/null
+++ b/comm/mail/components/im/modules/chatHandler.sys.mjs
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+export var allContacts = {};
+export var onlineContacts = {};
+
+export var ChatCore = {
+ initialized: false,
+ _initializing: false,
+ init() {
+ if (this._initializing) {
+ return;
+ }
+ this._initializing = true;
+
+ Services.obs.addObserver(this, "browser-request");
+ Services.obs.addObserver(this, "contact-signed-on");
+ Services.obs.addObserver(this, "contact-signed-off");
+ Services.obs.addObserver(this, "contact-added");
+ Services.obs.addObserver(this, "contact-removed");
+ },
+ idleStart() {
+ IMServices.core.init();
+
+ // Find the accounts that exist in the im account service but
+ // not in nsMsgAccountManager. They have probably been lost if
+ // the user has used an older version of Thunderbird on a
+ // profile with IM accounts. See bug 736035.
+ let accountsById = {};
+ for (let account of IMServices.accounts.getAccounts()) {
+ accountsById[account.numericId] = account;
+ }
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ delete accountsById[incomingServer.wrappedJSObject.imAccount.numericId];
+ }
+ // Let's recreate each of them...
+ for (let id in accountsById) {
+ let account = accountsById[id];
+ let inServer = MailServices.accounts.createIncomingServer(
+ account.name,
+ account.protocol.id, // hostname
+ "im"
+ );
+ inServer.wrappedJSObject.imAccount = account;
+ let acc = MailServices.accounts.createAccount();
+ // Avoid new folder notifications.
+ inServer.valid = false;
+ acc.incomingServer = inServer;
+ inServer.valid = true;
+ MailServices.accounts.notifyServerLoaded(inServer);
+ }
+
+ IMServices.tags.getTags().forEach(function (aTag) {
+ aTag.getContacts().forEach(function (aContact) {
+ let name = aContact.preferredBuddy.normalizedName;
+ allContacts[name] = aContact;
+ });
+ });
+
+ ChatCore.initialized = true;
+ Services.obs.notifyObservers(null, "chat-core-initialized");
+ ChatCore._initializing = false;
+ },
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "browser-request") {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ aSubject
+ );
+ return;
+ }
+
+ if (aTopic == "contact-signed-on") {
+ onlineContacts[aSubject.preferredBuddy.normalizedName] = aSubject;
+ return;
+ }
+
+ if (aTopic == "contact-signed-off") {
+ delete onlineContacts[aSubject.preferredBuddy.normalizedName];
+ return;
+ }
+
+ if (aTopic == "contact-added") {
+ allContacts[aSubject.preferredBuddy.normalizedName] = aSubject;
+ return;
+ }
+
+ if (aTopic == "contact-removed") {
+ delete allContacts[aSubject.preferredBuddy.normalizedName];
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/chatIcons.sys.mjs b/comm/mail/components/im/modules/chatIcons.sys.mjs
new file mode 100644
index 0000000000..e965c23183
--- /dev/null
+++ b/comm/mail/components/im/modules/chatIcons.sys.mjs
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export var ChatIcons = {
+ /**
+ * Get the icon URI for the given protocol.
+ *
+ * @param {prplIProtocol} protocol - The protocol to get the icon URI for.
+ * @param {16|32|48} [size=16] - The width and height of the icon.
+ *
+ * @returns {string} - The icon's URI.
+ */
+ getProtocolIconURI(protocol, size = 16) {
+ return `${protocol.iconBaseURI}icon${size === 16 ? "" : size}.png`;
+ },
+
+ /**
+ * Sets the opacity of the given protocol icon depending on the given chat
+ * status (see getStatusIconURI).
+ *
+ * @param {HTMLImageElement} protoIconElement - The protocol icon.
+ * @param {string} statusName - The name for the chat status.
+ */
+ setProtocolIconOpacity(protoIconElement, statusName) {
+ switch (statusName) {
+ case "unknown":
+ case "offline":
+ case "left":
+ protoIconElement.classList.add("protoIconDimmed");
+ break;
+ default:
+ protoIconElement.classList.remove("protoIconDimmed");
+ }
+ },
+
+ fallbackUserIconURI: "chrome://messenger/skin/icons/userIcon.svg",
+
+ /**
+ * Set up the user icon to show the given uri, or a fallback.
+ *
+ * @param {HTMLImageElement} userIconElement - An icon with the "userIcon"
+ * class.
+ * @param {string|null} iconUri - The uri to set, or "" to use a fallback
+ * icon, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIconSrc(userIconElement, iconUri, useFallback) {
+ if (iconUri) {
+ userIconElement.setAttribute("src", iconUri);
+ userIconElement.classList.remove("fillUserIcon");
+ } else if (useFallback) {
+ userIconElement.setAttribute("src", this.fallbackUserIconURI);
+ userIconElement.classList.add("fillUserIcon");
+ } else {
+ userIconElement.removeAttribute("src");
+ userIconElement.classList.remove("fillUserIcon");
+ }
+ },
+
+ /**
+ * Get the icon URI for the given chat status. Often given statusName would be
+ * the return of Status.toAttribute for a given status type. But a few more
+ * terms or aliases are supported.
+ *
+ * @param {string} statusName - The name for the chat status.
+ *
+ * @returns {string|null} - The icon URI for the given status, or null if none
+ * exists.
+ */
+ getStatusIconURI(statusName) {
+ switch (statusName) {
+ case "unknown":
+ return "chrome://chat/skin/unknown.svg";
+ case "available":
+ case "connected":
+ return "chrome://messenger/skin/icons/new/status-online.svg";
+ case "unavailable":
+ case "away":
+ return "chrome://messenger/skin/icons/new/status-away.svg";
+ case "offline":
+ case "disconnected":
+ case "invisible":
+ case "left":
+ return "chrome://messenger/skin/icons/new/status-offline.svg";
+ case "connecting":
+ case "disconnecting":
+ case "joining":
+ return "chrome://global/skin/icons/loading.png";
+ case "idle":
+ return "chrome://messenger/skin/icons/new/status-idle.svg";
+ case "mobile":
+ return "chrome://chat/skin/mobile.svg";
+ case "chat":
+ return "chrome://messenger/skin/icons/new/compact/chat.svg";
+ case "chat-left":
+ return "chrome://chat/skin/chat-left.svg";
+ case "active-typing":
+ return "chrome://chat/skin/typing.svg";
+ case "paused-typing":
+ return "chrome://chat/skin/typed.svg";
+ }
+ return null;
+ },
+};
diff --git a/comm/mail/components/im/modules/chatNotifications.sys.mjs b/comm/mail/components/im/modules/chatNotifications.sys.mjs
new file mode 100644
index 0000000000..664fe4e5ca
--- /dev/null
+++ b/comm/mail/components/im/modules/chatNotifications.sys.mjs
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { PluralForm } from "resource://gre/modules/PluralForm.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { ChatIcons } from "resource:///modules/chatIcons.sys.mjs";
+
+// Time in seconds: it is the minimum time of inactivity
+// needed to show the bundled notification.
+var kTimeToWaitForMoreMsgs = 3;
+
+export var Notifications = {
+ get ellipsis() {
+ let ellipsis = "[\u2026]";
+
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ return ellipsis;
+ },
+
+ // Holds the first direct message of a bundle while we wait for further
+ // messages from the same sender to arrive.
+ _heldMessage: null,
+ // Number of messages to be bundled in the notification (excluding
+ // _heldMessage).
+ _msgCounter: 0,
+ // Time the last message was received.
+ _lastMessageTime: 0,
+ // Sender of the last message.
+ _lastMessageSender: null,
+ // timeout Id for the set timeout for showing notification.
+ _timeoutId: null,
+
+ _showMessageNotification(aMessage, aCounter = 0) {
+ // We are about to show the notification, so let's play the notification sound.
+ // We play the sound if the user is away from TB window or even away from chat tab.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (
+ !Services.focus.activeWindow ||
+ win.document.getElementById("tabmail").currentTabInfo.mode.name != "chat"
+ ) {
+ Services.obs.notifyObservers(aMessage, "play-chat-notification-sound");
+ }
+
+ // If TB window has focus, there's no need to show the notification..
+ if (win && win.document.hasFocus()) {
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ return;
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ let messageText, icon, name;
+ let notificationContent = Services.prefs.getIntPref(
+ "mail.chat.notification_info"
+ );
+ // 0 - show all the info,
+ // 1 - show only the sender not the message,
+ // 2 - show no details about the message being notified.
+ switch (notificationContent) {
+ case 0:
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(aMessage.displayMessage, "text/html");
+ let body = doc.querySelector("body");
+ let encoder = Cu.createDocumentEncoder("text/plain");
+ encoder.init(doc, "text/plain", 0);
+ encoder.setNode(body);
+ messageText = encoder.encodeToString().replace(/\s+/g, " ");
+
+ // Crop the end of the text if needed.
+ if (messageText.length > 50) {
+ messageText = messageText.substr(0, 50);
+ if (aCounter == 0) {
+ messageText = messageText + this.ellipsis;
+ }
+ }
+
+ // If there are more messages being bundled, add the count string.
+ // ellipsis is a part of bundledMessagePreview so we don't include it here.
+ if (aCounter > 0) {
+ let bundledMessage = bundle.formatStringFromName(
+ "bundledMessagePreview",
+ [messageText]
+ );
+ messageText = PluralForm.get(aCounter, bundledMessage).replace(
+ "#1",
+ aCounter
+ );
+ }
+ // Falls through
+ case 1:
+ // Use the buddy icon if available for the icon of the notification.
+ let conv = aMessage.conversation;
+ icon = conv.convIconFilename;
+ if (!icon && !conv.isChat) {
+ icon = conv.buddy?.buddyIconFilename;
+ }
+
+ // Handle third person messages
+ name = aMessage.alias || aMessage.who;
+ if (messageText && aMessage.action) {
+ messageText = name + " " + messageText;
+ }
+ // Falls through
+ case 2:
+ if (!icon) {
+ icon = ChatIcons.fallbackUserIconURI;
+ }
+
+ if (!messageText) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/chat.properties"
+ );
+ messageText = bundle.GetStringFromName("messagePreview");
+ }
+ }
+
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ alert.init(
+ "", // name
+ icon,
+ name, // title
+ messageText,
+ true // clickable
+ );
+ // Show the notification!
+ Cc["@mozilla.org/alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .showAlert(alert, (subject, topic, data) => {
+ if (topic != "alertclickcallback") {
+ return;
+ }
+
+ // If there is a timeout set, clear it.
+ clearTimeout(this._timeoutId);
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ this._lastMessageTime = 0;
+ this._lastMessageSender = null;
+ // Focus the conversation if the notification is clicked.
+ let uiConv = IMServices.conversations.getUIConversation(
+ aMessage.conversation
+ );
+ let mainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mainWindow) {
+ mainWindow.focus();
+ mainWindow.showChatTab();
+ mainWindow.chatHandler.focusConversation(uiConv);
+ } else {
+ Services.appShell.hiddenDOMWindow.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "chat",
+ tabParams: { convType: "focus", conv: uiConv },
+ }
+ );
+ }
+ if (AppConstants.platform == "macosx") {
+ Cc["@mozilla.org/widget/macdocksupport;1"]
+ .getService(Ci.nsIMacDockSupport)
+ .activateApplication(true);
+ }
+ });
+
+ this._heldMessage = null;
+ this._msgCounter = 0;
+ },
+
+ init() {
+ Services.obs.addObserver(Notifications, "new-otr-verification-request");
+ Services.obs.addObserver(Notifications, "new-directed-incoming-message");
+ Services.obs.addObserver(Notifications, "alertclickcallback");
+ },
+
+ _notificationPrefName: "mail.chat.show_desktop_notifications",
+ observe(aSubject, aTopic, aData) {
+ if (!Services.prefs.getBoolPref(this._notificationPrefName)) {
+ return;
+ }
+
+ switch (aTopic) {
+ case "new-directed-incoming-message":
+ // If this is the first message, we show the notification and
+ // store the sender's name.
+ let sender = aSubject.who || aSubject.alias;
+ if (this._lastMessageSender == null) {
+ this._lastMessageSender = sender;
+ this._lastMessageTime = aSubject.time;
+ this._showMessageNotification(aSubject);
+ } else if (
+ this._lastMessageSender != sender ||
+ aSubject.time > this._lastMessageTime + kTimeToWaitForMoreMsgs
+ ) {
+ // If the sender is not the same as the previous sender or the
+ // time elapsed since the last message is greater than kTimeToWaitForMoreMsgs,
+ // we show the held notification and set timeout for the message just arrived.
+ if (this._heldMessage) {
+ // if the time for the current message is greater than _lastMessageTime by
+ // more than kTimeToWaitForMoreMsgs, this will not happen since the notification will
+ // have already been dispatched.
+ clearTimeout(this._timeoutId);
+ this._showMessageNotification(this._heldMessage, this._msgCounter);
+ }
+ this._lastMessageSender = sender;
+ this._lastMessageTime = aSubject.time;
+ this._showMessageNotification(aSubject);
+ } else if (
+ this._lastMessageSender == sender &&
+ this._lastMessageTime + kTimeToWaitForMoreMsgs >= aSubject.time
+ ) {
+ // If the sender is same as the previous sender and the time elapsed since the
+ // last held message is less than kTimeToWaitForMoreMsgs, we increase the held messages
+ // counter and update the last message's arrival time.
+ this._lastMessageTime = aSubject.time;
+ if (!this._heldMessage) {
+ this._heldMessage = aSubject;
+ } else {
+ this._msgCounter++;
+ }
+
+ clearTimeout(this._timeoutId);
+ this._timeoutId = setTimeout(() => {
+ this._showMessageNotification(this._heldMessage, this._msgCounter);
+ }, kTimeToWaitForMoreMsgs * 1000);
+ }
+ break;
+
+ case "new-otr-verification-request":
+ // If the Chat tab is not focused, play the sounds and update the icon
+ // counter, and show the counter in the buddy richlistitem.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (
+ !Services.focus.activeWindow ||
+ win.document.getElementById("tabmail").currentTabInfo.mode.name !=
+ "chat"
+ ) {
+ Services.obs.notifyObservers(
+ aSubject,
+ "play-chat-notification-sound"
+ );
+ }
+
+ break;
+ }
+ },
+};
diff --git a/comm/mail/components/im/modules/index_im.sys.mjs b/comm/mail/components/im/modules/index_im.sys.mjs
new file mode 100644
index 0000000000..bcea54e1ea
--- /dev/null
+++ b/comm/mail/components/im/modules/index_im.sys.mjs
@@ -0,0 +1,928 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var CC = Components.Constructor;
+
+const { Gloda } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+const { GlodaAccount } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaDataModel.jsm"
+);
+const { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+const { GlodaIndexer, IndexingJob } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaDatastore",
+ "resource:///modules/gloda/GlodaDatastore.jsm"
+);
+
+var kCacheFileName = "indexedFiles.json";
+
+var FileInputStream = CC(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+var ScriptableInputStream = CC(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+);
+
+// kIndexingDelay is how long we wait from the point of scheduling an indexing
+// job to actually carrying it out.
+var kIndexingDelay = 5000; // in milliseconds
+
+XPCOMUtils.defineLazyGetter(lazy, "MailFolder", () =>
+ Cc["@mozilla.org/mail/folder-factory;1?name=mailbox"].createInstance(
+ Ci.nsIMsgFolder
+ )
+);
+
+var gIMAccounts = {};
+
+function GlodaIMConversation(aTitle, aTime, aPath, aContent) {
+ // grokNounItem from Gloda.jsm puts automatically the values of all
+ // JS properties in the jsonAttributes magic attribute, except if
+ // they start with _, so we put the values in _-prefixed properties,
+ // and have getters in the prototype.
+ this._title = aTitle;
+ this._time = aTime;
+ this._path = aPath;
+ this._content = aContent;
+}
+GlodaIMConversation.prototype = {
+ get title() {
+ return this._title;
+ },
+ get time() {
+ return this._time;
+ },
+ get path() {
+ return this._path;
+ },
+ get content() {
+ return this._content;
+ },
+
+ // for glodaFacetBindings.xml compatibility (pretend we are a message object)
+ get account() {
+ let [protocol, username] = this._path.split("/", 2);
+
+ let cacheName = protocol + "/" + username;
+ if (cacheName in gIMAccounts) {
+ return gIMAccounts[cacheName];
+ }
+
+ // Find the nsIIncomingServer for the current imIAccount.
+ for (let account of MailServices.accounts.accounts) {
+ let incomingServer = account.incomingServer;
+ if (!incomingServer || incomingServer.type != "im") {
+ continue;
+ }
+ let imAccount = incomingServer.wrappedJSObject.imAccount;
+ if (
+ imAccount.protocol.normalizedName == protocol &&
+ imAccount.normalizedName == username
+ ) {
+ return (gIMAccounts[cacheName] = new GlodaAccount(incomingServer));
+ }
+ }
+ // The IM conversation is probably for an account that no longer exists.
+ return null;
+ },
+ get subject() {
+ return this._title;
+ },
+ get date() {
+ return new Date(this._time * 1000);
+ },
+ get involves() {
+ return GlodaConstants.IGNORE_FACET;
+ },
+ _recipients: null,
+ get recipients() {
+ if (!this._recipients) {
+ this._recipients = [{ contact: { name: this._path.split("/", 2)[1] } }];
+ }
+ return this._recipients;
+ },
+ _from: null,
+ get from() {
+ if (!this._from) {
+ let from = "";
+ let account = this.account;
+ if (account) {
+ from = account.incomingServer.wrappedJSObject.imAccount.protocol.name;
+ }
+ this._from = { value: "", contact: { name: from } };
+ }
+ return this._from;
+ },
+ get tags() {
+ return [];
+ },
+ get starred() {
+ return false;
+ },
+ get attachmentNames() {
+ return null;
+ },
+ get indexedBodyText() {
+ return this._content;
+ },
+ get read() {
+ return true;
+ },
+ get folder() {
+ return GlodaConstants.IGNORE_FACET;
+ },
+
+ // for glodaFacetView.js _removeDupes
+ get headerMessageID() {
+ return this.id;
+ },
+};
+
+// FIXME
+var WidgetProvider = {
+ providerName: "widget",
+ *process() {
+ // XXX What is this supposed to do?
+ yield GlodaConstants.kWorkDone;
+ },
+};
+
+var IMConversationNoun = {
+ name: "im-conversation",
+ clazz: GlodaIMConversation,
+ allowsArbitraryAttrs: true,
+ tableName: "imConversations",
+ schema: {
+ columns: [
+ ["id", "INTEGER PRIMARY KEY"],
+ ["title", "STRING"],
+ ["time", "NUMBER"],
+ ["path", "STRING"],
+ ],
+ fulltextColumns: [["content", "STRING"]],
+ },
+};
+Gloda.defineNoun(IMConversationNoun);
+
+// Needs to be set after calling defineNoun, otherwise it's replaced
+// by GlodaDatabind.jsm' implementation.
+IMConversationNoun.objFromRow = function (aRow) {
+ // Row columns are:
+ // 0 id
+ // 1 title
+ // 2 time
+ // 3 path
+ // 4 jsonAttributes
+ // 5 content
+ // 6 offsets
+ let conv = new GlodaIMConversation(
+ aRow.getString(1),
+ aRow.getInt64(2),
+ aRow.getString(3),
+ aRow.getString(5)
+ );
+ conv.id = aRow.getInt64(0); // handleResult will keep only our first result
+ // if the id property isn't set.
+ return conv;
+};
+
+var EXT_NAME = "im";
+
+// --- special (on-row) attributes
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "time",
+ singular: true,
+ special: GlodaConstants.kSpecialColumn,
+ specialColumnName: "time",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_NUMBER,
+ canQuery: true,
+});
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "title",
+ singular: true,
+ special: GlodaConstants.kSpecialString,
+ specialColumnName: "title",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_STRING,
+ canQuery: true,
+});
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "path",
+ singular: true,
+ special: GlodaConstants.kSpecialString,
+ specialColumnName: "path",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_STRING,
+ canQuery: true,
+});
+
+// --- fulltext attributes
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrFundamental,
+ attributeName: "content",
+ singular: true,
+ special: GlodaConstants.kSpecialFulltext,
+ specialColumnName: "content",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_FULLTEXT,
+ canQuery: true,
+});
+
+// -- fulltext search helper
+// fulltextMatches. Match over message subject, body, and attachments
+// @testpoint gloda.noun.message.attr.fulltextMatches
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrDerived,
+ attributeName: "fulltextMatches",
+ singular: true,
+ special: GlodaConstants.kSpecialFulltext,
+ specialColumnName: "imConversationsText",
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_FULLTEXT,
+});
+// For Facet.jsm DateFaceter
+Gloda.defineAttribute({
+ provider: WidgetProvider,
+ extensionName: EXT_NAME,
+ attributeType: GlodaConstants.kAttrDerived,
+ attributeName: "date",
+ singular: true,
+ special: GlodaConstants.kSpecialColumn,
+ subjectNouns: [IMConversationNoun.id],
+ objectNoun: GlodaConstants.NOUN_NUMBER,
+ facet: {
+ type: "date",
+ },
+ canQuery: true,
+});
+
+var GlodaIMIndexer = {
+ name: "index_im",
+ cacheVersion: 1,
+ enable() {
+ Services.obs.addObserver(this, "conversation-closed");
+ Services.obs.addObserver(this, "new-ui-conversation");
+ Services.obs.addObserver(this, "conversation-update-type");
+ Services.obs.addObserver(this, "ui-conversation-closed");
+ Services.obs.addObserver(this, "ui-conversation-replaced");
+
+ // The shutdown blocker ensures pending saves happen even if the app
+ // gets shut down before the timer fires.
+ if (this._shutdownBlockerAdded) {
+ return;
+ }
+ this._shutdownBlockerAdded = true;
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "GlodaIMIndexer cache save",
+ () => {
+ if (!this._cacheSaveTimer) {
+ return Promise.resolve();
+ }
+ clearTimeout(this._cacheSaveTimer);
+ return this._saveCacheNow();
+ }
+ );
+
+ this._knownFiles = {};
+
+ let dir = FileUtils.getFile("ProfD", ["logs"]);
+ if (!dir.exists() || !dir.isDirectory()) {
+ return;
+ }
+ let cacheFile = dir.clone();
+ cacheFile.append(kCacheFileName);
+ if (!cacheFile.exists()) {
+ return;
+ }
+
+ const PR_RDONLY = 0x01;
+ let fis = new FileInputStream(
+ cacheFile,
+ PR_RDONLY,
+ parseInt("0444", 8),
+ Ci.nsIFileInputStream.CLOSE_ON_EOF
+ );
+ let sis = new ScriptableInputStream(fis);
+ let text = sis.read(sis.available());
+ sis.close();
+
+ let data = JSON.parse(text);
+
+ // Check to see if the Gloda datastore ID matches the one that we saved
+ // in the cache. If so, we can trust it. If not, that means that the
+ // cache is likely invalid now, so we ignore it (and eventually
+ // overwrite it).
+ if (
+ "datastoreID" in data &&
+ Gloda.datastoreID &&
+ data.datastoreID === Gloda.datastoreID
+ ) {
+ // Ok, the cache's datastoreID matches the one we expected, so it's
+ // still valid.
+ this._knownFiles = data.knownFiles;
+ }
+
+ this.cacheVersion = data.version;
+
+ // If there was no version set on the cache, there is a chance that the index
+ // is affected by bug 1069845. fixEntriesWithAbsolutePaths() sets the version to 1.
+ if (!this.cacheVersion) {
+ this.fixEntriesWithAbsolutePaths();
+ }
+ },
+ disable() {
+ Services.obs.removeObserver(this, "conversation-closed");
+ Services.obs.removeObserver(this, "new-ui-conversation");
+ Services.obs.removeObserver(this, "conversation-update-type");
+ Services.obs.removeObserver(this, "ui-conversation-closed");
+ Services.obs.removeObserver(this, "ui-conversation-replaced");
+ },
+
+ /* _knownFiles is a tree whose leaves are the last modified times of
+ * log files when they were last indexed.
+ * Each level of the tree is stored as an object. The root node is an
+ * object that maps a protocol name to an object representing the subtree
+ * for that protocol. The structure is:
+ * _knownFiles -> protoObj -> accountObj -> convObj
+ * The corresponding keys of the above objects are:
+ * protocol names -> account names -> conv names -> file names -> last modified time
+ * convObj maps ALL previously indexed log files of a chat buddy or MUC to
+ * their last modified times. Note that gloda knows nothing about log grouping
+ * done by logger.js.
+ */
+ _knownFiles: {},
+ _cacheSaveTimer: null,
+ _shutdownBlockerAdded: false,
+ _scheduleCacheSave() {
+ if (this._cacheSaveTimer) {
+ return;
+ }
+ this._cacheSaveTimer = setTimeout(this._saveCacheNow, 5000);
+ },
+ _saveCacheNow() {
+ GlodaIMIndexer._cacheSaveTimer = null;
+
+ let data = {
+ knownFiles: GlodaIMIndexer._knownFiles,
+ datastoreID: Gloda.datastoreID,
+ version: GlodaIMIndexer.cacheVersion,
+ };
+
+ // Asynchronously copy the data to the file.
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs",
+ kCacheFileName
+ );
+ return IOUtils.writeJSON(path, data, {
+ tmpPath: path + ".tmp",
+ }).catch(aError => console.error("Failed to write cache file: " + aError));
+ },
+
+ _knownConversations: {},
+ // Promise queue for indexing jobs. The next indexing job is queued using this
+ // promise's then() to ensure we only load logs for one conv at a time.
+ _indexingJobPromise: null,
+ // Maps a conv id to the function that resolves the promise representing the
+ // ongoing indexing job on it. This is called from indexIMConversation when it
+ // finishes and will trigger the next queued indexing job.
+ _indexingJobCallbacks: new Map(),
+
+ _scheduleIndexingJob(aConversation) {
+ let convId = aConversation.id;
+
+ // If we've already scheduled this conversation to be indexed, let's
+ // not repeat.
+ if (!(convId in this._knownConversations)) {
+ this._knownConversations[convId] = {
+ id: convId,
+ scheduledIndex: null,
+ logFileCount: null,
+ convObj: {},
+ };
+ }
+
+ if (!this._knownConversations[convId].scheduledIndex) {
+ // Ok, let's schedule the job.
+ this._knownConversations[convId].scheduledIndex = setTimeout(
+ this._beginIndexingJob.bind(this, aConversation),
+ kIndexingDelay
+ );
+ }
+ },
+
+ _beginIndexingJob(aConversation) {
+ let convId = aConversation.id;
+
+ // In the event that we're triggering this indexing job manually, without
+ // bothering to schedule it (for example, when a conversation is closed),
+ // we give the conversation an entry in _knownConversations, which would
+ // normally have been done in _scheduleIndexingJob.
+ if (!(convId in this._knownConversations)) {
+ this._knownConversations[convId] = {
+ id: convId,
+ scheduledIndex: null,
+ logFileCount: null,
+ convObj: {},
+ };
+ }
+
+ let conv = this._knownConversations[convId];
+ (async () => {
+ // We need to get the log files every time, because a new log file might
+ // have been started since we last got them.
+ let logFiles = await IMServices.logs.getLogPathsForConversation(
+ aConversation
+ );
+ if (!logFiles || !logFiles.length) {
+ // No log files exist yet, nothing to do!
+ return;
+ }
+
+ if (conv.logFileCount == undefined) {
+ // We initialize the _knownFiles tree path for the current files below in
+ // case it doesn't already exist.
+ let folder = PathUtils.parent(logFiles[0]);
+ let convName = PathUtils.filename(folder);
+ folder = PathUtils.parent(folder);
+ let accountName = PathUtils.filename(folder);
+ folder = PathUtils.parent(folder);
+ let protoName = PathUtils.filename(folder);
+ if (
+ !Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)
+ ) {
+ this._knownFiles[protoName] = {};
+ }
+ let protoObj = this._knownFiles[protoName];
+ if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) {
+ protoObj[accountName] = {};
+ }
+ let accountObj = protoObj[accountName];
+ if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) {
+ accountObj[convName] = {};
+ }
+
+ // convObj is the penultimate level of the tree,
+ // maps file name -> last modified time
+ conv.convObj = accountObj[convName];
+ conv.logFileCount = 0;
+ }
+
+ // The last log file in the array is the one currently being written to.
+ // When new log files are started, we want to finish indexing the previous
+ // one as well as index the new ones. The index of the previous one is
+ // conv.logFiles.length - 1, so we slice from there. This gives us all new
+ // log files even if there are multiple new ones.
+ let currentLogFiles =
+ conv.logFileCount > 1
+ ? logFiles.slice(conv.logFileCount - 1)
+ : logFiles;
+ for (let logFile of currentLogFiles) {
+ let fileName = PathUtils.filename(logFile);
+ let lastModifiedTime = (await IOUtils.stat(logFile)).lastModified;
+ if (
+ Object.prototype.hasOwnProperty.call(conv.convObj, fileName) &&
+ conv.convObj[fileName] == lastModifiedTime
+ ) {
+ // The file hasn't changed since we last indexed it, so we're done.
+ continue;
+ }
+
+ if (this._indexingJobPromise) {
+ await this._indexingJobPromise;
+ }
+ this._indexingJobPromise = new Promise(aResolve => {
+ this._indexingJobCallbacks.set(convId, aResolve);
+ });
+
+ let job = new IndexingJob("indexIMConversation", null);
+ job.conversation = conv;
+ job.path = logFile;
+ job.lastModifiedTime = lastModifiedTime;
+ GlodaIndexer.indexJob(job);
+ }
+ conv.logFileCount = logFiles.length;
+ })().catch(console.error);
+
+ // Now clear the job, so we can index in the future.
+ this._knownConversations[convId].scheduledIndex = null;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic == "new-ui-conversation" ||
+ aTopic == "conversation-update-type"
+ ) {
+ // Add ourselves to the ui-conversation's list of observers for the
+ // unread-message-count-changed notification.
+ // For this notification, aSubject is the ui-conversation that is opened.
+ aSubject.addObserver(this);
+ return;
+ }
+
+ if (
+ aTopic == "ui-conversation-closed" ||
+ aTopic == "ui-conversation-replaced"
+ ) {
+ aSubject.removeObserver(this);
+ return;
+ }
+
+ if (aTopic == "unread-message-count-changed") {
+ // We get this notification by attaching observers to conversations
+ // directly (see the new-ui-conversation handler for when we attach).
+ if (aSubject.unreadIncomingMessageCount == 0) {
+ // The unread message count changed to 0, meaning that a conversation
+ // that had been in the background and receiving messages was suddenly
+ // moved to the foreground and displayed to the user. We schedule an
+ // indexing job on this conversation now, since we want to index messages
+ // that the user has seen.
+ this._scheduleIndexingJob(aSubject.target);
+ }
+ return;
+ }
+
+ if (aTopic == "conversation-closed") {
+ let convId = aSubject.id;
+ // If there's a scheduled indexing job, cancel it, because we're going
+ // to index now.
+ if (
+ convId in this._knownConversations &&
+ this._knownConversations[convId].scheduledIndex != null
+ ) {
+ clearTimeout(this._knownConversations[convId].scheduledIndex);
+ }
+
+ this._beginIndexingJob(aSubject);
+ delete this._knownConversations[convId];
+ return;
+ }
+
+ if (aTopic == "new-text" && !aSubject.noLog) {
+ // Ok, some new text is about to be put into a conversation. For this
+ // notification, aSubject is a prplIMessage.
+ let conv = aSubject.conversation;
+ let uiConv = IMServices.conversations.getUIConversation(conv);
+
+ // We only want to schedule an indexing job if this message is
+ // immediately visible to the user. We figure this out by finding
+ // the unread message count on the associated UIConversation for this
+ // message. If the unread count is 0, we know that the message has been
+ // displayed to the user.
+ if (uiConv.unreadIncomingMessageCount == 0) {
+ this._scheduleIndexingJob(conv);
+ }
+ }
+ },
+
+ /* If there is an existing gloda conversation for the given path,
+ * find its id.
+ */
+ _getIdFromPath(aPath) {
+ let selectStatement = lazy.GlodaDatastore._createAsyncStatement(
+ "SELECT id FROM imConversations WHERE path = ?1"
+ );
+ selectStatement.bindByIndex(0, aPath);
+ let id;
+ return new Promise((resolve, reject) => {
+ selectStatement.executeAsync({
+ handleResult: aResultSet => {
+ let row = aResultSet.getNextRow();
+ if (!row) {
+ return;
+ }
+ if (id || aResultSet.getNextRow()) {
+ console.error(
+ "Warning: found more than one gloda conv id for " + aPath + "\n"
+ );
+ }
+ id = id || row.getInt64(0); // We use the first found id.
+ },
+ handleError: aError =>
+ console.error("Error finding gloda id from path:\n" + aError),
+ handleCompletion: () => {
+ resolve(id);
+ },
+ });
+ });
+ },
+
+ // Get the path of a log file relative to the logs directory - the last 4
+ // components of the path.
+ _getRelativePath(aLogPath) {
+ return PathUtils.split(aLogPath).slice(-4).join("/");
+ },
+
+ /**
+ * @param {object} aCache - An object mapping file names to their last
+ * modified times at the time they were last indexed. The value for the file
+ * currently being indexed is updated to the aLastModifiedTime parameter's
+ * value once indexing is complete.
+ * @param {GlodaIMConversation} [aGlodaConv] - An optional in-out param that
+ * lets the caller save and reuse the GlodaIMConversation instance created
+ * when the conversation is indexed the first time. After a conversation is
+ * indexed for the first time, the GlodaIMConversation instance has its id
+ * property set to the row id of the conversation in the database. This id
+ * is required to later update the conversation in the database, so the
+ * caller dealing with ongoing conversation has to provide the aGlodaConv
+ * parameter, while the caller dealing with old conversations doesn't care.
+ */
+ async indexIMConversation(
+ aCallbackHandle,
+ aLogPath,
+ aLastModifiedTime,
+ aCache,
+ aGlodaConv
+ ) {
+ let log = await IMServices.logs.getLogFromFile(aLogPath);
+ let logConv = await log.getConversation();
+
+ // Ignore corrupted log files.
+ if (!logConv) {
+ return GlodaConstants.kWorkDone;
+ }
+
+ let fileName = PathUtils.filename(aLogPath);
+ let messages = logConv
+ .getMessages()
+ // Some messages returned, e.g. sessionstart messages,
+ // may have the noLog flag set. Ignore these.
+ .filter(m => !m.noLog);
+ let content = [];
+ while (messages.length > 0) {
+ await new Promise(resolve => {
+ ChromeUtils.idleDispatch(timing => {
+ while (timing.timeRemaining() > 5 && messages.length > 0) {
+ let m = messages.shift();
+ let who = m.alias || m.who;
+ // Messages like topic change notifications may not have a source.
+ let prefix = who ? who + ": " : "";
+ content.push(
+ prefix +
+ lazy.MailFolder.convertMsgSnippetToPlainText(
+ "<!DOCTYPE html>" + m.message
+ )
+ );
+ }
+ resolve();
+ });
+ });
+ }
+ content = content.join("\n\n");
+ let glodaConv;
+ if (aGlodaConv && aGlodaConv.value) {
+ glodaConv = aGlodaConv.value;
+ glodaConv._content = content;
+ } else {
+ let relativePath = this._getRelativePath(aLogPath);
+ glodaConv = new GlodaIMConversation(
+ logConv.title,
+ log.time,
+ relativePath,
+ content
+ );
+ // If we've indexed this file before, we need the id of the existing
+ // gloda conversation so that the existing entry gets updated. This can
+ // happen if the log sweep detects that the last messages in an open
+ // chat were not in fact indexed before that session was shut down.
+ let id = await this._getIdFromPath(relativePath);
+ if (id) {
+ glodaConv.id = id;
+ }
+ if (aGlodaConv) {
+ aGlodaConv.value = glodaConv;
+ }
+ }
+
+ if (!aCache) {
+ throw new Error("indexIMConversation called without aCache parameter.");
+ }
+ let isNew =
+ !Object.prototype.hasOwnProperty.call(aCache, fileName) && !glodaConv.id;
+ let rv = aCallbackHandle.pushAndGo(
+ Gloda.grokNounItem(glodaConv, {}, true, isNew, aCallbackHandle)
+ );
+
+ if (!aLastModifiedTime) {
+ console.error(
+ "indexIMConversation called without lastModifiedTime parameter."
+ );
+ }
+ aCache[fileName] = aLastModifiedTime || 1;
+ this._scheduleCacheSave();
+
+ return rv;
+ },
+
+ *_worker_indexIMConversation(aJob, aCallbackHandle) {
+ let glodaConv = {};
+ let existingGlodaConv = aJob.conversation.glodaConv;
+ if (
+ existingGlodaConv &&
+ existingGlodaConv.path == this._getRelativePath(aJob.path)
+ ) {
+ glodaConv.value = aJob.conversation.glodaConv;
+ }
+
+ // indexIMConversation may initiate an async grokNounItem sub-job.
+ this.indexIMConversation(
+ aCallbackHandle,
+ aJob.path,
+ aJob.lastModifiedTime,
+ aJob.conversation.convObj,
+ glodaConv
+ ).then(() => GlodaIndexer.callbackDriver());
+ // Tell the Indexer that we're doing async indexing. We'll be left alone
+ // until callbackDriver() is called above.
+ yield GlodaConstants.kWorkAsync;
+
+ // Resolve the promise for this job.
+ this._indexingJobCallbacks.get(aJob.conversation.id)();
+ this._indexingJobCallbacks.delete(aJob.conversation.id);
+ this._indexingJobPromise = null;
+ aJob.conversation.indexPending = false;
+ aJob.conversation.glodaConv = glodaConv.value;
+ yield GlodaConstants.kWorkDone;
+ },
+
+ *_worker_logsFolderSweep(aJob) {
+ let dir = FileUtils.getFile("ProfD", ["logs"]);
+ if (!dir.exists() || !dir.isDirectory()) {
+ // If the folder does not exist, then we are done.
+ yield GlodaConstants.kWorkDone;
+ }
+
+ // Sweep the logs directory for log files, adding any new entries to the
+ // _knownFiles tree as we traverse.
+ for (let proto of dir.directoryEntries) {
+ if (!proto.isDirectory()) {
+ continue;
+ }
+ let protoName = proto.leafName;
+ if (!Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)) {
+ this._knownFiles[protoName] = {};
+ }
+ let protoObj = this._knownFiles[protoName];
+ let accounts = proto.directoryEntries;
+ for (let account of accounts) {
+ if (!account.isDirectory()) {
+ continue;
+ }
+ let accountName = account.leafName;
+ if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) {
+ protoObj[accountName] = {};
+ }
+ let accountObj = protoObj[accountName];
+ for (let conv of account.directoryEntries) {
+ let convName = conv.leafName;
+ if (!conv.isDirectory() || convName == ".system") {
+ continue;
+ }
+ if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) {
+ accountObj[convName] = {};
+ }
+ let job = new IndexingJob("convFolderSweep", null);
+ job.folder = conv;
+ job.convObj = accountObj[convName];
+ GlodaIndexer.indexJob(job);
+ }
+ }
+ }
+
+ yield GlodaConstants.kWorkDone;
+ },
+
+ *_worker_convFolderSweep(aJob, aCallbackHandle) {
+ let folder = aJob.folder;
+
+ for (let file of folder.directoryEntries) {
+ let fileName = file.leafName;
+ if (
+ !file.isFile() ||
+ !file.isReadable() ||
+ !fileName.endsWith(".json") ||
+ (Object.prototype.hasOwnProperty.call(aJob.convObj, fileName) &&
+ aJob.convObj[fileName] == file.lastModifiedTime)
+ ) {
+ continue;
+ }
+ // indexIMConversation may initiate an async grokNounItem sub-job.
+ this.indexIMConversation(
+ aCallbackHandle,
+ file.path,
+ file.lastModifiedTime,
+ aJob.convObj
+ ).then(() => GlodaIndexer.callbackDriver());
+ // Tell the Indexer that we're doing async indexing. We'll be left alone
+ // until callbackDriver() is called above.
+ yield GlodaConstants.kWorkAsync;
+ }
+ yield GlodaConstants.kWorkDone;
+ },
+
+ get workers() {
+ return [
+ ["indexIMConversation", { worker: this._worker_indexIMConversation }],
+ ["logsFolderSweep", { worker: this._worker_logsFolderSweep }],
+ ["convFolderSweep", { worker: this._worker_convFolderSweep }],
+ ];
+ },
+
+ initialSweep() {
+ let job = new IndexingJob("logsFolderSweep", null);
+ GlodaIndexer.indexJob(job);
+ },
+
+ // Due to bug 1069845, some logs were indexed against their full paths instead
+ // of their path relative to the logs directory. These entries are updated to
+ // use relative paths below.
+ fixEntriesWithAbsolutePaths() {
+ let store = lazy.GlodaDatastore;
+ let selectStatement = store._createAsyncStatement(
+ "SELECT id, path FROM imConversations"
+ );
+ let updateStatement = store._createAsyncStatement(
+ "UPDATE imConversations SET path = ?1 WHERE id = ?2"
+ );
+
+ store._beginTransaction();
+ selectStatement.executeAsync({
+ handleResult: aResultSet => {
+ let row;
+ while ((row = aResultSet.getNextRow())) {
+ // If the path has more than 4 components, it is not relative to
+ // the logs folder. Update it to use only the last 4 components.
+ // The absolute paths were stored as OS-specific paths, so we split
+ // them with PathUtils.split(). It's a safe assumption that nobody
+ // ported their profile folder to a different OS since the regression,
+ // so this should work.
+ let pathComponents = PathUtils.split(row.getString(1));
+ if (pathComponents.length > 4) {
+ updateStatement.bindByIndex(1, row.getInt64(0)); // id
+ updateStatement.bindByIndex(0, pathComponents.slice(-4).join("/")); // Last 4 path components
+ updateStatement.executeAsync({
+ handleResult: () => {},
+ handleError: aError =>
+ console.error("Error updating bad entry:\n" + aError),
+ handleCompletion: () => {},
+ });
+ }
+ }
+ },
+
+ handleError: aError =>
+ console.error("Error looking for bad entries:\n" + aError),
+
+ handleCompletion: () => {
+ store.runPostCommit(() => {
+ this.cacheVersion = 1;
+ this._scheduleCacheSave();
+ });
+ store._commitTransaction();
+ },
+ });
+ },
+};
+
+GlodaIndexer.registerIndexer(GlodaIMIndexer);
diff --git a/comm/mail/components/im/moz.build b/comm/mail/components/im/moz.build
new file mode 100644
index 0000000000..3780532058
--- /dev/null
+++ b/comm/mail/components/im/moz.build
@@ -0,0 +1,38 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "IMIncomingServer.sys.mjs",
+ "IMProtocolInfo.sys.mjs",
+ "modules/ChatEncryption.sys.mjs",
+ "modules/chatHandler.sys.mjs",
+ "modules/chatIcons.sys.mjs",
+ "modules/chatNotifications.sys.mjs",
+ "modules/GlodaIMSearcher.sys.mjs",
+ "modules/index_im.sys.mjs",
+]
+
+TESTING_JS_MODULES += [
+ "test/TestProtocol.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+JS_PREFERENCE_FILES += [
+ "all-im.js",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+if CONFIG["ENABLE_TESTS"]:
+ XPCOM_MANIFESTS += [
+ "test/components.conf",
+ ]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/im/smileys/theme.json b/comm/mail/components/im/smileys/theme.json
new file mode 100644
index 0000000000..bbe0001f64
--- /dev/null
+++ b/comm/mail/components/im/smileys/theme.json
@@ -0,0 +1,22 @@
+{
+ "smileys": [
+ { "glyph": "\uD83D\uDE01", "textCodes": [":-)", ":)", "(-:", "(:"] },
+ { "glyph": "\uD83D\uDE02", "textCodes": [":-D", ":D"] },
+ { "glyph": "\uD83D\uDE09", "textCodes": [";-)", ";)"] },
+ { "glyph": "\uD83D\uDE2D", "textCodes": [":'("] },
+ { "glyph": "\uD83D\uDE2D", "textCodes": [":-o", ":-O", "o_o", "O_O"] },
+ { "glyph": "\uD83D\uDE15", "textCodes": [":-S", ":S", ":-s", ":s"] },
+ { "glyph": "\uD83D\uDE1F", "textCodes": [":-/", ":-\\"] },
+ { "glyph": "\uD83D\uDE20", "textCodes": ["x-("] },
+ { "glyph": "\uD83D\uDE41", "textCodes": [":-(", ":(", ")-:", "):"] },
+ { "glyph": "\uD83D\uDE0E", "textCodes": ["B-)", "8-)"] },
+ { "glyph": "\uD83D\uDE1B", "textCodes": [":-P", ":P", ":-p", ":p"] },
+ { "glyph": "\uD83D\uDE05", "textCodes": [":-]", ":]", "^^'"] },
+ { "glyph": "♥", "textCodes": ["<3"] },
+ { "glyph": "\uD83D\uDE10", "textCodes": [":-|"] },
+ { "glyph": "☺", "textCodes": ["^^"] },
+ { "glyph": "\uD83D\uDE2B", "textCodes": ["-_-"] },
+ { "glyph": "\uD83D\uDE24", "textCodes": ["-_-'", "--'"] },
+ { "glyph": "\uD83E\uDD23", "textCodes": ["XD", "xD"] }
+ ]
+}
diff --git a/comm/mail/components/im/test/TestProtocol.sys.mjs b/comm/mail/components/im/test/TestProtocol.sys.mjs
new file mode 100644
index 0000000000..7fddbf176b
--- /dev/null
+++ b/comm/mail/components/im/test/TestProtocol.sys.mjs
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ GenericAccountPrototype,
+ GenericConvChatPrototype,
+ GenericConvIMPrototype,
+ GenericConversationPrototype,
+ GenericProtocolPrototype,
+ GenericConvChatBuddyPrototype,
+ GenericMessagePrototype,
+ TooltipInfo,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+import { nsSimpleEnumerator } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+function Message(who, text, properties, conversation) {
+ this._init(who, text, properties, conversation);
+ this.displayed = new Promise(resolve => {
+ this._onDisplayed = resolve;
+ });
+ this.read = new Promise(resolve => {
+ this._onRead = resolve;
+ });
+ this.actionRan = new Promise(resolve => {
+ this._onAction = resolve;
+ });
+}
+
+Message.prototype = {
+ __proto__: GenericMessagePrototype,
+
+ whenDisplayed() {
+ this._onDisplayed();
+ },
+
+ whenRead() {
+ this._onRead();
+ },
+
+ getActions() {
+ return [
+ {
+ QueryInterface: ChromeUtils.generateQI(["prplIMessageAction"]),
+ label: "Test",
+ run: () => {
+ this._onAction();
+ },
+ },
+ ];
+ },
+};
+
+/**
+ *
+ * @param {string} who - Nick of the participant.
+ * @param {string} [alias] - Display name of the participant.
+ */
+function Participant(who, alias) {
+ this._name = who;
+ if (alias) {
+ this.alias = alias;
+ }
+}
+Participant.prototype = {
+ __proto__: GenericConvChatBuddyPrototype,
+};
+
+const SharedConversationPrototype = {
+ _disconnected: false,
+ /**
+ * Disconnect the conversation.
+ */
+ _setDisconnected() {
+ this._disconnected = true;
+ },
+ /**
+ * Close the conversation, including in the UI.
+ */
+ close() {
+ this._disconnected = true;
+ this._account._conversations.delete(this);
+ GenericConversationPrototype.close.call(this);
+ },
+ /**
+ * Send an outgoing message.
+ *
+ * @param {string} aMsg - Message to send.
+ * @returns
+ */
+ dispatchMessage(aMsg, aAction = false, aNotice = false) {
+ if (this._disconnected) {
+ return;
+ }
+ this.writeMessage("You", aMsg, { outgoing: true, notification: aNotice });
+ },
+
+ /**
+ *
+ * @param {Array<object>} messages - Array of messages to add to the
+ * conversation. Expects an object with a |who|, |content| and |options|
+ * properties, corresponding to the three params of |writeMessage|.
+ */
+ addMessages(messages) {
+ for (const message of messages) {
+ this.writeMessage(message.who, message.content, message.options);
+ }
+ },
+
+ /**
+ * Add a notice to the conversation.
+ */
+ addNotice() {
+ this.writeMessage("system", "test notice", { system: true });
+ },
+
+ createMessage(who, text, options) {
+ const message = new Message(who, text, options, this);
+ return message;
+ },
+};
+
+/**
+ *
+ * @param {prplIAccount} account
+ * @param {string} name - Name of the conversation.
+ */
+function MUC(account, name) {
+ this._init(account, name, "You");
+}
+MUC.prototype = {
+ __proto__: GenericConvChatPrototype,
+
+ /**
+ *
+ * @param {string} who - Nick of the user to add.
+ * @param {string} alias - Display name of the participant.
+ * @returns
+ */
+ addParticipant(who, alias) {
+ if (this._participants.has(who)) {
+ return;
+ }
+ const participant = new Participant(who, alias);
+ this._participants.set(who, participant);
+ },
+ ...SharedConversationPrototype,
+};
+
+/**
+ *
+ * @param {prplIAccount} account
+ * @param {string} name - Name of the conversation.
+ */
+function DM(account, name) {
+ this._init(account, name);
+}
+DM.prototype = {
+ __proto__: GenericConvIMPrototype,
+ ...SharedConversationPrototype,
+};
+
+function Account(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+ this._conversations = new Set();
+}
+Account.prototype = {
+ __proto__: GenericAccountPrototype,
+
+ /**
+ * @type {Set<GenericConversationPrototype>}
+ */
+ _conversations: null,
+
+ /**
+ *
+ * @param {string} name - Name of the conversation.
+ * @returns {MUC}
+ */
+ makeMUC(name) {
+ const conversation = new MUC(this, name);
+ this._conversations.add(conversation);
+ return conversation;
+ },
+
+ /**
+ *
+ * @param {string} name - Name of the conversation.
+ * @returns {DM}
+ */
+ makeDM(name) {
+ const conversation = new DM(this, name);
+ this._conversations.add(conversation);
+ return conversation;
+ },
+
+ connect() {
+ this.reportConnecting();
+ // do something here
+ this.reportConnected();
+ },
+ disconnect() {
+ this.reportDisconnecting(Ci.prplIAccount.NO_ERROR, "");
+ this.reportDisconnected();
+ },
+
+ requestBuddyInfo(who) {
+ const participant = Array.from(this._conversations)
+ .find(conv => conv.isChat && conv._participants.has(who))
+ ?._participants.get(who);
+ if (participant) {
+ const tooltipInfo = [new TooltipInfo("Display Name", participant.alias)];
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(tooltipInfo),
+ "user-info-received",
+ who
+ );
+ }
+ },
+
+ get canJoinChat() {
+ return true;
+ },
+ chatRoomFields: {
+ channel: { label: "_Channel Field", required: true },
+ channelDefault: { label: "_Field with default", default: "Default Value" },
+ password: {
+ label: "_Password Field",
+ default: "",
+ isPassword: true,
+ required: false,
+ },
+ sampleIntField: {
+ label: "_Int Field",
+ default: 4,
+ min: 0,
+ max: 10,
+ required: true,
+ },
+ },
+
+ // Nothing to do.
+ unInit() {
+ for (const conversation of this._conversations) {
+ conversation.close();
+ }
+ },
+ remove() {},
+};
+
+export function TestProtocol() {}
+TestProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get id() {
+ return "prpl-mochitest";
+ },
+ get normalizedName() {
+ return "mochitest";
+ },
+ get name() {
+ return "Mochitest";
+ },
+ options: {
+ text: { label: "Text option", default: "foo" },
+ bool: { label: "Boolean option", default: true },
+ int: { label: "Integer option", default: 42 },
+ list: {
+ label: "Select option",
+ default: "option2",
+ listValues: {
+ option1: "First option",
+ option2: "Default option",
+ option3: "Other option",
+ },
+ },
+ },
+ usernameSplits: [
+ {
+ label: "Server",
+ separator: "@",
+ defaultValue: "default.server",
+ reverse: true,
+ },
+ ],
+ getAccount(aImAccount) {
+ return new Account(this, aImAccount);
+ },
+ classID: Components.ID("{a4617631-b8b8-4053-8afa-5c4c43498280}"),
+};
+
+export function registerTestProtocol() {
+ Services.catMan.addCategoryEntry(
+ "im-protocol-plugin",
+ TestProtocol.prototype.id,
+ "@mozilla.org/chat/mochitest;1",
+ false,
+ true
+ );
+}
+
+export function unregisterTestProtocol() {
+ Services.catMan.deleteCategoryEntry(
+ "im-protocol-plugin",
+ TestProtocol.prototype.id,
+ true
+ );
+}
diff --git a/comm/mail/components/im/test/browser/browser.ini b/comm/mail/components/im/test/browser/browser.ini
new file mode 100644
index 0000000000..5592953682
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser.ini
@@ -0,0 +1,26 @@
+[default]
+prefs =
+ ldap_2.servers.osx.description=
+ ldap_2.servers.osx.dirType=-1
+ ldap_2.servers.osx.uri=
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ chat.otr.enable=false
+subsuite = thunderbird
+head = head.js
+
+[browser_browserRequest.js]
+[browser_chatNotifications.js]
+[browser_chatTelemetry.js]
+[browser_contextMenu.js]
+[browser_logs.js]
+[browser_messagesMail.js]
+[browser_readMessage.js]
+[browser_removeMessage.js]
+[browser_requestNotifications.js]
+[browser_spacesToolbarChat.js]
+[browser_tooltips.js]
+[browser_updateMessage.js]
diff --git a/comm/mail/components/im/test/browser/browser_browserRequest.js b/comm/mail/components/im/test/browser/browser_browserRequest.js
new file mode 100644
index 0000000000..7ffdb1c725
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_browserRequest.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { InteractiveBrowser, CancelledError } = ChromeUtils.importESModule(
+ "resource:///modules/InteractiveBrowser.sys.mjs"
+);
+const kBaseWindowUri = "chrome://messenger/content/browserRequest.xhtml";
+
+add_task(async function testBrowserRequestObserverNotification() {
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ let notifyLoaded;
+ const loadedPromise = new Promise(resolve => {
+ notifyLoaded = resolve;
+ });
+ const cancelledPromise = new Promise(resolve => {
+ Services.obs.notifyObservers(
+ {
+ promptText: "",
+ iconURI: "",
+ url: "about:blank",
+ cancelled() {
+ resolve();
+ },
+ loaded(window, webProgress) {
+ ok(webProgress);
+ notifyLoaded(window);
+ },
+ },
+ "browser-request"
+ );
+ });
+
+ const requestWindow = await windowPromise;
+ const loadedWindow = await loadedPromise;
+ ok(loadedWindow);
+ is(loadedWindow.document.documentURI, kBaseWindowUri);
+
+ const closeEvent = new Event("close");
+ requestWindow.dispatchEvent(closeEvent);
+ await BrowserTestUtils.closeWindow(requestWindow);
+
+ await cancelledPromise;
+});
+
+add_task(async function testWaitForRedirect() {
+ const initialUrl = "about:blank";
+ const promptText = "just testing";
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/done?info=foo";
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ const request = InteractiveBrowser.waitForRedirect(initialUrl, promptText);
+ const requestWindow = await windowPromise;
+ is(requestWindow.document.title, promptText, "set window title");
+
+ const closedWindow = BrowserTestUtils.domWindowClosed(requestWindow);
+ const browser = requestWindow.document.getElementById("requestFrame");
+ await BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, completionUrl);
+ const result = await request;
+ is(result, completionUrl, "finished with correct URL");
+
+ await closedWindow;
+});
+
+add_task(async function testCancelWaitForRedirect() {
+ const initialUrl = "about:blank";
+ const promptText = "just testing";
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ const request = InteractiveBrowser.waitForRedirect(initialUrl, promptText);
+ const requestWindow = await windowPromise;
+ is(requestWindow.document.title, promptText, "set window title");
+
+ await new Promise(resolve => setTimeout(resolve));
+
+ const closeEvent = new Event("close");
+ requestWindow.dispatchEvent(closeEvent);
+ await BrowserTestUtils.closeWindow(requestWindow);
+
+ try {
+ await request;
+ ok(false, "request should be rejected");
+ } catch (error) {
+ ok(error instanceof CancelledError, "request was rejected");
+ }
+});
+
+add_task(async function testAlreadyComplete() {
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/done?info=foo";
+ const promptText = "just testing";
+ const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ win => win.document.documentURI === kBaseWindowUri
+ );
+ const request = InteractiveBrowser.waitForRedirect(completionUrl, promptText);
+ const requestWindow = await windowPromise;
+ is(requestWindow.document.title, promptText, "set window title");
+
+ const closedWindow = BrowserTestUtils.domWindowClosed(requestWindow);
+ const result = await request;
+ is(result, completionUrl, "finished with correct URL");
+
+ await closedWindow;
+});
diff --git a/comm/mail/components/im/test/browser/browser_chatNotifications.js b/comm/mail/components/im/test/browser/browser_chatNotifications.js
new file mode 100644
index 0000000000..f902a9132b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_chatNotifications.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../../content/chat-messenger.js */
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+);
+
+let originalAlertsServiceCID;
+let alertShown;
+const reset = () => {
+ alertShown = false;
+};
+
+add_setup(async () => {
+ reset();
+ class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+ showAlert(alertInfo, listener) {
+ alertShown = true;
+ }
+ }
+ originalAlertsServiceCID = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ new MockAlertsService()
+ );
+});
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(originalAlertsServiceCID);
+});
+
+add_task(async function testNotificationsDisabled() {
+ Services.prefs.setBoolPref("mail.chat.show_desktop_notifications", false);
+
+ Services.obs.notifyObservers(
+ {
+ who: "notifier",
+ alias: "Notifier",
+ time: Date.now() / 1000 - 10,
+ displayMessage: "<strong>lorem ipsum</strong>",
+ action: false,
+ conversation: {
+ isChat: true,
+ },
+ },
+ "new-directed-incoming-message"
+ );
+
+ await TestUtils.waitForTick();
+ ok(!alertShown, "No alert shown when they are disabled");
+
+ Services.prefs.setBoolPref("mail.chat.show_desktop_notifications", true);
+ reset();
+
+ let soundPlayed = TestUtils.topicObserved("play-chat-notification-sound");
+ Services.obs.notifyObservers(
+ {
+ who: "notifier",
+ alias: "Notifier",
+ time: Date.now() / 1000 - 5,
+ displayMessage: "",
+ action: false,
+ conversation: {
+ isChat: true,
+ },
+ },
+ "new-directed-incoming-message"
+ );
+ await soundPlayed;
+ ok(!alertShown, "No alert shown with main window focused");
+
+ reset();
+
+ await openChatTab();
+
+ Services.obs.notifyObservers(
+ {
+ who: "notifier",
+ alias: "Notifier",
+ time: Date.now() / 1000,
+ displayMessage: "",
+ action: false,
+ conversation: {
+ isChat: true,
+ },
+ },
+ "new-directed-incoming-message"
+ );
+ await TestUtils.waitForTick();
+ ok(!alertShown, "No alert shown, no sound with chat tab focused");
+
+ await closeChatTab();
+});
diff --git a/comm/mail/components/im/test/browser/browser_chatTelemetry.js b/comm/mail/components/im/test/browser/browser_chatTelemetry.js
new file mode 100644
index 0000000000..4dbad87708
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_chatTelemetry.js
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_task(async function testMessageThemeTelemetry() {
+ Services.telemetry.clearScalars();
+
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ ok(
+ !scalars["tb.chat.active_message_theme"],
+ "Active chat theme not reported without open conversation."
+ );
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ const conversationLoaded = waitForConversationLoad(chatConv.convBrowser);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ await conversationLoaded;
+ scalars = TelemetryTestUtils.getProcessScalars("parent");
+ // NOTE: tb.chat.active_message_theme expires at v 117.
+ is(
+ scalars["tb.chat.active_message_theme"],
+ "mail:default",
+ "Active chat message theme and variant reported after opening conversation."
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_contextMenu.js b/comm/mail/components/im/test/browser/browser_contextMenu.js
new file mode 100644
index 0000000000..44afcb2a3b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_contextMenu.js
@@ -0,0 +1,243 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testContextMenu() {
+ const account = IMServices.accounts.createAccount(
+ "context",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("context");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ const conversationLoaded = waitForConversationLoad();
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ await conversationLoaded;
+
+ const contextMenu = document.getElementById("chatConversationContextMenu");
+ ok(BrowserTestUtils.is_hidden(contextMenu));
+
+ const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "body",
+ 0,
+ 0,
+ { type: "contextmenu" },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShown;
+
+ const popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ // Assume normal context menu semantics work and just close it directly.
+ contextMenu.hidePopup();
+ await popupHidden;
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testMessageContextMenuOnLink() {
+ const account = IMServices.accounts.createAccount(
+ "context",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("linker");
+
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ conversation.addMessages([
+ {
+ who: "linker",
+ content: "hi https://example.com/",
+ options: {
+ incoming: true,
+ },
+ },
+ {
+ who: "linker",
+ content: "hi mailto:test@example.com",
+ options: {
+ incoming: true,
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const contextMenu = document.getElementById("chatConversationContextMenu");
+ ok(BrowserTestUtils.is_hidden(contextMenu));
+
+ const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ ".message:nth-child(1) a",
+ 0,
+ 0,
+ { type: "contextmenu", centered: true },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShown;
+
+ ok(
+ BrowserTestUtils.is_visible(contextMenu.querySelector("#context-openlink")),
+ "open link"
+ );
+ ok(
+ BrowserTestUtils.is_visible(contextMenu.querySelector("#context-copylink")),
+ "copy link"
+ );
+
+ const popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ // Assume normal context menu semantics work and just close it directly.
+ contextMenu.hidePopup();
+ await popupHidden;
+
+ const popupShownAgain = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ BrowserTestUtils.synthesizeMouse(
+ ".message:nth-child(2) a",
+ 0,
+ 0,
+ { type: "contextmenu", centered: true },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShownAgain;
+
+ ok(
+ BrowserTestUtils.is_visible(
+ contextMenu.querySelector("#context-copyemail")
+ ),
+ "copy mail"
+ );
+
+ const popupHiddenAgain = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ // Assume normal context menu semantics work and just close it directly.
+ contextMenu.hidePopup();
+ await popupHiddenAgain;
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testMessageAction() {
+ const account = IMServices.accounts.createAccount(
+ "context",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("context");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ const messagePromise = waitForNotification(conversation, "new-text");
+ const displayedPromise = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ conversation.writeMessage("context", "hello world", {
+ incoming: true,
+ });
+ const { subject: message } = await messagePromise;
+ await displayedPromise;
+
+ const contextMenu = document.getElementById("chatConversationContextMenu");
+ ok(BrowserTestUtils.is_hidden(contextMenu));
+
+ const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ ".message:nth-child(1)",
+ 0,
+ 0,
+ { type: "contextmenu", centered: true },
+ chatConv.convBrowser,
+ true
+ );
+ await popupShown;
+
+ const separator = contextMenu.querySelector("#context-sep-messageactions");
+ if (!BrowserTestUtils.is_visible(separator)) {
+ await BrowserTestUtils.waitForMutationCondition(
+ separator,
+ {
+ subtree: false,
+ childList: false,
+ attributes: true,
+ attributeFilter: ["hidden"],
+ },
+ () => BrowserTestUtils.is_visible(separator)
+ );
+ }
+ const item = contextMenu.querySelector(
+ "#context-sep-messageactions + menuitem"
+ );
+ ok(item, "Item for message action injected");
+ is(item.getAttribute("label"), "Test");
+
+ const popupHiddenAgain = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ item.click();
+ // Assume normal context menu semantics work and just close it.
+ contextMenu.hidePopup();
+ await Promise.all([message.actionRan, popupHiddenAgain]);
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_logs.js b/comm/mail/components/im/test/browser/browser_logs.js
new file mode 100644
index 0000000000..2f95a2accd
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_logs.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+add_task(async function testTopicRestored() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation =
+ account.prplAccount.wrappedJSObject.makeMUC("logs topic");
+ let convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ let chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ conversation.addParticipant("topic");
+ conversation.addMessages([
+ {
+ who: "topic",
+ content: "hi",
+ options: {
+ incoming: true,
+ },
+ },
+ ]);
+ await browserDisplayed;
+
+ // Close and re-open conversation to get logs
+ conversation.close();
+ const newConversation =
+ account.prplAccount.wrappedJSObject.makeMUC("logs topic");
+ convNode = getConversationItem(newConversation);
+ ok(convNode);
+
+ let conversationLoaded = waitForConversationLoad();
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ chatConv = getChatConversationElement(newConversation);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ const topicChanged = waitForNotification(
+ newConversation,
+ "chat-update-topic"
+ );
+ newConversation.setTopic("foo bar", "topic");
+ await topicChanged;
+ const logTree = document.getElementById("logTree");
+ const chatTopInfo = document.querySelector("chat-conversation-info");
+
+ is(chatTopInfo.topic.value, "foo bar");
+
+ // Wait for log list to be populated, sadly there is no event and it is delayed by promises.
+ await TestUtils.waitForCondition(() => logTree.view.rowCount > 0);
+
+ await conversationLoaded;
+ const logBrowser = document.getElementById("conv-log-browser");
+ conversationLoaded = waitForConversationLoad(logBrowser);
+ mailTestUtils.treeClick(EventUtils, window, logTree, 0, 0, {
+ clickCount: 1,
+ });
+ await conversationLoaded;
+
+ ok(BrowserTestUtils.is_visible(logBrowser));
+ is(chatTopInfo.topic.value, "", "Topic is cleared when viewing logs");
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("goToConversation"),
+ {}
+ );
+
+ ok(BrowserTestUtils.is_hidden(logBrowser));
+ is(chatTopInfo.topic.value, "foo bar");
+
+ newConversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_messagesMail.js b/comm/mail/components/im/test/browser/browser_messagesMail.js
new file mode 100644
index 0000000000..6bc73c723c
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_messagesMail.js
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testCollapse() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+
+ await addNotice(conversation, chatConv);
+
+ is(
+ messageParent.querySelector(".event-row:nth-child(1) .body").textContent,
+ "test notice",
+ "notice added to conv"
+ );
+
+ await addNotice(conversation, chatConv);
+ await addNotice(conversation, chatConv);
+ await addNotice(conversation, chatConv);
+ await Promise.all([
+ await addNotice(conversation, chatConv),
+ BrowserTestUtils.waitForMutationCondition(
+ messageParent,
+ {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["class"],
+ },
+ () => messageParent.querySelector(".hide-children")
+ ),
+ ]);
+
+ const hiddenGroup = messageParent.querySelector(".hide-children");
+ const toggle = hiddenGroup.querySelector(".eventToggle");
+ ok(toggle);
+ ok(hiddenGroup.querySelectorAll(".event-row").length >= 5);
+
+ toggle.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ hiddenGroup,
+ {
+ attributes: true,
+ attributeFilter: ["class"],
+ },
+ () => !hiddenGroup.classList.contains("hide-children")
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testGrouping() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(
+ BrowserTestUtils.is_visible(document.getElementById("chatPanel")),
+ "Chat tab is visible"
+ );
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("grouping");
+ const convNode = getConversationItem(conversation);
+ ok(convNode, "Conversation is in contacts list");
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "Found conversation element");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+
+ conversation.addMessages([
+ {
+ who: "grouping",
+ content: "system message",
+ options: {
+ system: true,
+ incoming: true,
+ },
+ },
+ {
+ who: "grouping",
+ content: "normal message",
+ options: {
+ incoming: true,
+ },
+ },
+ {
+ who: "grouping",
+ content: "another system message",
+ options: {
+ system: true,
+ incoming: true,
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ for (let child of messageParent.children) {
+ isnot(child.id, "insert", "Message element is not the insert point");
+ }
+ is(
+ messageParent.childElementCount,
+ 3,
+ "All three messages are their own top level element"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testSystemMessageReplacement() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(
+ BrowserTestUtils.is_visible(document.getElementById("chatPanel")),
+ "Chat tab is visible"
+ );
+
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("replacing");
+ const convNode = getConversationItem(conversation);
+ ok(convNode, "Conversation is in contacts list");
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "Found conversation element");
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+
+ conversation.addMessages([
+ {
+ who: "replacing",
+ content: "system message",
+ options: {
+ system: true,
+ incoming: true,
+ remoteId: "foo",
+ },
+ },
+ {
+ who: "replacing",
+ content: "another system message",
+ options: {
+ system: true,
+ incoming: true,
+ remoteId: "bar",
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const updateTextPromise = waitForNotification(conversation, "update-text");
+ conversation.updateMessage("replacing", "better system message", {
+ system: true,
+ incoming: true,
+ remoteId: "foo",
+ });
+ await updateTextPromise;
+ await TestUtils.waitForTick();
+
+ is(messageParent.childElementCount, 1, "Only one message group in browser");
+ is(
+ messageParent.firstElementChild.childElementCount,
+ 3,
+ "Has two messages plus insert inside group"
+ );
+ const firstMessage = messageParent.firstElementChild.firstElementChild;
+ ok(
+ firstMessage.classList.contains("event-row"),
+ "Replacement message is an event-row"
+ );
+ is(firstMessage.dataset.remoteId, "foo");
+ is(
+ firstMessage.querySelector(".body").textContent,
+ "better system message",
+ "Message content was updated"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+function addNotice(conversation, uiConversation) {
+ conversation.addNotice();
+ return BrowserTestUtils.waitForEvent(
+ uiConversation.convBrowser,
+ "MessagesDisplayed"
+ );
+}
diff --git a/comm/mail/components/im/test/browser/browser_readMessage.js b/comm/mail/components/im/test/browser/browser_readMessage.js
new file mode 100644
index 0000000000..e290cb36fb
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_readMessage.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testDisplayed() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ ok(!convNode.hasAttribute("unread"), "No unread messages");
+
+ const messagePromise = waitForNotification(conversation, "new-text");
+ conversation.writeMessage("mochitest", "hello world", {
+ incoming: true,
+ });
+ const { subject: message } = await messagePromise;
+
+ ok(convNode.hasAttribute("unread"), "Unread message waiting");
+ is(convNode.getAttribute("unreadCount"), "(1)");
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+
+ await browserDisplayed;
+ await message.displayed;
+
+ ok(!convNode.hasAttribute("unread"), "Message read");
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_removeMessage.js b/comm/mail/components/im/test/browser/browser_removeMessage.js
new file mode 100644
index 0000000000..0d95bb77b5
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_removeMessage.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testRemove() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ conversation.writeMessage("mochitest", "hello world", {
+ incoming: true,
+ remoteId: "foo",
+ });
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+ await browserDisplayed;
+
+ is(
+ messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt")
+ .textContent,
+ "hello world",
+ "message added to conv"
+ );
+
+ const updateTextPromise = waitForNotification(conversation, "remove-text");
+ conversation.removeMessage("foo");
+ await updateTextPromise;
+ await TestUtils.waitForTick();
+
+ ok(!messageParent.querySelector(".message"));
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_requestNotifications.js b/comm/mail/components/im/test/browser/browser_requestNotifications.js
new file mode 100644
index 0000000000..62128add8b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_requestNotifications.js
@@ -0,0 +1,350 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testGrantingBuddyRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const notificationTopic = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addBuddyRequest("test-user", resolve, reject);
+ });
+ const [request] = await notificationTopic;
+ is(request.userName, "test-user");
+ is(request.account.id, account.id);
+ await TestUtils.waitForTick();
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value = "buddy-auth-request-" + request.account.id + request.userName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ ok(
+ BrowserTestUtils.is_hidden(notification.closeButton),
+ "Can't dismiss without interacting"
+ );
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {}
+ );
+ await requestPromise;
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testCancellingBuddyRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const notificationTopic = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ prplAccount.addBuddyRequest(
+ "test-user",
+ () => {
+ ok(false, "request was granted");
+ },
+ () => {
+ ok(false, "request was denied");
+ }
+ );
+ const [request] = await notificationTopic;
+ is(request.userName, "test-user");
+ is(request.account.id, account.id);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value = "buddy-auth-request-" + request.account.id + request.userName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ const cancelTopic = TestUtils.topicObserved(
+ "buddy-authorization-request-canceled"
+ );
+ prplAccount.cancelBuddyRequest("test-user");
+ const [canceledRequest] = await cancelTopic;
+ is(canceledRequest.userName, request.userName);
+ is(canceledRequest.account.id, request.account.id);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testDenyingBuddyRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const notificationTopic = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addBuddyRequest("test-user", reject, resolve);
+ });
+ const [request] = await notificationTopic;
+ is(request.userName, "test-user");
+ is(request.account.id, account.id);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value = "buddy-auth-request-" + request.account.id + request.userName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.lastElementChild,
+ {}
+ );
+ await requestPromise;
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testGrantingChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addChatRequest("test-chat", resolve, reject);
+ });
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat");
+ is(request.account.id, account.id);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ ok(
+ BrowserTestUtils.is_hidden(notification.closeButton),
+ "Can't dismiss without interacting"
+ );
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {}
+ );
+ await requestPromise;
+ const result = await request.completePromise;
+ ok(result);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testCancellingChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(
+ BrowserTestUtils.is_visible(document.getElementById("chatPanel")),
+ "chat tab visible"
+ );
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ prplAccount.addChatRequest(
+ "test-chat",
+ () => {
+ ok(false, "chat request was granted");
+ },
+ () => {
+ ok(false, "chat request was denied");
+ }
+ );
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat", "conversation name matches");
+ is(request.account.id, account.id, "account id matches");
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ prplAccount.cancelChatRequest("test-chat");
+ await Assert.rejects(
+ request.completePromise,
+ /Cancelled/,
+ "completePromise is rejected to indicate cancellation"
+ );
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testDenyingChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ const requestPromise = new Promise((resolve, reject) => {
+ prplAccount.addChatRequest("test-chat", reject, resolve);
+ });
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat");
+ is(request.account.id, account.id);
+ ok(request.canDeny);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.lastElementChild,
+ {}
+ );
+ await requestPromise;
+ const result = await request.completePromise;
+ ok(!result);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testUndenyableChatRequest() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ const prplAccount = account.prplAccount.wrappedJSObject;
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const requestTopic = TestUtils.topicObserved("conv-authorization-request");
+ const requestPromise = new Promise(resolve => {
+ prplAccount.addChatRequest("test-chat", resolve);
+ });
+ const [request] = await requestTopic;
+ is(request.conversationName, "test-chat");
+ is(request.account.id, account.id);
+ ok(!request.canDeny);
+
+ const notificationBox = window.chatHandler.msgNotificationBar;
+ const value =
+ "conv-auth-request-" + request.account.id + request.conversationName;
+ const notification = notificationBox.getNotificationWithValue(value);
+ ok(notification, "notification shown");
+ const closePromise = new Promise(resolve => {
+ notification.eventCallback = event => {
+ resolve();
+ };
+ });
+ is(notification.buttonContainer.children.length, 1);
+
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.firstElementChild,
+ {}
+ );
+ await requestPromise;
+ const result = await request.completePromise;
+ ok(result);
+
+ await closePromise;
+ ok(!notificationBox.getNotificationWithValue(value), "notification closed");
+
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js b/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js
new file mode 100644
index 0000000000..d95b5e48c0
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function test_spacesToolbarChatBadgeMUC() {
+ window.gSpacesToolbar.toggleToolbar(false);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const chatButton = document.getElementById("chatButton");
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+
+ // Send a new message in a MUC that is not currently open.
+ const conversation =
+ account.prplAccount.wrappedJSObject.makeMUC("noSpaceBadge");
+ const messagePromise = waitForNotification(conversation, "new-text");
+ conversation.writeMessage("spaceBadge", "just a normal message", {
+ incoming: true,
+ });
+ await messagePromise;
+ // Make sure nothing else was waiting to happen.
+ await TestUtils.waitForTick();
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Untargeted MUC message doesn't change badge"
+ );
+
+ // Send a new targeted message in the conversation.
+ const unreadContainer = chatButton.querySelector(".spaces-badge-container");
+ const unreadContainerText = unreadContainer.textContent;
+ const unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ containsNick: true,
+ });
+ await unreadCountChanged;
+ ok(chatButton.classList.contains("has-badge"), "Unread badge is shown");
+
+ // Fluent doesn't immediately apply the translation, wait for it.
+ await TestUtils.waitForCondition(
+ () => unreadContainer.textContent !== unreadContainerText
+ );
+
+ is(unreadContainer.textContent, "1", "Unread count is in badge");
+ ok(unreadContainer.title);
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function test_spacesToolbarChatBadgeDM() {
+ window.gSpacesToolbar.toggleToolbar(false);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const chatButton = document.getElementById("chatButton");
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+
+ const unreadContainer = chatButton.querySelector(".spaces-badge-container");
+ if (unreadContainer.textContent !== "0") {
+ await BrowserTestUtils.waitForMutationCondition(
+ unreadContainer,
+ {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ },
+ () => unreadContainer.textContent === "0"
+ );
+ }
+
+ // Send a new message in a DM conversation that is not currently open.
+ const unreadContainerText = unreadContainer.textContent;
+ let unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("spaceBadge");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ });
+ await unreadCountChanged;
+ ok(chatButton.classList.contains("has-badge"), "Unread badge is shown");
+
+ // Fluent doesn't immediately apply the translation, wait for it.
+ await TestUtils.waitForCondition(
+ () => unreadContainer.textContent !== unreadContainerText
+ );
+
+ is(unreadContainer.textContent, "1", "Unread count is in badge");
+ ok(unreadContainer.title);
+
+ // Display the DM conversation.
+ unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ await openChatTab();
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+ await unreadCountChanged;
+
+ ok(
+ !chatButton.classList.contains("has-badge"),
+ "Unread badge is hidden again"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function test_spacesToolbarPinnedChatBadgeMUC() {
+ window.gSpacesToolbar.toggleToolbar(true);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const spacesPopupButtonChat = document.getElementById(
+ "spacesPopupButtonChat"
+ );
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+
+ // Send a new message in a MUC that is not currently open.
+ const conversation =
+ account.prplAccount.wrappedJSObject.makeMUC("noSpaceBadge");
+ const messagePromise = waitForNotification(conversation, "new-text");
+ conversation.writeMessage("spaceBadge", "just a normal message", {
+ incoming: true,
+ });
+ await messagePromise;
+ // Make sure nothing else was waiting to happen.
+ await TestUtils.waitForTick();
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Untargeted MUC message doesn't change badge"
+ );
+
+ // Send a new targeted message in the conversation.
+ const unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ containsNick: true,
+ });
+ await unreadCountChanged;
+ ok(
+ spacesPopupButtonChat.classList.contains("has-badge"),
+ "Unread badge is shown"
+ );
+ ok(
+ document
+ .getElementById("spacesPinnedButton")
+ .classList.contains("has-badge"),
+ "Unread state is propagated to pinned menu button"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function test_spacesToolbarPinnedChatBadgeDM() {
+ window.gSpacesToolbar.toggleToolbar(true);
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ if (window.chatHandler._chatButtonUpdatePending) {
+ await TestUtils.waitForTick();
+ }
+
+ const spacesPopupButtonChat = document.getElementById(
+ "spacesPopupButtonChat"
+ );
+ const spacesPinnedButton = document.getElementById("spacesPinnedButton");
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Initially no unread chat messages"
+ );
+ ok(!spacesPinnedButton.classList.contains("has-badge"));
+
+ // Send a new message in a DM conversation that is not currently open.
+ let unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ const conversation = account.prplAccount.wrappedJSObject.makeDM("spaceBadge");
+ conversation.writeMessage("spaceBadge", "new direct message", {
+ incoming: true,
+ });
+ await unreadCountChanged;
+ ok(
+ spacesPopupButtonChat.classList.contains("has-badge"),
+ "Unread badge is shown"
+ );
+ ok(spacesPinnedButton.classList.contains("has-badge"));
+
+ // Display the DM conversation.
+ unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed");
+ await openChatTab();
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+ await unreadCountChanged;
+
+ ok(
+ !spacesPopupButtonChat.classList.contains("has-badge"),
+ "Unread badge is hidden again"
+ );
+ ok(!spacesPinnedButton.classList.contains("has-badge"));
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/browser_tooltips.js b/comm/mail/components/im/test/browser/browser_tooltips.js
new file mode 100644
index 0000000000..db8a7fd86b
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_tooltips.js
@@ -0,0 +1,194 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testMUCMessageSenderTooltip() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("tooltips");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+ const messageParent = await getChatMessageParent(chatConv);
+
+ conversation.addParticipant("foo", "1");
+ conversation.addParticipant("bar", "2");
+ conversation.addParticipant("loremipsum", "3");
+ conversation.addMessages([
+ // Message without alias
+ {
+ who: "foo",
+ content: "hi",
+ options: {
+ incoming: true,
+ },
+ },
+ // Message with alias
+ {
+ who: "bar",
+ content: "o/",
+ options: {
+ incoming: true,
+ _alias: "Bar",
+ },
+ },
+ // Alias is not directly related to nick
+ {
+ who: "loremipsum",
+ content: "what's up?",
+ options: {
+ incoming: true,
+ _alias: "Dolor sit amet",
+ },
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const tooltip = document.getElementById("imTooltip");
+ const tooltipTests = [
+ {
+ messageIndex: 1,
+ who: "foo",
+ alias: "1",
+ displayed: "foo",
+ },
+ {
+ messageIndex: 2,
+ who: "bar",
+ alias: "2",
+ displayed: "Bar",
+ },
+ {
+ messageIndex: 3,
+ who: "loremipsum",
+ alias: "3",
+ displayed: "Dolor sit amet",
+ },
+ ];
+ window.windowUtils.disableNonTestMouseEvents(true);
+ try {
+ for (const testInfo of tooltipTests) {
+ const usernameSelector = `.message:nth-child(${testInfo.messageIndex}) .ib-sender`;
+ const username = messageParent.querySelector(usernameSelector);
+ is(username.textContent, testInfo.displayed);
+
+ let buddyInfo = TestUtils.topicObserved(
+ "user-info-received",
+ (subject, data) => data === testInfo.who
+ );
+ await showTooltip(usernameSelector, tooltip, chatConv.convBrowser);
+
+ is(tooltip.getAttribute("displayname"), testInfo.who);
+ await buddyInfo;
+ is(tooltip.table.querySelector("td").textContent, testInfo.alias);
+ await hideTooltip(tooltip, chatConv.convBrowser);
+ }
+ } finally {
+ window.windowUtils.disableNonTestMouseEvents(false);
+ }
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+add_task(async function testTimestampTooltip() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("tooltips");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv);
+ ok(BrowserTestUtils.is_visible(chatConv));
+
+ const messageTime = Math.floor(Date.now() / 1000);
+
+ conversation.addParticipant("foo", "1");
+ conversation.addMessages([
+ {
+ who: "foo",
+ content: "hi",
+ options: {
+ incoming: true,
+ },
+ time: messageTime,
+ },
+ ]);
+ // Wait for at least one event.
+ do {
+ await BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ } while (chatConv.convBrowser.getPendingMessagesCount() > 0);
+
+ const tooltip = document.getElementById("imTooltip");
+ window.windowUtils.disableNonTestMouseEvents(true);
+ try {
+ const messageSelector = ".message:nth-child(1)";
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "medium",
+ });
+ const expectedText = dateTimeFormatter.format(new Date(messageTime * 1000));
+
+ await showTooltip(messageSelector, tooltip, chatConv.convBrowser);
+
+ const htmlTooltip = tooltip.querySelector(".htmlTooltip");
+ ok(BrowserTestUtils.is_visible(htmlTooltip));
+ is(htmlTooltip.textContent, expectedText);
+ await hideTooltip(tooltip, chatConv.convBrowser);
+ } finally {
+ window.windowUtils.disableNonTestMouseEvents(false);
+ }
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
+
+async function showTooltip(elementSelector, tooltip, browser) {
+ const popupShown = BrowserTestUtils.waitForEvent(tooltip, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ elementSelector,
+ { type: "mousemove" },
+ browser
+ );
+ return popupShown;
+}
+
+async function hideTooltip(tooltip, browser) {
+ const popupHidden = BrowserTestUtils.waitForEvent(tooltip, "popuphidden");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".message .body",
+ { type: "mousemove" },
+ browser
+ );
+ return popupHidden;
+}
diff --git a/comm/mail/components/im/test/browser/browser_updateMessage.js b/comm/mail/components/im/test/browser/browser_updateMessage.js
new file mode 100644
index 0000000000..1aa74a9c64
--- /dev/null
+++ b/comm/mail/components/im/test/browser/browser_updateMessage.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function testUpdate() {
+ const account = IMServices.accounts.createAccount(
+ "testuser",
+ "prpl-mochitest"
+ );
+ account.password = "this is a test";
+ account.connect();
+
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel")));
+
+ const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse");
+ const convNode = getConversationItem(conversation);
+ ok(convNode);
+
+ conversation.writeMessage("mochitest", "hello world", {
+ incoming: true,
+ remoteId: "foo",
+ });
+
+ await EventUtils.synthesizeMouseAtCenter(convNode, {});
+
+ const chatConv = getChatConversationElement(conversation);
+ ok(chatConv, "found conversation");
+ const browserDisplayed = BrowserTestUtils.waitForEvent(
+ chatConv.convBrowser,
+ "MessagesDisplayed"
+ );
+ ok(BrowserTestUtils.is_visible(chatConv), "conversation visible");
+ const messageParent = await getChatMessageParent(chatConv);
+ await browserDisplayed;
+
+ is(
+ messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt")
+ .textContent,
+ "hello world",
+ "message added to conv"
+ );
+
+ const updateTextPromise = waitForNotification(conversation, "update-text");
+ conversation.updateMessage("mochitest", "bye world", {
+ incoming: true,
+ remoteId: "foo",
+ });
+ await updateTextPromise;
+ await TestUtils.waitForTick();
+
+ is(
+ messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt")
+ .textContent,
+ "bye world",
+ "message text updated"
+ );
+
+ conversation.close();
+ account.disconnect();
+ IMServices.accounts.deleteAccount(account.id);
+});
diff --git a/comm/mail/components/im/test/browser/head.js b/comm/mail/components/im/test/browser/head.js
new file mode 100644
index 0000000000..b80d274149
--- /dev/null
+++ b/comm/mail/components/im/test/browser/head.js
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { registerTestProtocol, unregisterTestProtocol } =
+ ChromeUtils.importESModule("resource://testing-common/TestProtocol.sys.mjs");
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+async function openChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.selectedTab = chatMode.tabs[0];
+ } else {
+ window.showChatTab();
+ }
+
+ is(chatMode.tabs.length, 1, "chat tab is open");
+ is(tabmail.selectedTab, chatMode.tabs[0], "chat tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.closeTab(chatMode.tabs[0]);
+ }
+
+ is(chatMode.tabs.length, 0, "chat tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * @param {prplIConversation} conversation
+ * @returns {HTMLElement} The corresponding chat-imconv-richlistitem element.
+ */
+function getConversationItem(conversation) {
+ const convList = document.getElementById("contactlistbox");
+ const convNode = Array.from(convList.children).find(
+ element =>
+ element.getAttribute("is") === "chat-imconv-richlistitem" &&
+ element.getAttribute("displayname") === conversation.name
+ );
+ return convNode;
+}
+
+/**
+ * @param {prplIConversation} conversation
+ * @returns {HTMLElement} The corresponding chat-conversation element.
+ */
+function getChatConversationElement(conversation) {
+ const chatConv = Array.from(
+ document.querySelectorAll("chat-conversation")
+ ).find(element => element._conv.target.wrappedJSObject === conversation);
+ return chatConv;
+}
+
+/**
+ * @param {HTMLElement} chatConv - chat-conversation element.
+ * @returns {HTMLElement} The parent element to all chat messages.
+ */
+async function getChatMessageParent(chatConv) {
+ await BrowserTestUtils.browserLoaded(chatConv.convBrowser);
+ const messageParent = chatConv.convBrowser.contentChatNode;
+ return messageParent;
+}
+
+/**
+ * @param {HTMLElement} [browser] - The conversation-browser element.
+ * @returns {Promise<void>}
+ */
+function waitForConversationLoad(browser) {
+ return TestUtils.topicObserved(
+ "conversation-loaded",
+ subject => !browser || subject === browser
+ );
+}
+
+function waitForNotification(target, expectedTopic) {
+ let observer;
+ let promise = new Promise(resolve => {
+ observer = {
+ observe(subject, topic, data) {
+ if (topic === expectedTopic) {
+ resolve({ subject, data });
+ target.removeObserver(observer);
+ }
+ },
+ };
+ });
+ target.addObserver(observer);
+ return promise;
+}
+
+registerTestProtocol();
+
+registerCleanupFunction(async () => {
+ // Make sure the chat state is clean
+ await closeChatTab();
+
+ const conversations = IMServices.conversations.getConversations();
+ is(conversations.length, 0, "All conversations were closed by their test");
+ for (const conversation of conversations) {
+ try {
+ conversation.close();
+ } catch (error) {
+ ok(false, error.message);
+ }
+ }
+
+ const accounts = IMServices.accounts.getAccounts();
+ is(accounts.length, 0, "All accounts were removed by their test");
+ for (const account of accounts) {
+ try {
+ if (account.connected || account.connecting) {
+ account.disconnect();
+ }
+ IMServices.accounts.deleteAccount(account.id);
+ } catch (error) {
+ ok(false, "Error deleting account " + account.id + ": " + error.message);
+ }
+ }
+
+ unregisterTestProtocol();
+});
diff --git a/comm/mail/components/im/test/components.conf b/comm/mail/components/im/test/components.conf
new file mode 100644
index 0000000000..3f8c09fc09
--- /dev/null
+++ b/comm/mail/components/im/test/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{a4617631-b8b8-4053-8afa-5c4c43498280}',
+ 'contract_ids': ['@mozilla.org/chat/mochitest;1'],
+ 'esModule': 'resource://testing-common/TestProtocol.sys.mjs',
+ 'constructor': 'TestProtocol',
+ },
+]
diff --git a/comm/mail/components/migration/content/migration.js b/comm/mail/components/migration/content/migration.js
new file mode 100644
index 0000000000..10e43600d1
--- /dev/null
+++ b/comm/mail/components/migration/content/migration.js
@@ -0,0 +1,464 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var kIMig = Ci.nsIMailProfileMigrator;
+var kIPStartup = Ci.nsIProfileStartup;
+var kProfileMigratorContractIDPrefix =
+ "@mozilla.org/profile/migrator;1?app=mail&type=";
+
+var MigrationWizard = {
+ _source: "", // Source Profile Migrator ContractID suffix
+ _itemsFlags: kIMig.ALL, // Selected Import Data Sources (16-bit bitfield)
+ _selectedProfile: null, // Selected Profile name to import from
+ _wiz: null,
+ _migrator: null,
+ _autoMigrate: null,
+
+ init() {
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardback", this.onBack.bind(this));
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardcancel", this.onCancel.bind(this));
+
+ let importSourcePage = document.getElementById("importSource");
+ importSourcePage.addEventListener(
+ "pageadvanced",
+ this.onImportSourcePageAdvanced.bind(this)
+ );
+
+ let selectProfilePage = document.getElementById("selectProfile");
+ selectProfilePage.addEventListener(
+ "pageshow",
+ this.onSelectProfilePageShow.bind(this)
+ );
+ selectProfilePage.addEventListener(
+ "pagerewound",
+ this.onSelectProfilePageRewound.bind(this)
+ );
+ selectProfilePage.addEventListener(
+ "pageadvanced",
+ this.onSelectProfilePageAdvanced.bind(this)
+ );
+
+ let importItemsPage = document.getElementById("importItems");
+ importItemsPage.addEventListener(
+ "pageshow",
+ this.onImportItemsPageShow.bind(this)
+ );
+ importItemsPage.addEventListener(
+ "pagerewound",
+ this.onImportItemsPageAdvanced.bind(this)
+ );
+ importItemsPage.addEventListener(
+ "pageadvanced",
+ this.onImportItemsPageAdvanced.bind(this)
+ );
+
+ let migratingPage = document.getElementById("migrating");
+ migratingPage.addEventListener(
+ "pageshow",
+ this.onMigratingPageShow.bind(this)
+ );
+
+ let donePage = document.getElementById("done");
+ donePage.addEventListener("pageshow", this.onDonePageShow.bind(this));
+
+ let failedPage = document.getElementById("failed");
+ failedPage.addEventListener("pageshow", () => (this._failed = true));
+ failedPage.addEventListener("pagerewound", () => (this._failed = false));
+
+ Services.obs.addObserver(this, "Migration:Started");
+ Services.obs.addObserver(this, "Migration:ItemBeforeMigrate");
+ Services.obs.addObserver(this, "Migration:ItemAfterMigrate");
+ Services.obs.addObserver(this, "Migration:Ended");
+ Services.obs.addObserver(this, "Migration:Progress");
+
+ this._wiz = document.querySelector("wizard");
+
+ if ("arguments" in window && !window.arguments[3]) {
+ this._source = window.arguments[0];
+ this._migrator = window.arguments[1]
+ ? window.arguments[1].QueryInterface(kIMig)
+ : null;
+ this._autoMigrate = window.arguments[2].QueryInterface(kIPStartup);
+
+ // Show the "nothing" option in the automigrate case to provide an
+ // easily identifiable way to avoid migration and create a new profile.
+ var nothing = document.getElementById("nothing");
+ nothing.hidden = false;
+ }
+
+ this.onImportSourcePageShow();
+
+ // Behavior alert! If we were given a migrator already, then we are going to perform migration
+ // with that migrator, skip the wizard screen where we show all of the migration sources and
+ // jump right into migration.
+ if (this._migrator) {
+ if (this._migrator.sourceHasMultipleProfiles) {
+ this._wiz.goTo("selectProfile");
+ } else {
+ var sourceProfiles = this._migrator.sourceProfiles;
+ this._selectedProfile = sourceProfiles[0];
+ this._wiz.goTo("migrating");
+ }
+ }
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "Migration:Started");
+ Services.obs.removeObserver(this, "Migration:ItemBeforeMigrate");
+ Services.obs.removeObserver(this, "Migration:ItemAfterMigrate");
+ Services.obs.removeObserver(this, "Migration:Ended");
+ Services.obs.removeObserver(this, "Migration:Progress");
+
+ // Imported accounts don't show up without restarting.
+ if (this._wiz.onLastPage && !this._failed) {
+ MailUtils.restartApplication();
+ }
+ },
+
+ // 1 - Import Source
+ onImportSourcePageShow() {
+ this._wiz.canRewind = false;
+ this._wiz.canAdvance = false;
+
+ // Figure out what source apps are are available to import from:
+ var group = document.getElementById("importSourceGroup");
+ for (let childNode of group.children) {
+ let suffix = childNode.id;
+ if (suffix != "nothing") {
+ var contractID =
+ kProfileMigratorContractIDPrefix + suffix.split("-")[0];
+ var migrator = Cc[contractID].createInstance(kIMig);
+ if (!migrator.sourceExists) {
+ childNode.hidden = true;
+ if (this._source == suffix) {
+ this._source = null;
+ }
+ }
+ }
+ }
+
+ var firstNonDisabled = null;
+ for (let childNode of group.children) {
+ if (!childNode.hidden && !childNode.disabled) {
+ firstNonDisabled = childNode;
+ break;
+ }
+ }
+ group.selectedItem =
+ this._source == ""
+ ? firstNonDisabled
+ : document.getElementById(this._source);
+
+ if (firstNonDisabled) {
+ this._wiz.canAdvance = true;
+ document.getElementById("importSourceFound").hidden = false;
+ return;
+ }
+ // If no usable import module was found, inform user and enable back button.
+ document.getElementById("importSourceNotFound").hidden = false;
+ this._wiz.canRewind = true;
+ this._wiz.getButton("back").setAttribute("hidden", "false");
+ },
+
+ onImportSourcePageAdvanced() {
+ var newSource =
+ document.getElementById("importSourceGroup").selectedItem.id;
+
+ if (newSource == "nothing") {
+ document.querySelector("wizard").cancel();
+ return;
+ }
+
+ if (!this._migrator || newSource != this._source) {
+ // Create the migrator for the selected source.
+ var contractID =
+ kProfileMigratorContractIDPrefix + newSource.split("-")[0];
+ this._migrator = Cc[contractID].createInstance(kIMig);
+
+ this._itemsFlags = kIMig.ALL;
+ this._selectedProfile = null;
+ }
+
+ this._source = newSource;
+
+ // check for more than one source profile
+ if (this._migrator.sourceHasMultipleProfiles) {
+ this._wiz.currentPage.next = "selectProfile";
+ } else {
+ this._wiz.currentPage.next = "migrating";
+ var sourceProfiles = this._migrator.sourceProfiles;
+ if (sourceProfiles && sourceProfiles.length == 1) {
+ this._selectedProfile = sourceProfiles[0];
+ } else {
+ this._selectedProfile = "";
+ }
+ }
+ },
+
+ // 2 - [Profile Selection]
+ onSelectProfilePageShow() {
+ // Disabling this for now, since we ask about import sources in automigration
+ // too and don't want to disable the back button
+ // if (this._autoMigrate)
+ // document.querySelector("wizard").getButton("back").disabled = true;
+
+ var profiles = document.getElementById("profiles");
+ while (profiles.hasChildNodes()) {
+ profiles.lastChild.remove();
+ }
+
+ if (!this._migrator) {
+ return;
+ }
+ var sourceProfiles = this._migrator.sourceProfiles;
+ var count = sourceProfiles.length;
+ for (var i = 0; i < count; ++i) {
+ var item = document.createXULElement("radio");
+ item.id = sourceProfiles[i];
+ item.setAttribute("label", item.id);
+ profiles.appendChild(item);
+ }
+
+ profiles.selectedItem = this._selectedProfile
+ ? document.getElementById(this._selectedProfile)
+ : profiles.firstElementChild;
+ },
+
+ onSelectProfilePageRewound() {
+ var profiles = document.getElementById("profiles");
+ this._selectedProfile = profiles.selectedItem.id;
+ },
+
+ onSelectProfilePageAdvanced() {
+ var profiles = document.getElementById("profiles");
+ this._selectedProfile = profiles.selectedItem.id;
+
+ // If we're automigrating, don't show the item selection page, just grab everything.
+ if (this._autoMigrate) {
+ this._wiz.currentPage.next = "migrating";
+ }
+ },
+
+ // 3 - ImportItems
+ onImportItemsPageShow() {
+ var dataSources = document.getElementById("dataSources");
+ while (dataSources.hasChildNodes()) {
+ dataSources.lastChild.remove();
+ }
+
+ var bundle = document.getElementById("bundle");
+
+ var items = this._migrator.getMigrateData(
+ this._selectedProfile,
+ this._autoMigrate
+ );
+ for (var i = 0; i < 16; ++i) {
+ var itemID = (items >> i) & 0x1 ? Math.pow(2, i) : 0;
+ if (itemID > 0) {
+ var checkbox = document.createXULElement("checkbox");
+ checkbox.id = itemID;
+ checkbox.setAttribute(
+ "label",
+ bundle.getString(itemID + "_" + this._source.split("-")[0])
+ );
+ dataSources.appendChild(checkbox);
+ if (!this._itemsFlags || this._itemsFlags & itemID) {
+ checkbox.checked = true;
+ }
+ }
+ }
+ },
+
+ onImportItemsPageAdvanced() {
+ var dataSources = document.getElementById("dataSources");
+ this._itemsFlags = 0;
+ for (var i = 0; i < dataSources.children.length; ++i) {
+ var checkbox = dataSources.children[i];
+ if (checkbox.localName == "checkbox" && checkbox.checked) {
+ this._itemsFlags |= parseInt(checkbox.id);
+ }
+ }
+ },
+
+ onImportItemCommand(aEvent) {
+ var items = document.getElementById("dataSources");
+ var checkboxes = items.getElementsByTagName("checkbox");
+
+ var oneChecked = false;
+ for (var i = 0; i < checkboxes.length; ++i) {
+ if (checkboxes[i].checked) {
+ oneChecked = true;
+ break;
+ }
+ }
+
+ this._wiz.canAdvance = oneChecked;
+ },
+
+ // 4 - Migrating
+ async onMigratingPageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._wiz.canAdvance = false;
+
+ // When automigrating or migrating all, show all of the data that can
+ // be received from this source.
+ if (this._autoMigrate || this._itemsFlags == kIMig.ALL) {
+ this._itemsFlags = this._migrator.getMigrateData(
+ this._selectedProfile,
+ this._autoMigrate
+ );
+ }
+
+ this._listItems("migratingItems");
+ try {
+ await this.onMigratingMigrate();
+ } catch (e) {
+ switch (e.message) {
+ case "file-picker-cancelled":
+ this._wiz.canRewind = true;
+ this._wiz.rewind();
+ this._wiz.canAdvance = true;
+ return;
+ case "zip-file-too-big":
+ this._wiz.canRewind = true;
+ this._wiz.rewind();
+ this._wiz.canAdvance = true;
+ let [zipFileTooBigTitle, zipFileTooBigMessage] =
+ await document.l10n.formatValues([
+ "zip-file-too-big-title",
+ "zip-file-too-big-message",
+ ]);
+ Services.prompt.alert(
+ window,
+ zipFileTooBigTitle,
+ zipFileTooBigMessage
+ );
+ document.getElementById("importSourceGroup").selectedItem =
+ document.getElementById("thunderbird-dir");
+ return;
+ default:
+ document.getElementById("failed-message-default").hidden = e.message;
+ document.getElementById("failed-message").hidden = !e.message;
+ document.getElementById("failed-message").textContent =
+ e.message || "";
+ this._wiz.canAdvance = true;
+ this._wiz.advance("failed");
+ throw e;
+ }
+ }
+ },
+
+ async onMigratingMigrate(aOuter) {
+ let [source, type] = this._source.split("-");
+ if (source == "thunderbird") {
+ // Ask user for the profile directory location.
+ await this._migrator.wrappedJSObject.getProfileDir(window, type);
+ await this._migrator.wrappedJSObject.asyncMigrate();
+ return;
+ }
+ this._migrator.migrate(
+ this._itemsFlags,
+ this._autoMigrate,
+ this._selectedProfile
+ );
+ },
+
+ _listItems(aID) {
+ var items = document.getElementById(aID);
+ while (items.hasChildNodes()) {
+ items.lastChild.remove();
+ }
+
+ var bundle = document.getElementById("bundle");
+ for (var i = 0; i < 16; ++i) {
+ var itemID = (this._itemsFlags >> i) & 0x1 ? Math.pow(2, i) : 0;
+ if (itemID > 0) {
+ var label = document.createXULElement("label");
+ label.id = itemID + "_migrated";
+ try {
+ label.setAttribute(
+ "value",
+ "- " + bundle.getString(itemID + "_" + this._source.split("-")[0])
+ );
+ items.appendChild(label);
+ } catch (e) {
+ // if the block above throws, we've enumerated all the import data types we
+ // currently support and are now just wasting time, break.
+ break;
+ }
+ }
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "Migration:Started":
+ dump("*** started\n");
+ break;
+ case "Migration:ItemBeforeMigrate": {
+ dump("*** before " + aData + "\n");
+ let label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.setAttribute("style", "font-weight: bold");
+ }
+ break;
+ }
+ case "Migration:ItemAfterMigrate": {
+ dump("*** after " + aData + "\n");
+ let label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.removeAttribute("style");
+ }
+ break;
+ }
+ case "Migration:Ended":
+ dump("*** done\n");
+ if (this._autoMigrate) {
+ // We're done now.
+ this._wiz.canAdvance = true;
+ this._wiz.advance();
+ setTimeout(window.close, 5000);
+ } else {
+ this._wiz.canAdvance = true;
+ var nextButton = this._wiz.getButton("next");
+ nextButton.click();
+ }
+ break;
+ case "Migration:Progress":
+ document.getElementById("progressBar").value = aData;
+ break;
+ }
+ },
+
+ onDonePageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._listItems("doneItems");
+ },
+
+ onBack(event) {
+ this._wiz.goTo("importSource");
+ this._wiz.canRewind = false;
+ event.preventDefault();
+ },
+
+ onCancel() {
+ // If .closeMigration is false, the user clicked Back button,
+ // then do not change its value.
+ if (
+ window.arguments[3] &&
+ "closeMigration" in window.arguments[3] &&
+ window.arguments[3].closeMigration !== false
+ ) {
+ window.arguments[3].closeMigration = true;
+ }
+ },
+};
diff --git a/comm/mail/components/migration/content/migration.xhtml b/comm/mail/components/migration/content/migration.xhtml
new file mode 100644
index 0000000000..4171d02f63
--- /dev/null
+++ b/comm/mail/components/migration/content/migration.xhtml
@@ -0,0 +1,89 @@
+<?xml version="1.0"?>
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+
+<!DOCTYPE window SYSTEM "chrome://messenger/locale/migration/migration.dtd" >
+
+<window id="migrationWizard"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&migrationWizard.title;"
+ onload="MigrationWizard.init()"
+ onunload="MigrationWizard.uninit()"
+ style="width: 40em;"
+ branded="true"
+ buttons="accept,cancel">
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/wizard.ftl"/>
+ <html:link rel="localization" href="messenger/importDialog.ftl"/>
+ </linkset>
+
+ <script src="chrome://messenger/content/migration/migration.js"/>
+
+ <stringbundle id="bundle" src="chrome://messenger/locale/migration/migration.properties"/>
+
+ <wizard>
+ <wizardpage id="importSource" pageid="importSource" next="selectProfile"
+ label="&importSource.title;">
+ <vbox id="importSourceFound" hidden="true">
+#ifdef XP_WIN
+ <label control="importSourceGroup">&importFromWin.label;</label>
+#else
+ <label control="importSourceGroup">&importFromNonWin.label;</label>
+#endif
+ <radiogroup id="importSourceGroup">
+ <radio id="thunderbird-zip" data-l10n-id="import-from-thunderbird-zip"/>
+ <radio id="thunderbird-dir" data-l10n-id="import-from-thunderbird-dir"/>
+ <radio id="seamonkey" label="&importFromSeamonkey3.label;"
+ accesskey="&importFromSeamonkey3.accesskey;"/>
+#ifdef XP_WIN
+ <radio id="outlook" label="&importFromOutlook.label;"
+ accesskey="&importFromOutlook.accesskey;"/>
+#endif
+ <radio id="nothing" label="&importFromNothing.label;"
+ accesskey="&importFromNothing.accesskey;" hidden="true"/>
+ </radiogroup>
+ </vbox>
+ <label id="importSourceNotFound" hidden="true">&importSourceNotFound.label;</label>
+ </wizardpage>
+
+ <wizardpage id="selectProfile" pageid="selectProfile" label="&selectProfile.title;"
+ next="importItems">
+ <label control="profiles">&selectProfile.label;</label>
+ <radiogroup id="profiles" align="start"/>
+ </wizardpage>
+
+ <wizardpage id="importItems" pageid="importItems" label="&importItems.title;"
+ next="migrating"
+ oncommand="MigrationWizard.onImportItemCommand();">
+ <description>&importItems.label;</description>
+ <vbox id="dataSources"
+ style="overflow: auto; appearance: auto; -moz-default-appearance: listbox"
+ align="start" flex="1"/>
+ </wizardpage>
+
+ <wizardpage id="migrating" pageid="migrating" label="&migrating.title;"
+ next="done">
+ <description>&migrating.label;</description>
+ <separator class="thin"/>
+ <vbox id="migratingItems" class="indent" style="overflow: auto;" flex="1" align="start"/>
+ <separator class="thin"/>
+ <html:progress class="progressmeter-statusbar" id="progressBar" flex="1" value="0" max="100"/>
+ </wizardpage>
+
+ <wizardpage id="done" pageid="done" label="&done.title;">
+ <description>&done.label;</description>
+ <separator class="thin"/>
+ <vbox id="doneItems" class="indent" style="overflow: auto;" align="start"/>
+ </wizardpage>
+
+ <wizardpage id="failed" pageid="failed" data-l10n-id="wizardpage-failed">
+ <description id="failed-message-default"
+ data-l10n-id="wizardpage-failed-message"></description>
+ <description id="failed-message"></description>
+ </wizardpage>
+ </wizard>
+</window>
diff --git a/comm/mail/components/migration/jar.mn b/comm/mail/components/migration/jar.mn
new file mode 100644
index 0000000000..db93c8847e
--- /dev/null
+++ b/comm/mail/components/migration/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+* content/messenger/migration/migration.xhtml (content/migration.xhtml)
+ content/messenger/migration/migration.js (content/migration.js)
diff --git a/comm/mail/components/migration/moz.build b/comm/mail/components/migration/moz.build
new file mode 100644
index 0000000000..110c0645dd
--- /dev/null
+++ b/comm/mail/components/migration/moz.build
@@ -0,0 +1,11 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "public",
+ "src",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/migration/public/moz.build b/comm/mail/components/migration/public/moz.build
new file mode 100644
index 0000000000..20c4c0e5c5
--- /dev/null
+++ b/comm/mail/components/migration/public/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "nsIMailProfileMigrator.idl",
+]
+
+XPIDL_MODULE = "mailprofilemigration"
+
+EXPORTS += []
diff --git a/comm/mail/components/migration/public/nsIMailProfileMigrator.idl b/comm/mail/components/migration/public/nsIMailProfileMigrator.idl
new file mode 100644
index 0000000000..398857b459
--- /dev/null
+++ b/comm/mail/components/migration/public/nsIMailProfileMigrator.idl
@@ -0,0 +1,70 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+interface nsIProfileStartup;
+
+[scriptable, uuid(fca38a7a-c43f-4b28-adbd-61e5cc942508)]
+interface nsIMailProfileMigrator : nsISupports
+{
+ /**
+ * profile items to migrate. use with migrate().
+ */
+ const unsigned short ALL = 0x0000;
+ const unsigned short SETTINGS = 0x0001;
+ const unsigned short ACCOUNT_SETTINGS = 0x0002;
+ const unsigned short ADDRESSBOOK_DATA = 0x0004;
+ const unsigned short JUNKTRAINING = 0x0008;
+ const unsigned short PASSWORDS = 0x0010;
+ const unsigned short OTHERDATA = 0x0020;
+ const unsigned short NEWSDATA = 0x0040;
+ const unsigned short MAILDATA = 0x0080;
+ const unsigned short FILTERS = 0x0100;
+
+ /**
+ * Copy user profile information to the current active profile.
+ * @param aItems list of data items to migrate. see above for values.
+ * @param aReplace replace or append current data where applicable.
+ * @param aProfile profile to migrate from, if there is more than one.
+ */
+ void migrate(in unsigned short aItems, in nsIProfileStartup aStartup, in wstring aProfile);
+
+ /**
+ * A bit field containing profile items that this migrator
+ * offers for import.
+ * @param aProfile the profile that we are looking for available data
+ * to import
+ * @param aStarting "true" if the profile is not currently being used.
+ * @returns bit field containing profile items (see above)
+ */
+ unsigned short getMigrateData(in wstring aProfile, in boolean aDoingStartup);
+
+ /**
+ * Whether or not there is any data that can be imported from this
+ * mailer (i.e. whether or not it is installed, and there exists
+ * a user profile)
+ */
+ readonly attribute boolean sourceExists;
+
+ /**
+ * Whether or not the import source implementing this interface
+ * has multiple user profiles configured.
+ */
+ readonly attribute boolean sourceHasMultipleProfiles;
+
+ /**
+ * An array of available profile names. If the import source does not support
+ * profiles, this attribute is empty.
+ */
+ readonly attribute Array<AString> sourceProfiles;
+
+ /**
+ * An array of available profile locations. If the import source does not
+ * support profiles, this attribute is empty.
+ */
+ readonly attribute Array<nsIFile> sourceProfileLocations;
+};
diff --git a/comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm b/comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm
new file mode 100644
index 0000000000..6827ea5523
--- /dev/null
+++ b/comm/mail/components/migration/src/ThunderbirdProfileMigrator.jsm
@@ -0,0 +1,869 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ThunderbirdProfileMigrator"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const lazy = {};
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/importDialog.ftl"])
+);
+
+// Pref branches that need special handling.
+const MAIL_IDENTITY = "mail.identity.";
+const MAIL_SERVER = "mail.server.";
+const MAIL_ACCOUNT = "mail.account.";
+const IM_ACCOUNT = "messenger.account.";
+const SMTP_SERVER = "mail.smtpserver.";
+const ADDRESS_BOOK = "ldap_2.servers.";
+const LDAP_AUTO_COMPLETE = "ldap_2.autoComplete.";
+const CALENDAR = "calendar.registry.";
+
+// Prefs (branches) that we do not want to copy directly.
+const IGNORE_PREFS = [
+ "app.update.",
+ "browser.",
+ "calendar.list.sortOrder",
+ "calendar.timezone",
+ "devtools.",
+ "extensions.",
+ "mail.accountmanager.",
+ "mail.cloud_files.accounts.",
+ "mail.newsrc_root",
+ "mail.root.",
+ "mail.smtpservers",
+ "messenger.accounts",
+ "print.",
+ "services.",
+ "toolkit.telemetry.",
+];
+
+// When importing from a zip file, ignoring these folders.
+const IGNORE_DIRS = [
+ "chrome_debugger_profile",
+ "crashes",
+ "datareporting",
+ "extensions",
+ "extension-store",
+ "logs",
+ "minidumps",
+ "saved-telemetry-pings",
+ "security_state",
+ "storage",
+ "xulstore",
+];
+
+/**
+ * A pref is represented as [type, name, value].
+ *
+ * @typedef {["Bool"|"Char"|"Int", string, number|string|boolean]} PrefItem
+ *
+ * A map from source smtp server key to target smtp server key.
+ * @typedef {Map<string, string>} SmtpServerKeyMap
+ *
+ * A map from source identity key to target identity key.
+ * @typedef {Map<string, string>} IdentityKeyMap
+ *
+ * A map from source IM account key to target IM account key.
+ * @typedef {Map<string, string>} IMAccountKeyMap
+ *
+ * A map from source incoming server key to target incoming server key.
+ * @typedef {Map<string, string>} IncomingServerKeyMap
+ */
+
+/**
+ * A class to support importing from a Thunderbird profile directory.
+ *
+ * @implements {nsIMailProfileMigrator}
+ */
+class ThunderbirdProfileMigrator {
+ QueryInterface = ChromeUtils.generateQI(["nsIMailProfileMigrator"]);
+
+ get wrappedJSObject() {
+ return this;
+ }
+
+ _logger = console.createInstance({
+ prefix: "mail.import",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.import.loglevel",
+ });
+
+ get sourceExists() {
+ return true;
+ }
+
+ get sourceProfiles() {
+ return this._sourceProfileDir ? [this._sourceProfileDir.path] : [];
+ }
+
+ get sourceHasMultipleProfiles() {
+ return false;
+ }
+
+ /**
+ * Other profile migrators try known install directories to get a source
+ * profile dir. But in this class, we always ask user for the profile
+ * location.
+ */
+ async getProfileDir(window, type) {
+ let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ let [filePickerTitleZip, filePickerTitleDir] = await lazy.l10n.formatValues(
+ ["import-select-profile-zip", "import-select-profile-dir"]
+ );
+ switch (type) {
+ case "zip":
+ filePicker.init(window, filePickerTitleZip, filePicker.modeOpen);
+ filePicker.appendFilter("", "*.zip");
+ break;
+ case "dir":
+ filePicker.init(window, filePickerTitleDir, filePicker.modeGetFolder);
+ break;
+ default:
+ throw new Error(`Unsupported type: ${type}`);
+ }
+ let selectedFile = await new Promise((resolve, reject) => {
+ filePicker.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !filePicker.file) {
+ reject(new Error("file-picker-cancelled"));
+ return;
+ }
+ resolve(filePicker.file);
+ });
+ });
+ if (selectedFile.isDirectory()) {
+ this._sourceProfileDir = selectedFile;
+ } else {
+ if (selectedFile.fileSize > 2147483647) {
+ // nsIZipReader only supports zip file less than 2GB.
+ // throw new Error(zipFileTooBigMessage);
+ throw new Error("zip-file-too-big");
+ }
+ this._importingFromZip = true;
+ // Extract the zip file to a tmp dir.
+ let targetDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ targetDir.append("tmp-profile");
+ targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ let ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+ );
+ let zip = ZipReader(filePicker.file);
+ for (let entry of zip.findEntries(null)) {
+ let parts = entry.split("/");
+ if (IGNORE_DIRS.includes(parts[1]) || entry.endsWith("/")) {
+ continue;
+ }
+ // Folders can not be unzipped recursively, have to iterate and
+ // extract all file entries one by one.
+ let target = targetDir.clone();
+ for (let part of parts.slice(1)) {
+ // Drop the root folder name in the zip file.
+ target.append(part);
+ }
+ if (!target.parent.exists()) {
+ target.parent.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ try {
+ this._logger.debug(`Extracting ${entry} to ${target.path}`);
+ zip.extract(entry, target);
+ } catch (e) {
+ this._logger.error(e);
+ }
+ }
+ // Use the tmp dir as source profile dir.
+ this._sourceProfileDir = targetDir;
+ }
+ }
+
+ getMigrateData() {
+ return (
+ Ci.nsIMailProfileMigrator.ACCOUNT_SETTINGS |
+ Ci.nsIMailProfileMigrator.MAILDATA |
+ Ci.nsIMailProfileMigrator.NEWSDATA |
+ Ci.nsIMailProfileMigrator.ADDRESSBOOK_DATA |
+ Ci.nsIMailProfileMigrator.SETTINGS
+ );
+ }
+
+ migrate(items, startup, profile) {
+ throw new Error("migrate not implemented");
+ }
+
+ async asyncMigrate() {
+ Services.obs.notifyObservers(null, "Migration:Started");
+ try {
+ await this._importPreferences();
+ } finally {
+ if (this._importingFromZip) {
+ IOUtils.remove(this._sourceProfileDir.path, { recursive: true });
+ }
+ }
+ Services.obs.notifyObservers(null, "Migration:Ended");
+ }
+
+ /**
+ * Collect interested prefs from this._sourceProfileDir, then import them one
+ * by one.
+ */
+ async _importPreferences() {
+ // A Map to collect all prefs in interested pref branches.
+ // @type {Map<string, PrefItem[]>}
+ let branchPrefsMap = new Map([
+ [MAIL_IDENTITY, []],
+ [MAIL_SERVER, []],
+ [MAIL_ACCOUNT, []],
+ [IM_ACCOUNT, []],
+ [SMTP_SERVER, []],
+ [ADDRESS_BOOK, []],
+ [CALENDAR, []],
+ ]);
+ let accounts;
+ let defaultAccount;
+ let defaultSmtpServer;
+ let ldapAutoComplete = {};
+ let otherPrefs = [];
+
+ let sourcePrefsFile = this._sourceProfileDir.clone();
+ sourcePrefsFile.append("prefs.js");
+ let sourcePrefsBuffer = await IOUtils.read(sourcePrefsFile.path);
+
+ let savePref = (type, name, value) => {
+ for (let [branchName, branchPrefs] of branchPrefsMap) {
+ if (name.startsWith(branchName)) {
+ branchPrefs.push([type, name.slice(branchName.length), value]);
+ return;
+ }
+ }
+ if (name == "mail.accountmanager.accounts") {
+ accounts = value;
+ return;
+ }
+ if (name == "mail.accountmanager.defaultaccount") {
+ defaultAccount = value;
+ return;
+ }
+ if (name == "mail.smtp.defaultserver") {
+ defaultSmtpServer = value;
+ return;
+ }
+ if (name.startsWith(LDAP_AUTO_COMPLETE)) {
+ ldapAutoComplete[name.slice(LDAP_AUTO_COMPLETE.length)] = value;
+ return;
+ }
+ if (IGNORE_PREFS.some(ignore => name.startsWith(ignore))) {
+ return;
+ }
+ // Collect all the other prefs.
+ otherPrefs.push([type, name, value]);
+ };
+
+ Services.prefs.parsePrefsFromBuffer(sourcePrefsBuffer, {
+ onStringPref: (kind, name, value) => savePref("Char", name, value),
+ onIntPref: (kind, name, value) => savePref("Int", name, value),
+ onBoolPref: (kind, name, value) => savePref("Bool", name, value),
+ onError: msg => {
+ throw new Error(msg);
+ },
+ });
+
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.ACCOUNT_SETTINGS
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ // Import SMTP servers first, the importing order is important.
+ let smtpServerKeyMap = this._importSmtpServers(
+ branchPrefsMap.get(SMTP_SERVER),
+ defaultSmtpServer
+ );
+ // mail.identity.idN.smtpServer depends on transformed smtp server key.
+ let identityKeyMap = this._importIdentities(
+ branchPrefsMap.get(MAIL_IDENTITY),
+ smtpServerKeyMap
+ );
+ let imAccountKeyMap = await this._importIMAccounts(
+ branchPrefsMap.get(IM_ACCOUNT)
+ );
+ // mail.server.serverN.imAccount depends on transformed im account key.
+ let incomingServerKeyMap = await this._importIncomingServers(
+ branchPrefsMap.get(MAIL_SERVER),
+ imAccountKeyMap
+ );
+ // mail.account.accountN.{identities, server} depends on previous steps.
+ this._importAccounts(
+ branchPrefsMap.get(MAIL_ACCOUNT),
+ accounts,
+ defaultAccount,
+ identityKeyMap,
+ incomingServerKeyMap
+ );
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.ACCOUNT_SETTINGS
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "25");
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.MAILDATA
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ await this._copyMailFolders(incomingServerKeyMap);
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.MAILDATA
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "50");
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.ADDRESSBOOK_DATA
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this._importAddressBooks(
+ branchPrefsMap.get(ADDRESS_BOOK),
+ ldapAutoComplete
+ );
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.ADDRESSBOOK_DATA
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "75");
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemBeforeMigrate",
+ Ci.nsIMailProfileMigrator.SETTINGS
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this._importPasswords();
+ this._importOtherPrefs(otherPrefs);
+ this._importCalendars(branchPrefsMap.get(CALENDAR));
+ Services.obs.notifyObservers(
+ null,
+ "Migration:ItemAfterMigrate",
+ Ci.nsIMailProfileMigrator.SETTINGS
+ );
+ Services.obs.notifyObservers(null, "Migration:Progress", "100");
+ }
+
+ /**
+ * Import SMTP servers.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the SMTP_SERVER branch.
+ * @param {string} sourceDefaultServer - The value of mail.smtp.defaultserver
+ * in the source profile.
+ * @returns {smtpServerKeyMap} A map from source server key to new server key.
+ */
+ _importSmtpServers(prefs, sourceDefaultServer) {
+ let smtpServerKeyMap = new Map();
+ let branch = Services.prefs.getBranch(SMTP_SERVER);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newServerKey = smtpServerKeyMap.get(key);
+ if (!newServerKey) {
+ // For every smtp server, create a new one to avoid conflicts.
+ let server = MailServices.smtp.createServer();
+ newServerKey = server.key;
+ smtpServerKeyMap.set(key, newServerKey);
+ this._logger.debug(
+ `Mapping SMTP server from ${key} to ${newServerKey}`
+ );
+ }
+
+ let newName = `${newServerKey}${name.slice(key.length)}`;
+ branch[`set${type}Pref`](newName, value);
+ }
+
+ // Set defaultserver if it doesn't already exist.
+ let defaultServer = Services.prefs.getCharPref(
+ "mail.smtp.defaultserver",
+ ""
+ );
+ if (sourceDefaultServer && !defaultServer) {
+ Services.prefs.setCharPref(
+ "mail.smtp.defaultserver",
+ smtpServerKeyMap.get(sourceDefaultServer)
+ );
+ }
+ return smtpServerKeyMap;
+ }
+
+ /**
+ * Import mail identites.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the MAIL_IDENTITY branch.
+ * @param {SmtpServerKeyMap} smtpServerKeyMap - A map from the source SMTP
+ * server key to new SMTP server key.
+ * @returns {IdentityKeyMap} A map from the source identity key to new identity
+ * key.
+ */
+ _importIdentities(prefs, smtpServerKeyMap) {
+ let identityKeyMap = new Map();
+ let branch = Services.prefs.getBranch(MAIL_IDENTITY);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newIdentityKey = identityKeyMap.get(key);
+ if (!newIdentityKey) {
+ // For every identity, create a new one to avoid conflicts.
+ let identity = MailServices.accounts.createIdentity();
+ newIdentityKey = identity.key;
+ identityKeyMap.set(key, newIdentityKey);
+ this._logger.debug(`Mapping identity from ${key} to ${newIdentityKey}`);
+ }
+
+ let newName = `${newIdentityKey}${name.slice(key.length)}`;
+ let newValue = value;
+ if (name.endsWith(".smtpServer")) {
+ newValue = smtpServerKeyMap.get(value) || newValue;
+ }
+ branch[`set${type}Pref`](newName, newValue);
+ }
+ return identityKeyMap;
+ }
+
+ /**
+ * Import IM accounts.
+ *
+ * @param {Array<[string, string, number|string|boolean]>} prefs - All source
+ * prefs in the IM_ACCOUNT branch.
+ * @returns {IMAccountKeyMap} A map from the source account key to new account
+ * key.
+ */
+ async _importIMAccounts(prefs) {
+ let imAccountKeyMap = new Map();
+ let branch = Services.prefs.getBranch(IM_ACCOUNT);
+
+ let lastKey = 1;
+ function _getUniqueAccountKey() {
+ let key = `account${lastKey++}`;
+ if (Services.prefs.getCharPref(`messenger.account.${key}.name`, "")) {
+ return _getUniqueAccountKey();
+ }
+ return key;
+ }
+
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newAccountKey = imAccountKeyMap.get(key);
+ if (!newAccountKey) {
+ // For every account, create a new one to avoid conflicts.
+ newAccountKey = _getUniqueAccountKey();
+ imAccountKeyMap.set(key, newAccountKey);
+ this._logger.debug(
+ `Mapping IM account from ${key} to ${newAccountKey}`
+ );
+ }
+
+ let newName = `${newAccountKey}${name.slice(key.length)}`;
+ branch[`set${type}Pref`](newName, value);
+ }
+
+ return imAccountKeyMap;
+ }
+
+ /**
+ * Import incoming servers.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the MAIL_SERVER branch.
+ * @param {IMAccountKeyMap} imAccountKeyMap - A map from the source account
+ * key to new account key.
+ * @returns {IncomingServerKeyMap} A map from the source server key to new
+ * server key.
+ */
+ async _importIncomingServers(prefs, imAccountKeyMap) {
+ let incomingServerKeyMap = new Map();
+ let branch = Services.prefs.getBranch(MAIL_SERVER);
+
+ let lastKey = 1;
+ function _getUniqueIncomingServerKey() {
+ let key = `server${lastKey++}`;
+ if (branch.getCharPref(`${key}.type`, "")) {
+ return _getUniqueIncomingServerKey();
+ }
+ return key;
+ }
+
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ let newServerKey = incomingServerKeyMap.get(key);
+ if (!newServerKey) {
+ // For every incoming server, create a new one to avoid conflicts.
+ newServerKey = _getUniqueIncomingServerKey();
+ incomingServerKeyMap.set(key, newServerKey);
+ this._logger.debug(`Mapping server from ${key} to ${newServerKey}`);
+ }
+
+ let newName = `${newServerKey}${name.slice(key.length)}`;
+ let newValue = value;
+ if (newName.endsWith(".imAccount")) {
+ newValue = imAccountKeyMap.get(value);
+ }
+ branch[`set${type}Pref`](newName, newValue || value);
+ }
+ return incomingServerKeyMap;
+ }
+
+ /**
+ * Copy mail folders from this._sourceProfileDir to the current profile dir.
+ *
+ * @param {PrefKeyMap} incomingServerKeyMap - A map from the source server key
+ * to new server key.
+ */
+ async _copyMailFolders(incomingServerKeyMap) {
+ for (let key of incomingServerKeyMap.values()) {
+ let branch = Services.prefs.getBranch(`${MAIL_SERVER}${key}.`);
+ if (!branch) {
+ continue;
+ }
+ let type = branch.getCharPref("type", "");
+ let hostname = branch.getCharPref("hostname", "");
+ if (!type || !hostname) {
+ continue;
+ }
+
+ // Use .directory-rel instead of .directory because .directory is an
+ // absolute path which may not exists.
+ let directoryRel = branch.getCharPref("directory-rel", "");
+ if (!directoryRel.startsWith("[ProfD]")) {
+ continue;
+ }
+ directoryRel = directoryRel.slice("[ProfD]".length);
+
+ let targetDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ if (type == "imap") {
+ targetDir.append("ImapMail");
+ } else if (type == "nntp") {
+ targetDir.append("News");
+ } else if (["none", "pop3", "rss"].includes(type)) {
+ targetDir.append("Mail");
+ } else {
+ continue;
+ }
+
+ // Use the hostname as mail folder name and ensure it's unique.
+ targetDir.append(hostname);
+ targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ // Remove the folder so that nsIFile.copyTo doesn't copy into targetDir.
+ targetDir.remove(false);
+
+ let sourceDir = this._sourceProfileDir.clone();
+ for (let part of directoryRel.split("/")) {
+ sourceDir.append(part);
+ }
+ if (
+ sourceDir.exists() &&
+ (type != "imap" || Services.appinfo.OS != "WINNT")
+ ) {
+ // For some reasons, if mail folders are copied on Windows,
+ // `errorGettingDB` is thrown after imported and restarted. IMAP folders
+ // will be downloaded automatically, better than a broken account.
+ this._logger.debug(`Copying ${sourceDir.path} to ${targetDir.path}`);
+ sourceDir.copyTo(targetDir.parent, targetDir.leafName);
+ }
+ branch.setCharPref("directory", targetDir.path);
+ // .directory-rel may be outdated, it will be created when first needed.
+ branch.clearUserPref("directory-rel");
+
+ if (type == "nntp") {
+ // Use .file-rel instead of .file because .file is an absolute path
+ // which may not exists.
+ let fileRel = branch.getCharPref("newsrc.file-rel", "");
+ if (!fileRel.startsWith("[ProfD]")) {
+ continue;
+ }
+ fileRel = fileRel.slice("[ProfD]".length);
+ let sourceNewsrc = this._sourceProfileDir.clone();
+ for (let part of fileRel.split("/")) {
+ sourceNewsrc.append(part);
+ }
+ let targetNewsrc = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetNewsrc.append("News");
+ targetNewsrc.append(`newsrc-${hostname}`);
+ targetNewsrc.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+ this._logger.debug(
+ `Copying ${sourceNewsrc.path} to ${targetNewsrc.path}`
+ );
+ sourceNewsrc.copyTo(targetNewsrc.parent, targetNewsrc.leafName);
+ branch.setCharPref("newsrc.file", targetNewsrc.path);
+ // .file-rel may be outdated, it will be created when first needed.
+ branch.clearUserPref("newsrc.file-rel");
+ }
+ }
+ }
+
+ /**
+ * Import mail accounts.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the MAIL_ACCOUNT branch.
+ * @param {string} sourceAccounts - The value of mail.accountmanager.accounts
+ * in the source profile.
+ * @param {string} sourceDefaultAccount - The value of
+ * mail.accountmanager.defaultaccount in the source profile.
+ * @param {IdentityKeyMap} identityKeyMap - A map from the source identity key
+ * to new identity key.
+ * @param {IncomingServerKeyMap} incomingServerKeyMap - A map from the source
+ * server key to new server key.
+ */
+ _importAccounts(
+ prefs,
+ sourceAccounts,
+ sourceDefaultAccount,
+ identityKeyMap,
+ incomingServerKeyMap
+ ) {
+ let accountKeyMap = new Map();
+ let branch = Services.prefs.getBranch(MAIL_ACCOUNT);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ if (key == "lastKey") {
+ continue;
+ }
+ let newAccountKey = accountKeyMap.get(key);
+ if (!newAccountKey) {
+ // For every account, create a new one to avoid conflicts.
+ newAccountKey = MailServices.accounts.getUniqueAccountKey();
+ accountKeyMap.set(key, newAccountKey);
+ }
+
+ let newName = `${newAccountKey}${name.slice(key.length)}`;
+ let newValue = value;
+ if (name.endsWith(".identities")) {
+ newValue = identityKeyMap.get(value);
+ } else if (name.endsWith(".server")) {
+ newValue = incomingServerKeyMap.get(value);
+ }
+ branch[`set${type}Pref`](newName, newValue || value);
+ }
+
+ // Append newly create accounts to mail.accountmanager.accounts.
+ let accounts = Services.prefs
+ .getCharPref("mail.accountmanager.accounts", "")
+ .split(",");
+ if (accounts.length == 1 && accounts[0] == "") {
+ accounts.length = 0;
+ }
+ if (sourceAccounts) {
+ for (let sourceAccountKey of sourceAccounts.split(",")) {
+ accounts.push(accountKeyMap.get(sourceAccountKey));
+ }
+ Services.prefs.setCharPref(
+ "mail.accountmanager.accounts",
+ accounts.join(",")
+ );
+ }
+
+ // Set defaultaccount if it doesn't already exist.
+ let defaultAccount = Services.prefs.getCharPref(
+ "mail.accountmanager.defaultaccount",
+ ""
+ );
+ if (sourceDefaultAccount && !defaultAccount) {
+ Services.prefs.setCharPref(
+ "mail.accountmanager.defaultaccount",
+ accountKeyMap.get(sourceDefaultAccount)
+ );
+ }
+ }
+
+ /**
+ * Import address books.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the ADDRESS_BOOK branch.
+ * @param {object} ldapAutoComplete - Pref values of LDAP_AUTO_COMPLETE branch.
+ * @param {boolean} ldapAutoComplete.useDirectory
+ * @param {string} ldapAutoComplete.directoryServer
+ */
+ _importAddressBooks(prefs, ldapAutoComplete) {
+ let keyMap = new Map();
+ let branch = Services.prefs.getBranch(ADDRESS_BOOK);
+ for (let [type, name, value] of prefs) {
+ let key = name.split(".")[0];
+ if (["pab", "history"].includes(key)) {
+ continue;
+ }
+ let newKey = keyMap.get(key);
+ if (!newKey) {
+ // For every address book, create a new one to avoid conflicts.
+ let uniqueCount = 0;
+ newKey = key;
+ while (true) {
+ if (!branch.getCharPref(`${newKey}.filename`, "")) {
+ break;
+ }
+ newKey = `${key}${++uniqueCount}`;
+ }
+ keyMap.set(key, newKey);
+ }
+
+ let newName = `${newKey}${name.slice(key.length)}`;
+ branch[`set${type}Pref`](newName, value);
+ }
+
+ // Transform the value of ldap_2.autoComplete.directoryServer if needed.
+ if (
+ ldapAutoComplete.useDirectory &&
+ ldapAutoComplete.directoryServer &&
+ !Services.prefs.getBoolPref(`${LDAP_AUTO_COMPLETE}useDirectory`, false)
+ ) {
+ let key = ldapAutoComplete.directoryServer.split("/").slice(-1)[0];
+ let newKey = keyMap.get(key);
+ if (newKey) {
+ Services.prefs.setBoolPref(`${LDAP_AUTO_COMPLETE}useDirectory`, true);
+ Services.prefs.setCharPref(
+ `${LDAP_AUTO_COMPLETE}directoryServer`,
+ `ldap_2.servers.${newKey}`
+ );
+ }
+ }
+
+ this._copyAddressBookDatabases(keyMap);
+ }
+
+ /**
+ * Copy sqlite files from this._sourceProfileDir to the current profile dir.
+ *
+ * @param {Map<string, string>} keyMap - A map from the source address
+ * book key to new address book key.
+ */
+ _copyAddressBookDatabases(keyMap) {
+ // Copy user created address books.
+ for (let key of keyMap.values()) {
+ let branch = Services.prefs.getBranch(`${ADDRESS_BOOK}${key}.`);
+ let filename = branch.getCharPref("filename", "");
+ if (!filename) {
+ continue;
+ }
+ let sourceFile = this._sourceProfileDir.clone();
+ sourceFile.append(filename);
+ if (!sourceFile.exists()) {
+ this._logger.debug(
+ `Ignoring non-existing address boook file ${sourceFile.path}`
+ );
+ continue;
+ }
+
+ let targetFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetFile.append(sourceFile.leafName);
+ targetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+ this._logger.debug(`Copying ${sourceFile.path} to ${targetFile.path}`);
+ sourceFile.copyTo(targetFile.parent, targetFile.leafName);
+
+ branch.setCharPref("filename", targetFile.leafName);
+ }
+
+ // Copy or import Personal Address Book.
+ this._importAddressBookDatabase("abook.sqlite");
+ // Copy or import Collected Addresses.
+ this._importAddressBookDatabase("history.sqlite");
+ }
+
+ /**
+ * Copy a sqlite file from this._sourceProfileDir to the current profile dir.
+ *
+ * @param {string} filename - The name of the sqlite file.
+ */
+ _importAddressBookDatabase(filename) {
+ let sourceFile = this._sourceProfileDir.clone();
+ sourceFile.append(filename);
+ let targetFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetFile.append(filename);
+
+ if (!sourceFile.exists()) {
+ return;
+ }
+
+ if (!targetFile.exists()) {
+ sourceFile.copyTo(targetFile.parent, "");
+ return;
+ }
+
+ let dirId = MailServices.ab.newAddressBook(
+ "tmp",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let tmpDirectory = MailServices.ab.getDirectoryFromId(dirId);
+ sourceFile.copyTo(targetFile.parent, tmpDirectory.fileName);
+
+ let targetDirectory = MailServices.ab.getDirectory(
+ `jsaddrbook://${filename}`
+ );
+ for (let card of tmpDirectory.childCards) {
+ targetDirectory.addCard(card);
+ }
+
+ MailServices.ab.deleteAddressBook(tmpDirectory.URI);
+ }
+
+ /**
+ * Import logins.json and key4.db.
+ */
+ _importPasswords() {
+ let sourceLoginsJson = this._sourceProfileDir.clone();
+ sourceLoginsJson.append("logins.json");
+ let sourceKeyDb = this._sourceProfileDir.clone();
+ sourceKeyDb.append("key4.db");
+ let targetLoginsJson = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ targetLoginsJson.append("logins.json");
+
+ if (
+ sourceLoginsJson.exists() &&
+ sourceKeyDb.exists() &&
+ !targetLoginsJson.exists()
+ ) {
+ // Only copy if logins.json doesn't exist in the current profile.
+ sourceLoginsJson.copyTo(targetLoginsJson.parent, "");
+ sourceKeyDb.copyTo(targetLoginsJson.parent, "");
+ }
+ }
+
+ /**
+ * Import a pref from source only when this pref has no user value in the
+ * current profile.
+ *
+ * @param {PrefItem[]} prefs - All source prefs to try to import.
+ */
+ _importOtherPrefs(prefs) {
+ for (let [type, name, value] of prefs) {
+ if (!Services.prefs.prefHasUserValue(name)) {
+ Services.prefs[`set${type}Pref`](name, value);
+ }
+ }
+ }
+
+ /**
+ * Import calendars.
+ *
+ * For storage calendars, we need to import everything from the source
+ * local.sqlite to the target local.sqlite, which is not implemented yet, see
+ * bug 1719582.
+ *
+ * @param {PrefItem[]} prefs - All source prefs in the CALENDAR branch.
+ */
+ _importCalendars(prefs) {
+ let branch = Services.prefs.getBranch(CALENDAR);
+ for (let [type, name, value] of prefs) {
+ branch[`set${type}Pref`](name, value);
+ }
+ }
+}
diff --git a/comm/mail/components/migration/src/components.conf b/comm/mail/components/migration/src/components.conf
new file mode 100644
index 0000000000..85b754c645
--- /dev/null
+++ b/comm/mail/components/migration/src/components.conf
@@ -0,0 +1,38 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ "cid": "{adb2e3a7-8df4-484a-b787-6c2184eb9756}",
+ "contract_ids": ["@mozilla.org/profile/migrator;1?app=mail&type=thunderbird"],
+ "jsm": "resource:///modules/ThunderbirdProfileMigrator.jsm",
+ "constructor": "ThunderbirdProfileMigrator",
+ },
+ {
+ "cid": "{b3c78baf-3a52-41d2-9718-c319bef9affc}",
+ "contract_ids": ["@mozilla.org/toolkit/profile-migrator;1"],
+ "type": "nsProfileMigrator",
+ "headers": ["/comm/mail/components/migration/src/nsProfileMigrator.h"],
+ },
+ {
+ "cid": "{62c6e1f9-3dc3-4b68-9c39-ad2f6d471ac0}",
+ "contract_ids": ["@mozilla.org/profile/migrator;1?app=mail&type=seamonkey"],
+ "type": "nsSeamonkeyProfileMigrator",
+ "headers": ["/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h"],
+ },
+]
+
+if buildconfig.substs["OS_ARCH"] == "WINNT":
+ Classes += [
+ {
+ "cid": "{910b6453-0719-41e8-a4c9-0319bb34c8ff}",
+ "contract_ids": ["@mozilla.org/profile/migrator;1?app=mail&type=outlook"],
+ "type": "nsOutlookProfileMigrator",
+ "headers": [
+ "/comm/mail/components/migration/src/nsOutlookProfileMigrator.h"
+ ],
+ },
+ ]
diff --git a/comm/mail/components/migration/src/moz.build b/comm/mail/components/migration/src/moz.build
new file mode 100644
index 0000000000..cfcc8d8239
--- /dev/null
+++ b/comm/mail/components/migration/src/moz.build
@@ -0,0 +1,32 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+SOURCES += [
+ "nsMailProfileMigratorUtils.cpp",
+ "nsNetscapeProfileMigratorBase.cpp",
+ "nsProfileMigrator.cpp",
+ "nsSeamonkeyProfileMigrator.cpp",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ SOURCES += [
+ "nsOutlookProfileMigrator.cpp",
+ "nsProfileMigratorBase.cpp",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ SOURCES += [
+ "nsProfileMigratorBase.cpp",
+ ]
+
+FINAL_LIBRARY = "mailcomps"
+
+EXTRA_JS_MODULES += [
+ "ThunderbirdProfileMigrator.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp
new file mode 100644
index 0000000000..795cd514f4
--- /dev/null
+++ b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.cpp
@@ -0,0 +1,86 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsIFile.h"
+#include "nsIProperties.h"
+#include "nsIProfileMigrator.h"
+
+#include "nsServiceManagerUtils.h"
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsXPCOMCID.h"
+
+void SetProxyPref(const nsACString& aHostPort, const char* aPref,
+ const char* aPortPref, nsIPrefBranch* aPrefs) {
+ nsAutoCString hostPort(aHostPort);
+ int32_t portDelimOffset = hostPort.RFindChar(':');
+ if (portDelimOffset > 0) {
+ nsAutoCString host(Substring(hostPort, 0, portDelimOffset));
+ nsAutoCString port(Substring(hostPort, portDelimOffset + 1,
+ hostPort.Length() - (portDelimOffset + 1)));
+
+ aPrefs->SetCharPref(aPref, host);
+ nsresult stringErr;
+ int32_t portValue = port.ToInteger(&stringErr);
+ aPrefs->SetIntPref(aPortPref, portValue);
+ } else
+ aPrefs->SetCharPref(aPref, hostPort);
+}
+
+void ParseOverrideServers(const char* aServers, nsIPrefBranch* aBranch) {
+ // Windows (and Opera) formats its proxy override list in the form:
+ // server;server;server where server is a server name or ip address,
+ // or "<local>". Mozilla's format is server,server,server, and <local>
+ // must be translated to "localhost,127.0.0.1"
+ nsAutoCString override(aServers);
+ int32_t left = 0, right = 0;
+ for (;;) {
+ right = override.FindChar(';', right);
+ const nsACString& host = Substring(
+ override, left, (right < 0 ? override.Length() : right) - left);
+ if (host.Equals("<local>"))
+ override.Replace(left, 7, "localhost,127.0.0.1"_ns);
+ if (right < 0) break;
+ left = right + 1;
+ override.Replace(right, 1, ","_ns);
+ }
+ aBranch->SetCharPref("network.proxy.no_proxies_on", override);
+}
+
+void GetMigrateDataFromArray(MigrationData* aDataArray,
+ int32_t aDataArrayLength, bool aReplace,
+ nsIFile* aSourceProfile, uint16_t* aResult) {
+ nsCOMPtr<nsIFile> sourceFile;
+ bool exists;
+ MigrationData* cursor;
+ MigrationData* end = aDataArray + aDataArrayLength;
+ for (cursor = aDataArray; cursor < end && cursor->fileName; ++cursor) {
+ // When in replace mode, all items can be imported.
+ // When in non-replace mode, only items that do not require file replacement
+ // can be imported.
+ if (aReplace || !cursor->replaceOnly) {
+ aSourceProfile->Clone(getter_AddRefs(sourceFile));
+ sourceFile->Append(nsDependentString(cursor->fileName));
+ sourceFile->Exists(&exists);
+ if (exists) *aResult |= cursor->sourceFlag;
+ }
+ free(cursor->fileName);
+ cursor->fileName = nullptr;
+ }
+}
+
+void GetProfilePath(nsIProfileStartup* aStartup,
+ nsCOMPtr<nsIFile>& aProfileDir) {
+ if (aStartup) {
+ aStartup->GetDirectory(getter_AddRefs(aProfileDir));
+ } else {
+ nsCOMPtr<nsIProperties> dirSvc(
+ do_GetService(NS_DIRECTORY_SERVICE_CONTRACTID));
+ if (dirSvc) {
+ dirSvc->Get(NS_APP_USER_PROFILE_50_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(aProfileDir));
+ }
+ }
+}
diff --git a/comm/mail/components/migration/src/nsMailProfileMigratorUtils.h b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.h
new file mode 100644
index 0000000000..01f21d0d72
--- /dev/null
+++ b/comm/mail/components/migration/src/nsMailProfileMigratorUtils.h
@@ -0,0 +1,54 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mailprofilemigratorutils___h___
+#define mailprofilemigratorutils___h___
+
+#define MIGRATION_ITEMBEFOREMIGRATE "Migration:ItemBeforeMigrate"
+#define MIGRATION_ITEMAFTERMIGRATE "Migration:ItemAfterMigrate"
+#define MIGRATION_STARTED "Migration:Started"
+#define MIGRATION_ENDED "Migration:Ended"
+#define MIGRATION_PROGRESS "Migration:Progress"
+
+#define NOTIFY_OBSERVERS(message, item) \
+ mObserverService->NotifyObservers(nullptr, message, item)
+
+#define COPY_DATA(func, replace, itemIndex) \
+ if (NS_SUCCEEDED(rv) && (aItems & itemIndex || !aItems)) { \
+ nsAutoString index; \
+ index.AppendInt(itemIndex); \
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get()); \
+ rv = func(replace); \
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get()); \
+ }
+
+#include "nsIPrefBranch.h"
+#include "nsIFile.h"
+#include "nsString.h"
+#include "nsCOMPtr.h"
+class nsIProfileStartup;
+
+// Proxy utilities shared by the Opera and IE migrators
+void ParseOverrideServers(const char* aServers, nsIPrefBranch* aBranch);
+void SetProxyPref(const nsACString& aHostPort, const char* aPref,
+ const char* aPortPref, nsIPrefBranch* aPrefs);
+
+struct MigrationData {
+ char16_t* fileName;
+ uint32_t sourceFlag;
+ bool replaceOnly;
+};
+
+class nsIFile;
+void GetMigrateDataFromArray(MigrationData* aDataArray,
+ int32_t aDataArrayLength, bool aReplace,
+ nsIFile* aSourceProfile, uint16_t* aResult);
+
+// get the base directory of the *target* profile
+// this is already cloned, modify it to your heart's content
+void GetProfilePath(nsIProfileStartup* aStartup,
+ nsCOMPtr<nsIFile>& aProfileDir);
+
+#endif
diff --git a/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp
new file mode 100644
index 0000000000..b4a7affe03
--- /dev/null
+++ b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.cpp
@@ -0,0 +1,371 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsIFile.h"
+#include "nsIInputStream.h"
+#include "nsILineInputStream.h"
+#include "nsIPrefBranch.h"
+#include "nsIPrefLocalizedString.h"
+#include "nsIPrefService.h"
+#include "nsIServiceManager.h"
+#include "nsIURL.h"
+#include "nsNetscapeProfileMigratorBase.h"
+#include "nsNetUtil.h"
+#include "prtime.h"
+#include "prprf.h"
+#include "nsITimer.h"
+#include "nsINIParser.h"
+#include "nsMailProfileMigratorUtils.h"
+#include "nsIDirectoryEnumerator.h"
+#include "nsServiceManagerUtils.h"
+
+#define MIGRATION_BUNDLE \
+ "chrome://messenger/locale/migration/migration.properties"
+
+#define FILE_NAME_PREFS_5X u"prefs.js"_ns
+
+///////////////////////////////////////////////////////////////////////////////
+// nsNetscapeProfileMigratorBase
+nsNetscapeProfileMigratorBase::nsNetscapeProfileMigratorBase() {
+ mObserverService = do_GetService("@mozilla.org/observer-service;1");
+ mMaxProgress = 0;
+ mCurrentProgress = 0;
+ mFileCopyTransactionIndex = 0;
+}
+
+NS_IMPL_ISUPPORTS(nsNetscapeProfileMigratorBase, nsIMailProfileMigrator,
+ nsITimerCallback)
+
+nsresult nsNetscapeProfileMigratorBase::GetProfileDataFromProfilesIni(
+ nsIFile* aDataDir, nsTArray<nsString>& aProfileNames,
+ nsTArray<RefPtr<nsIFile>>& aProfileLocations) {
+ nsCOMPtr<nsIFile> profileIni;
+ nsresult rv = aDataDir->Clone(getter_AddRefs(profileIni));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ profileIni->Append(u"profiles.ini"_ns);
+
+ // Does it exist?
+ bool profileFileExists = false;
+ rv = profileIni->Exists(&profileFileExists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!profileFileExists) return NS_ERROR_FILE_NOT_FOUND;
+
+ nsINIParser parser;
+ rv = parser.Init(profileIni);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString buffer, filePath;
+ bool isRelative;
+
+ // This is an infinite loop that is broken when we no longer find profiles
+ // for profileID with IsRelative option.
+ for (unsigned int c = 0; true; ++c) {
+ nsAutoCString profileID("Profile");
+ profileID.AppendInt(c);
+
+ if (NS_FAILED(parser.GetString(profileID.get(), "IsRelative", buffer)))
+ break;
+
+ isRelative = buffer.EqualsLiteral("1");
+
+ rv = parser.GetString(profileID.get(), "Path", filePath);
+ if (NS_FAILED(rv)) {
+ NS_ERROR("Malformed profiles.ini: Path= not found");
+ continue;
+ }
+
+ rv = parser.GetString(profileID.get(), "Name", buffer);
+ if (NS_FAILED(rv)) {
+ NS_ERROR("Malformed profiles.ini: Name= not found");
+ continue;
+ }
+
+ nsCOMPtr<nsIFile> rootDir;
+ rv = NS_NewNativeLocalFile(EmptyCString(), true, getter_AddRefs(rootDir));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = isRelative ? rootDir->SetRelativeDescriptor(aDataDir, filePath)
+ : rootDir->SetPersistentDescriptor(filePath);
+ if (NS_FAILED(rv)) continue;
+
+ bool exists = false;
+ rootDir->Exists(&exists);
+
+ if (exists) {
+ aProfileLocations.AppendElement(rootDir);
+ aProfileNames.AppendElement(NS_ConvertUTF8toUTF16(buffer));
+ }
+ }
+ return NS_OK;
+}
+
+#define GETPREF(xform, method, value) \
+ nsresult rv = aBranch->method(xform->sourcePrefName, value); \
+ if (NS_SUCCEEDED(rv)) xform->prefHasValue = true; \
+ return rv;
+
+#define SETPREF(xform, method, value) \
+ if (xform->prefHasValue) { \
+ return aBranch->method( \
+ xform->targetPrefName ? xform->targetPrefName : xform->sourcePrefName, \
+ value); \
+ } \
+ return NS_OK;
+
+nsresult nsNetscapeProfileMigratorBase::GetString(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ nsCString str;
+ nsresult rv = aBranch->GetCharPref(xform->sourcePrefName, str);
+ if (NS_SUCCEEDED(rv)) {
+ xform->prefHasValue = true;
+ xform->stringValue = moz_xstrdup(str.get());
+ }
+ return rv;
+}
+
+nsresult nsNetscapeProfileMigratorBase::SetString(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ SETPREF(xform, SetCharPref, nsDependentCString(xform->stringValue));
+}
+
+nsresult nsNetscapeProfileMigratorBase::GetBool(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ GETPREF(xform, GetBoolPref, &xform->boolValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::SetBool(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ SETPREF(xform, SetBoolPref, xform->boolValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::GetInt(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ GETPREF(xform, GetIntPref, &xform->intValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::SetInt(PrefTransform* aTransform,
+ nsIPrefBranch* aBranch) {
+ PrefTransform* xform = (PrefTransform*)aTransform;
+ SETPREF(xform, SetIntPref, xform->intValue);
+}
+
+nsresult nsNetscapeProfileMigratorBase::CopyFile(
+ const nsAString& aSourceFileName, const nsAString& aTargetFileName) {
+ nsCOMPtr<nsIFile> sourceFile;
+ mSourceProfile->Clone(getter_AddRefs(sourceFile));
+
+ sourceFile->Append(aSourceFileName);
+ bool exists = false;
+ sourceFile->Exists(&exists);
+ if (!exists) return NS_OK;
+
+ nsCOMPtr<nsIFile> targetFile;
+ mTargetProfile->Clone(getter_AddRefs(targetFile));
+
+ targetFile->Append(aTargetFileName);
+ targetFile->Exists(&exists);
+ if (exists) targetFile->Remove(false);
+
+ return sourceFile->CopyTo(mTargetProfile, aTargetFileName);
+}
+
+nsresult nsNetscapeProfileMigratorBase::GetSignonFileName(
+ bool aReplace, nsACString& aFileName) {
+ nsresult rv;
+ if (aReplace) {
+ // Find out what the signons file was called, this is stored in a pref
+ // in Seamonkey.
+ nsCOMPtr<nsIPrefService> psvc(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ psvc->ResetPrefs();
+
+ nsCOMPtr<nsIFile> sourcePrefsName;
+ mSourceProfile->Clone(getter_AddRefs(sourcePrefsName));
+ sourcePrefsName->Append(FILE_NAME_PREFS_5X);
+ psvc->ReadUserPrefsFromFile(sourcePrefsName);
+
+ nsCOMPtr<nsIPrefBranch> branch(do_QueryInterface(psvc));
+ rv = branch->GetCharPref("signon.SignonFileName", aFileName);
+ } else
+ rv = LocateSignonsFile(aFileName);
+ return rv;
+}
+
+nsresult nsNetscapeProfileMigratorBase::LocateSignonsFile(nsACString& aResult) {
+ nsCOMPtr<nsIDirectoryEnumerator> entries;
+ nsresult rv = mSourceProfile->GetDirectoryEntries(getter_AddRefs(entries));
+ if (NS_FAILED(rv)) return rv;
+
+ nsAutoCString fileName;
+ bool hasMore = false;
+ while (NS_SUCCEEDED(entries->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsIFile> currFile;
+ rv = entries->GetNextFile(getter_AddRefs(currFile));
+ if (NS_FAILED(rv)) break;
+
+ nsCOMPtr<nsIURI> uri;
+ rv = NS_NewFileURI(getter_AddRefs(uri), currFile);
+ if (NS_FAILED(rv)) break;
+ nsCOMPtr<nsIURL> url(do_QueryInterface(uri));
+
+ nsAutoCString extn;
+ url->GetFileExtension(extn);
+
+ if (extn.EqualsIgnoreCase("s")) {
+ url->GetFileName(fileName);
+ break;
+ }
+ }
+
+ aResult = fileName;
+
+ return NS_OK;
+}
+
+// helper function, copies the contents of srcDir into destDir.
+// destDir will be created if it doesn't exist.
+
+nsresult nsNetscapeProfileMigratorBase::RecursiveCopy(nsIFile* srcDir,
+ nsIFile* destDir) {
+ nsresult rv;
+ bool isDir;
+
+ rv = srcDir->IsDirectory(&isDir);
+ if (NS_FAILED(rv)) return rv;
+ if (!isDir) return NS_ERROR_INVALID_ARG;
+
+ bool exists;
+ rv = destDir->Exists(&exists);
+ if (NS_SUCCEEDED(rv) && !exists)
+ rv = destDir->Create(nsIFile::DIRECTORY_TYPE, 0775);
+ if (NS_FAILED(rv)) return rv;
+
+ nsCOMPtr<nsIDirectoryEnumerator> dirIterator;
+ rv = srcDir->GetDirectoryEntries(getter_AddRefs(dirIterator));
+ if (NS_FAILED(rv)) return rv;
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(dirIterator->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsIFile> dirEntry;
+ rv = dirIterator->GetNextFile(getter_AddRefs(dirEntry));
+ if (NS_SUCCEEDED(rv) && dirEntry) {
+ rv = dirEntry->IsDirectory(&isDir);
+ if (NS_SUCCEEDED(rv)) {
+ if (isDir) {
+ nsCOMPtr<nsIFile> newChild;
+ rv = destDir->Clone(getter_AddRefs(newChild));
+ if (NS_SUCCEEDED(rv)) {
+ nsAutoString leafName;
+ dirEntry->GetLeafName(leafName);
+ newChild->AppendRelativePath(leafName);
+ rv = newChild->Exists(&exists);
+ if (NS_SUCCEEDED(rv) && !exists) {
+ rv = newChild->Create(nsIFile::DIRECTORY_TYPE, 0775);
+ if (NS_FAILED(rv)) return rv;
+ }
+ rv = RecursiveCopy(dirEntry, newChild);
+ }
+ } else {
+ // we aren't going to do any actual file copying here. Instead, add
+ // this to our file transaction list so we can copy files
+ // asynchronously...
+ fileTransactionEntry fileEntry;
+ fileEntry.srcFile = dirEntry;
+ fileEntry.destFile = destDir;
+
+ mFileCopyTransactions.AppendElement(fileEntry);
+ }
+ }
+ }
+ }
+
+ return rv;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsITimerCallback
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::Notify(nsITimer* timer) {
+ CopyNextFolder();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::GetName(nsACString& aName) {
+ aName.AssignLiteral("nsNetscapeProfileMigratorBase");
+ return NS_OK;
+}
+
+void nsNetscapeProfileMigratorBase::CopyNextFolder() {
+ if (mFileCopyTransactionIndex < mFileCopyTransactions.Length()) {
+ fileTransactionEntry fileTransaction =
+ mFileCopyTransactions.ElementAt(mFileCopyTransactionIndex++);
+
+ // copy the file
+ fileTransaction.srcFile->CopyTo(fileTransaction.destFile,
+ fileTransaction.newName);
+
+ // add to our current progress
+ int64_t fileSize;
+ fileTransaction.srcFile->GetFileSize(&fileSize);
+ mCurrentProgress += fileSize;
+
+ uint32_t percentage = (uint32_t)(mCurrentProgress * 100 / mMaxProgress);
+
+ nsAutoString index;
+ index.AppendInt(percentage);
+
+ NOTIFY_OBSERVERS(MIGRATION_PROGRESS, index.get());
+
+ // fire a timer to handle the next one.
+ nsresult rv = NS_NewTimerWithCallback(
+ getter_AddRefs(mFileIOTimer), static_cast<nsITimerCallback*>(this),
+ percentage == 100 ? 500 : 0, nsITimer::TYPE_ONE_SHOT, nullptr);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Could not start mFileIOTimer timer");
+ }
+ } else
+ EndCopyFolders();
+
+ return;
+}
+
+void nsNetscapeProfileMigratorBase::EndCopyFolders() {
+ mFileCopyTransactions.Clear();
+ mFileCopyTransactionIndex = 0;
+
+ // notify the UI that we are done with the migration process
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ NOTIFY_OBSERVERS(MIGRATION_ENDED, nullptr);
+}
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::GetSourceHasMultipleProfiles(bool* aResult) {
+ nsTArray<nsString> profiles;
+ GetSourceProfiles(profiles);
+
+ *aResult = profiles.Length() > 1;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNetscapeProfileMigratorBase::GetSourceExists(bool* aResult) {
+ nsTArray<nsString> profiles;
+ GetSourceProfiles(profiles);
+
+ *aResult = profiles.Length() > 0;
+ return NS_OK;
+}
diff --git a/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h
new file mode 100644
index 0000000000..5227673532
--- /dev/null
+++ b/comm/mail/components/migration/src/nsNetscapeProfileMigratorBase.h
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef netscapeprofilemigratorbase___h___
+#define netscapeprofilemigratorbase___h___
+
+#include "nsAttrValue.h"
+#include "nsIFile.h"
+#include "nsIStringBundle.h"
+#include "nsString.h"
+#include "nsTArray.h"
+#include "nsIObserverService.h"
+#include "nsITimer.h"
+#include "nsIMailProfileMigrator.h"
+
+class nsIPrefBranch;
+
+struct fileTransactionEntry {
+ nsCOMPtr<nsIFile> srcFile; // the src path including leaf name
+ nsCOMPtr<nsIFile> destFile; // the destination path
+ nsString
+ newName; // only valid if the file should be renamed after getting copied
+};
+
+#define F(a) nsNetscapeProfileMigratorBase::a
+
+#define MAKEPREFTRANSFORM(pref, newpref, getmethod, setmethod) \
+ { \
+ pref, newpref, F(Get##getmethod), F(Set##setmethod), false, { -1 } \
+ }
+
+#define MAKESAMETYPEPREFTRANSFORM(pref, method) \
+ { \
+ pref, 0, F(Get##method), F(Set##method), false, { -1 } \
+ }
+
+class nsNetscapeProfileMigratorBase : public nsIMailProfileMigrator,
+ public nsITimerCallback,
+ public nsINamed
+
+{
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSITIMERCALLBACK
+ NS_DECL_NSINAMED
+
+ nsNetscapeProfileMigratorBase();
+
+ NS_IMETHOD GetSourceHasMultipleProfiles(bool* aResult) override;
+ NS_IMETHOD GetSourceExists(bool* aResult) override;
+
+ struct PrefTransform;
+ typedef nsresult (*prefConverter)(PrefTransform*, nsIPrefBranch*);
+
+ struct PrefTransform {
+ const char* sourcePrefName;
+ const char* targetPrefName;
+ prefConverter prefGetterFunc;
+ prefConverter prefSetterFunc;
+ bool prefHasValue;
+ union {
+ int32_t intValue;
+ bool boolValue;
+ char* stringValue;
+ };
+ };
+
+ struct PrefBranchStruct {
+ char* prefName;
+ int32_t type;
+ union {
+ char* stringValue;
+ int32_t intValue;
+ bool boolValue;
+ };
+ };
+
+ typedef nsTArray<PrefBranchStruct*> PBStructArray;
+
+ static nsresult GetString(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult SetString(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult GetBool(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult SetBool(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult GetInt(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+ static nsresult SetInt(PrefTransform* aTransform, nsIPrefBranch* aBranch);
+
+ nsresult RecursiveCopy(nsIFile* srcDir, nsIFile* destDir); // helper routine
+
+ protected:
+ virtual ~nsNetscapeProfileMigratorBase() {}
+ void CopyNextFolder();
+ void EndCopyFolders();
+
+ nsresult GetProfileDataFromProfilesIni(
+ nsIFile* aDataDir, nsTArray<nsString>& aProfileNames,
+ nsTArray<RefPtr<nsIFile>>& aProfileLocations);
+
+ nsresult CopyFile(const nsAString& aSourceFileName,
+ const nsAString& aTargetFileName);
+
+ nsresult GetSignonFileName(bool aReplace, nsACString& aFileName);
+ nsresult LocateSignonsFile(nsACString& aResult);
+
+ nsCOMPtr<nsIFile> mSourceProfile;
+ nsCOMPtr<nsIFile> mTargetProfile;
+
+ // List of src/destination files we still have to copy into the new profile
+ // directory.
+ nsTArray<fileTransactionEntry> mFileCopyTransactions;
+ uint32_t mFileCopyTransactionIndex;
+
+ int64_t mMaxProgress;
+ int64_t mCurrentProgress;
+
+ nsCOMPtr<nsIObserverService> mObserverService;
+ nsCOMPtr<nsITimer> mFileIOTimer;
+};
+
+#endif
diff --git a/comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp b/comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp
new file mode 100644
index 0000000000..dd7535e257
--- /dev/null
+++ b/comm/mail/components/migration/src/nsOutlookProfileMigrator.cpp
@@ -0,0 +1,135 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsIServiceManager.h"
+#include "nsOutlookProfileMigrator.h"
+#include "nsIProfileMigrator.h"
+#include "nsIImportSettings.h"
+#include "nsIFile.h"
+#include "nsITimer.h"
+#include "nsComponentManagerUtils.h"
+
+NS_IMPL_ISUPPORTS(nsOutlookProfileMigrator, nsIMailProfileMigrator,
+ nsITimerCallback)
+
+nsOutlookProfileMigrator::nsOutlookProfileMigrator() {
+ mProcessingMailFolders = false;
+ // get the import service
+ mImportModule = do_CreateInstance("@mozilla.org/import/import-outlook;1");
+}
+
+nsOutlookProfileMigrator::~nsOutlookProfileMigrator() {}
+
+nsresult nsOutlookProfileMigrator::ContinueImport() { return Notify(nullptr); }
+
+///////////////////////////////////////////////////////////////////////////////
+// nsITimerCallback
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::Notify(nsITimer* timer) {
+ int32_t progress;
+ mGenericImporter->GetProgress(&progress);
+
+ nsAutoString index;
+ index.AppendInt(progress);
+ NOTIFY_OBSERVERS(MIGRATION_PROGRESS, index.get());
+
+ if (progress == 100) // are we done yet?
+ {
+ if (mProcessingMailFolders)
+ return FinishCopyingMailFolders();
+ else
+ return FinishCopyingAddressBookData();
+ } else {
+ // fire a timer to handle the next one.
+ nsresult rv = NS_NewTimerWithCallback(
+ getter_AddRefs(mFileIOTimer), static_cast<nsITimerCallback*>(this), 100,
+ nsITimer::TYPE_ONE_SHOT, nullptr);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Could not start mFileIOTimer timer");
+ }
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetName(nsACString& aName) {
+ aName.AssignLiteral("nsOutlookProfileMigrator");
+ return NS_OK;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsIMailProfileMigrator
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::Migrate(uint16_t aItems, nsIProfileStartup* aStartup,
+ const char16_t* aProfile) {
+ nsresult rv = NS_OK;
+
+ if (aStartup) {
+ rv = aStartup->DoStartup();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ NOTIFY_OBSERVERS(MIGRATION_STARTED, nullptr);
+
+ rv = ImportSettings(mImportModule);
+
+ // now import address books
+ // this routine will asynchronously import address book data and it will then
+ // kick off the final migration step, copying the mail folders over.
+ rv = ImportAddressBook(mImportModule);
+
+ // don't broadcast an on end migration here. We aren't done until our asynch
+ // import process says we are done.
+ return rv;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetMigrateData(const char16_t* aProfile,
+ bool aReplace, uint16_t* aResult) {
+ // There's no harm in assuming everything is available.
+ *aResult = nsIMailProfileMigrator::ACCOUNT_SETTINGS |
+ nsIMailProfileMigrator::ADDRESSBOOK_DATA |
+ nsIMailProfileMigrator::MAILDATA;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceExists(bool* aResult) {
+ *aResult = false;
+
+ nsCOMPtr<nsISupports> supports;
+ mImportModule->GetImportInterface(NS_IMPORT_SETTINGS_STR,
+ getter_AddRefs(supports));
+ nsCOMPtr<nsIImportSettings> importSettings = do_QueryInterface(supports);
+
+ if (importSettings) {
+ nsString description;
+ nsCOMPtr<nsIFile> location;
+ importSettings->AutoLocate(getter_Copies(description),
+ getter_AddRefs(location), aResult);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceHasMultipleProfiles(bool* aResult) {
+ *aResult = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceProfiles(nsTArray<nsString>& aResult) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsOutlookProfileMigrator::GetSourceProfileLocations(
+ nsTArray<RefPtr<nsIFile>>& aResult) {
+ return NS_OK;
+}
diff --git a/comm/mail/components/migration/src/nsOutlookProfileMigrator.h b/comm/mail/components/migration/src/nsOutlookProfileMigrator.h
new file mode 100644
index 0000000000..6de79f98dd
--- /dev/null
+++ b/comm/mail/components/migration/src/nsOutlookProfileMigrator.h
@@ -0,0 +1,30 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef outlookprofilemigrator___h___
+#define outlookprofilemigrator___h___
+
+#include "nsIMailProfileMigrator.h"
+#include "nsITimer.h"
+#include "nsProfileMigratorBase.h"
+
+class nsOutlookProfileMigrator : public nsIMailProfileMigrator,
+ public nsITimerCallback,
+ public nsProfileMigratorBase,
+ public nsINamed {
+ public:
+ NS_DECL_NSIMAILPROFILEMIGRATOR
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSITIMERCALLBACK
+ NS_DECL_NSINAMED
+
+ nsOutlookProfileMigrator();
+ virtual nsresult ContinueImport();
+
+ private:
+ virtual ~nsOutlookProfileMigrator();
+};
+
+#endif
diff --git a/comm/mail/components/migration/src/nsProfileMigrator.cpp b/comm/mail/components/migration/src/nsProfileMigrator.cpp
new file mode 100644
index 0000000000..c6fa2bc867
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigrator.cpp
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsIFile.h"
+#include "mozIDOMWindow.h"
+#include "nsIProfileMigrator.h"
+#include "nsIPrefService.h"
+#include "nsIServiceManager.h"
+#include "nsIToolkitProfile.h"
+#include "nsIToolkitProfileService.h"
+#include "nsIWindowWatcher.h"
+#include "nsISupportsPrimitives.h"
+#include "nsIMutableArray.h"
+#include "nsComponentManagerUtils.h"
+#include "nsServiceManagerUtils.h"
+#include "nsIProperties.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsProfileMigrator.h"
+
+#ifdef XP_WIN
+# include <windows.h>
+#else
+# include <limits.h>
+#endif
+
+NS_IMPL_ISUPPORTS(nsProfileMigrator, nsIProfileMigrator)
+
+#define MIGRATION_WIZARD_FE_URL \
+ "chrome://messenger/content/migration/migration.xhtml"_ns
+#define MIGRATION_WIZARD_FE_FEATURES "chrome,dialog,modal,centerscreen"_ns
+
+NS_IMETHODIMP
+nsProfileMigrator::Migrate(nsIProfileStartup* aStartup, const nsACString& aKey,
+ const nsACString& aProfileName) {
+ nsAutoCString key;
+ nsCOMPtr<nsIMailProfileMigrator> mailMigrator;
+ nsresult rv = GetDefaultMailMigratorKey(key, mailMigrator);
+ NS_ENSURE_SUCCESS(rv, rv); // abort migration if we failed to get a
+ // mailMigrator (if we were supposed to)
+
+ nsCOMPtr<nsISupportsCString> cstr(
+ do_CreateInstance("@mozilla.org/supports-cstring;1"));
+ NS_ENSURE_TRUE(cstr, NS_ERROR_OUT_OF_MEMORY);
+ cstr->SetData(key);
+
+ // By opening the Migration FE with a supplied mailMigrator, it will
+ // automatically migrate from it.
+ nsCOMPtr<nsIWindowWatcher> ww(do_GetService(NS_WINDOWWATCHER_CONTRACTID));
+ nsCOMPtr<nsIMutableArray> params(do_CreateInstance(NS_ARRAY_CONTRACTID));
+ if (!ww || !params) return NS_ERROR_FAILURE;
+
+ params->AppendElement(cstr);
+ params->AppendElement(mailMigrator);
+ params->AppendElement(aStartup);
+
+ nsCOMPtr<mozIDOMWindowProxy> migrateWizard;
+ return ww->OpenWindow(nullptr, MIGRATION_WIZARD_FE_URL, "_blank"_ns,
+ MIGRATION_WIZARD_FE_FEATURES, params,
+ getter_AddRefs(migrateWizard));
+}
+
+#ifdef XP_WIN
+typedef struct {
+ WORD wLanguage;
+ WORD wCodePage;
+} LANGANDCODEPAGE;
+
+# define INTERNAL_NAME_THUNDERBIRD "Thunderbird"
+# define INTERNAL_NAME_SEAMONKEY "Mozilla"
+#endif
+
+nsresult nsProfileMigrator::GetDefaultMailMigratorKey(
+ nsACString& aKey, nsCOMPtr<nsIMailProfileMigrator>& mailMigrator) {
+ // look up the value of profile.force.migration in case we are supposed to
+ // force migration using a particular migrator....
+ nsresult rv = NS_OK;
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCString forceMigrationType;
+ prefs->GetCharPref("profile.force.migration", forceMigrationType);
+
+ // if we are being forced to migrate to a particular migration type, then
+ // create an instance of that migrator and return it.
+ nsAutoCString migratorID;
+ if (!forceMigrationType.IsEmpty()) {
+ bool exists = false;
+ migratorID.AppendLiteral("@mozilla.org/messenger/server;1?type=");
+ migratorID.Append(forceMigrationType);
+ mailMigrator = do_CreateInstance(migratorID.get());
+ if (!mailMigrator) return NS_ERROR_NOT_AVAILABLE;
+
+ mailMigrator->GetSourceExists(&exists);
+ /* trying to force migration on a source which doesn't
+ * have any profiles.
+ */
+ if (!exists) return NS_ERROR_NOT_AVAILABLE;
+ aKey = forceMigrationType;
+ return NS_OK;
+ }
+
+#define MAX_SOURCE_LENGTH 10
+ const char sources[][MAX_SOURCE_LENGTH] = {"seamonkey", "outlook", ""};
+ for (uint32_t i = 0; sources[i][0]; ++i) {
+ migratorID.AssignLiteral("@mozilla.org/messenger/server;1?type=");
+ migratorID.Append(sources[i]);
+ mailMigrator = do_CreateInstance(migratorID.get());
+ if (!mailMigrator) continue;
+
+ bool exists = false;
+ mailMigrator->GetSourceExists(&exists);
+ if (exists) {
+ mailMigrator = nullptr;
+ return NS_OK;
+ }
+ }
+
+ return NS_ERROR_NOT_AVAILABLE;
+}
diff --git a/comm/mail/components/migration/src/nsProfileMigrator.h b/comm/mail/components/migration/src/nsProfileMigrator.h
new file mode 100644
index 0000000000..d25a9989e9
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigrator.h
@@ -0,0 +1,36 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsIFile.h"
+#include "nsIProfileMigrator.h"
+#include "nsIMailProfileMigrator.h"
+#include "nsIServiceManager.h"
+#include "nsIToolkitProfile.h"
+#include "nsIToolkitProfileService.h"
+#include "nsCOMPtr.h"
+#include "nsDirectoryServiceDefs.h"
+
+#include "nsString.h"
+
+#define NS_THUNDERBIRD_PROFILEIMPORT_CID \
+ { \
+ 0xb3c78baf, 0x3a52, 0x41d2, { \
+ 0x97, 0x18, 0xc3, 0x19, 0xbe, 0xf9, 0xaf, 0xfc \
+ } \
+ }
+
+class nsProfileMigrator final : public nsIProfileMigrator {
+ public:
+ NS_DECL_NSIPROFILEMIGRATOR
+ NS_DECL_ISUPPORTS
+
+ nsProfileMigrator(){};
+
+ protected:
+ ~nsProfileMigrator(){};
+
+ nsresult GetDefaultMailMigratorKey(
+ nsACString& key, nsCOMPtr<nsIMailProfileMigrator>& mailMigrator);
+};
diff --git a/comm/mail/components/migration/src/nsProfileMigratorBase.cpp b/comm/mail/components/migration/src/nsProfileMigratorBase.cpp
new file mode 100644
index 0000000000..5ce067308c
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigratorBase.cpp
@@ -0,0 +1,173 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsISupportsPrimitives.h"
+#include "nsProfileMigratorBase.h"
+#include "nsIMailProfileMigrator.h"
+
+#include "nsIImportSettings.h"
+#include "nsIImportFilters.h"
+#include "nsComponentManagerUtils.h"
+#include "nsServiceManagerUtils.h"
+
+#define kPersonalAddressbookUri "jsaddrbook://abook.sqlite"
+
+nsProfileMigratorBase::nsProfileMigratorBase() {
+ mObserverService = do_GetService("@mozilla.org/observer-service;1");
+ mProcessingMailFolders = false;
+}
+
+nsProfileMigratorBase::~nsProfileMigratorBase() {
+ if (mFileIOTimer) mFileIOTimer->Cancel();
+}
+
+nsresult nsProfileMigratorBase::ImportSettings(nsIImportModule* aImportModule) {
+ nsresult rv;
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::ACCOUNT_SETTINGS);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ nsCOMPtr<nsISupports> supports;
+ rv = aImportModule->GetImportInterface(NS_IMPORT_SETTINGS_STR,
+ getter_AddRefs(supports));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIImportSettings> importSettings = do_QueryInterface(supports);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool importedSettings = false;
+
+ rv = importSettings->Import(getter_AddRefs(mLocalFolderAccount),
+ &importedSettings);
+
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ return rv;
+}
+
+nsresult nsProfileMigratorBase::ImportAddressBook(
+ nsIImportModule* aImportModule) {
+ nsresult rv;
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::ADDRESSBOOK_DATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ nsCOMPtr<nsISupports> supports;
+ rv = aImportModule->GetImportInterface(NS_IMPORT_ADDRESS_STR,
+ getter_AddRefs(supports));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mGenericImporter = do_QueryInterface(supports);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsISupportsCString> pabString =
+ do_CreateInstance(NS_SUPPORTS_CSTRING_CONTRACTID, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // We want to migrate the Outlook addressbook into our personal address book.
+ pabString->SetData(nsDependentCString(kPersonalAddressbookUri));
+ mGenericImporter->SetData("addressDestination", pabString);
+
+ bool importResult;
+ bool wantsProgress;
+ mGenericImporter->WantsProgress(&wantsProgress);
+ rv = mGenericImporter->BeginImport(nullptr, nullptr, &importResult);
+
+ if (wantsProgress)
+ ContinueImport();
+ else
+ FinishCopyingAddressBookData();
+
+ return rv;
+}
+
+nsresult nsProfileMigratorBase::FinishCopyingAddressBookData() {
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::ADDRESSBOOK_DATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ // now kick off the mail migration code
+ ImportMailData(mImportModule);
+
+ return NS_OK;
+}
+
+nsresult nsProfileMigratorBase::ImportMailData(nsIImportModule* aImportModule) {
+ nsresult rv;
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ nsCOMPtr<nsISupports> supports;
+ rv = aImportModule->GetImportInterface(NS_IMPORT_MAIL_STR,
+ getter_AddRefs(supports));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mGenericImporter = do_QueryInterface(supports);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsISupportsPRBool> migrating =
+ do_CreateInstance(NS_SUPPORTS_PRBOOL_CONTRACTID, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // by setting the migration flag, we force the import utility to install local
+ // folders from OE directly into Local Folders and not as a subfolder
+ migrating->SetData(true);
+ mGenericImporter->SetData("migration", migrating);
+
+ bool importResult;
+ bool wantsProgress;
+ mGenericImporter->WantsProgress(&wantsProgress);
+ rv = mGenericImporter->BeginImport(nullptr, nullptr, &importResult);
+
+ mProcessingMailFolders = true;
+
+ if (wantsProgress)
+ ContinueImport();
+ else
+ FinishCopyingMailFolders();
+
+ return rv;
+}
+
+nsresult nsProfileMigratorBase::FinishCopyingMailFolders() {
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+
+ // now kick off the filters migration code
+ return ImportFilters(mImportModule);
+}
+
+nsresult nsProfileMigratorBase::ImportFilters(nsIImportModule* aImportModule) {
+ nsresult rv = NS_OK;
+
+ nsCOMPtr<nsISupports> supports;
+ nsresult rv2 = aImportModule->GetImportInterface(NS_IMPORT_FILTERS_STR,
+ getter_AddRefs(supports));
+ nsCOMPtr<nsIImportFilters> importFilters = do_QueryInterface(supports);
+
+ if (NS_SUCCEEDED(rv2) && importFilters) {
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::FILTERS);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ bool importedFilters = false;
+ char16_t* error;
+
+ rv = importFilters->Import(&error, &importedFilters);
+
+ NOTIFY_OBSERVERS(MIGRATION_ITEMAFTERMIGRATE, index.get());
+ }
+
+ // migration is now done...notify the UI.
+ NOTIFY_OBSERVERS(MIGRATION_ENDED, nullptr);
+
+ return rv;
+}
diff --git a/comm/mail/components/migration/src/nsProfileMigratorBase.h b/comm/mail/components/migration/src/nsProfileMigratorBase.h
new file mode 100644
index 0000000000..6ca0d7fcb4
--- /dev/null
+++ b/comm/mail/components/migration/src/nsProfileMigratorBase.h
@@ -0,0 +1,40 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef profilemigratorbase___h___
+#define profilemigratorbase___h___
+
+#include "nsIFile.h"
+#include "nsIObserverService.h"
+#include "nsITimer.h"
+#include "nsIImportGeneric.h"
+#include "nsIImportModule.h"
+#include "nsIMsgAccount.h"
+
+class nsProfileMigratorBase {
+ public:
+ nsProfileMigratorBase();
+ virtual ~nsProfileMigratorBase();
+ virtual nsresult ContinueImport() = 0;
+
+ protected:
+ nsresult ImportSettings(nsIImportModule* aImportModule);
+ nsresult ImportAddressBook(nsIImportModule* aImportModule);
+ nsresult ImportMailData(nsIImportModule* aImportModule);
+ nsresult ImportFilters(nsIImportModule* aImportModule);
+ nsresult FinishCopyingAddressBookData();
+ nsresult FinishCopyingMailFolders();
+
+ nsCOMPtr<nsIObserverService> mObserverService;
+ nsCOMPtr<nsITimer> mFileIOTimer;
+ nsCOMPtr<nsIImportGeneric> mGenericImporter;
+ nsCOMPtr<nsIImportModule> mImportModule;
+ nsCOMPtr<nsIMsgAccount>
+ mLocalFolderAccount; // needed for nsIImportSettings::Import
+ bool mProcessingMailFolders; // we are either asynchronously parsing address
+ // books or mail folders
+};
+
+#endif
diff --git a/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp
new file mode 100644
index 0000000000..27251462c9
--- /dev/null
+++ b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.cpp
@@ -0,0 +1,1175 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsMailProfileMigratorUtils.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsIMsgAccountManager.h"
+#include "nsISmtpServer.h"
+#include "nsISmtpService.h"
+#include "nsIPrefLocalizedString.h"
+#include "nsIPrefService.h"
+#include "nsISupportsPrimitives.h"
+#include "nsNetCID.h"
+#include "nsNetUtil.h"
+#include "nsSeamonkeyProfileMigrator.h"
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsComponentManagerUtils.h" // for do_CreateInstance
+#include "mozilla/ArrayUtils.h"
+#include "nsIFile.h"
+
+#include "nsIAbManager.h"
+#include "nsIAbDirectory.h"
+#include "../../../../mailnews/import/src/MorkImport.h"
+
+// Mail specific folder paths
+#define MAIL_DIR_50_NAME u"Mail"_ns
+#define IMAP_MAIL_DIR_50_NAME u"ImapMail"_ns
+#define NEWS_DIR_50_NAME u"News"_ns
+
+///////////////////////////////////////////////////////////////////////////////
+// nsSeamonkeyProfileMigrator
+#define FILE_NAME_JUNKTRAINING u"training.dat"_ns
+#define FILE_NAME_PERSONALDICTIONARY u"persdict.dat"_ns
+#define FILE_NAME_PERSONAL_ADDRESSBOOK u"abook.mab"_ns
+#define FILE_NAME_MAILVIEWS u"mailviews.dat"_ns
+#define FILE_NAME_CERT9DB u"cert9.db"_ns
+#define FILE_NAME_KEY4DB u"key4.db"_ns
+#define FILE_NAME_SECMODDB u"secmod.db"_ns
+#define FILE_NAME_PREFS u"prefs.js"_ns
+#define FILE_NAME_USER_PREFS u"user.js"_ns
+
+struct PrefBranchStruct {
+ char* prefName;
+ int32_t type;
+ union {
+ char* stringValue;
+ int32_t intValue;
+ bool boolValue;
+ char16_t* wstringValue;
+ };
+};
+
+NS_IMPL_ISUPPORTS(nsSeamonkeyProfileMigrator, nsIMailProfileMigrator,
+ nsITimerCallback)
+
+nsSeamonkeyProfileMigrator::nsSeamonkeyProfileMigrator() {}
+
+nsSeamonkeyProfileMigrator::~nsSeamonkeyProfileMigrator() {}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsIMailProfileMigrator
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::Migrate(uint16_t aItems,
+ nsIProfileStartup* aStartup,
+ const char16_t* aProfile) {
+ nsresult rv = NS_OK;
+ bool aReplace = aStartup ? true : false;
+
+ if (!mTargetProfile) {
+ GetProfilePath(aStartup, mTargetProfile);
+ if (!mTargetProfile) return NS_ERROR_FAILURE;
+ }
+ if (!mSourceProfile) {
+ GetSourceProfile(aProfile);
+ if (!mSourceProfile) return NS_ERROR_FAILURE;
+ }
+
+ NOTIFY_OBSERVERS(MIGRATION_STARTED, nullptr);
+
+ if (aReplace) {
+ CopyPreferences(aReplace);
+ } else {
+ ImportPreferences(aItems);
+ }
+
+ // fake notifications for things we've already imported as part of
+ // CopyPreferences
+ COPY_DATA(DummyCopyRoutine, aReplace,
+ nsIMailProfileMigrator::ACCOUNT_SETTINGS);
+ COPY_DATA(DummyCopyRoutine, aReplace, nsIMailProfileMigrator::NEWSDATA);
+
+ // copy junk mail training file
+ COPY_DATA(CopyJunkTraining, aReplace, nsIMailProfileMigrator::JUNKTRAINING);
+ COPY_DATA(CopyPasswords, aReplace, nsIMailProfileMigrator::PASSWORDS);
+
+ // the last thing to do is to actually copy over any mail folders we have
+ // marked for copying we want to do this last and it will be asynchronous so
+ // the UI doesn't freeze up while we perform this potentially very long
+ // operation.
+
+ nsAutoString index;
+ index.AppendInt(nsIMailProfileMigrator::MAILDATA);
+ NOTIFY_OBSERVERS(MIGRATION_ITEMBEFOREMIGRATE, index.get());
+
+ // Generate the max progress value now that we know all of the files we need
+ // to copy
+ uint32_t count = mFileCopyTransactions.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ fileTransactionEntry fileTransaction = mFileCopyTransactions.ElementAt(i);
+ int64_t fileSize;
+ fileTransaction.srcFile->GetFileSize(&fileSize);
+ mMaxProgress += fileSize;
+ }
+
+ CopyNextFolder();
+
+ return rv;
+}
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::GetMigrateData(const char16_t* aProfile,
+ bool aReplace, uint16_t* aResult) {
+ *aResult = 0;
+
+ if (!mSourceProfile) {
+ GetSourceProfile(aProfile);
+ if (!mSourceProfile) return NS_ERROR_FILE_NOT_FOUND;
+ }
+
+ MigrationData data[] = {
+ {ToNewUnicode(FILE_NAME_PREFS), nsIMailProfileMigrator::SETTINGS, false},
+ {ToNewUnicode(FILE_NAME_JUNKTRAINING),
+ nsIMailProfileMigrator::JUNKTRAINING, true},
+ };
+
+ // Frees file name strings allocated above.
+ GetMigrateDataFromArray(data, sizeof(data) / sizeof(MigrationData), aReplace,
+ mSourceProfile, aResult);
+
+ // Now locate passwords
+ nsCString signonsFileName;
+ GetSignonFileName(aReplace, signonsFileName);
+
+ if (!signonsFileName.IsEmpty()) {
+ nsAutoString fileName;
+ CopyASCIItoUTF16(signonsFileName, fileName);
+ nsCOMPtr<nsIFile> sourcePasswordsFile;
+ mSourceProfile->Clone(getter_AddRefs(sourcePasswordsFile));
+ sourcePasswordsFile->Append(fileName);
+
+ bool exists;
+ sourcePasswordsFile->Exists(&exists);
+ if (exists) *aResult |= nsIMailProfileMigrator::PASSWORDS;
+ }
+
+ // add some extra migration fields for things we also migrate
+ *aResult |= nsIMailProfileMigrator::ACCOUNT_SETTINGS |
+ nsIMailProfileMigrator::MAILDATA |
+ nsIMailProfileMigrator::NEWSDATA |
+ nsIMailProfileMigrator::ADDRESSBOOK_DATA;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::GetSourceProfiles(nsTArray<nsString>& aResult) {
+ if (mProfileNames.IsEmpty() && mProfileLocations.IsEmpty()) {
+ // Fills mProfileNames and mProfileLocations
+ FillProfileDataFromSeamonkeyRegistry();
+ }
+
+ aResult = mProfileNames.Clone();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsSeamonkeyProfileMigrator::GetSourceProfileLocations(
+ nsTArray<RefPtr<nsIFile>>& aResult) {
+ if (mProfileNames.IsEmpty() && mProfileLocations.IsEmpty()) {
+ // Fills mProfileNames and mProfileLocations
+ FillProfileDataFromSeamonkeyRegistry();
+ }
+
+ aResult = mProfileLocations.Clone();
+ return NS_OK;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// nsSeamonkeyProfileMigrator
+
+nsresult nsSeamonkeyProfileMigrator::GetSourceProfile(
+ const char16_t* aProfile) {
+ uint32_t count = mProfileNames.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ nsString profileName = mProfileNames[i];
+ if (profileName.Equals(aProfile)) {
+ mSourceProfile = mProfileLocations[i];
+ break;
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::FillProfileDataFromSeamonkeyRegistry() {
+ // Find the Seamonkey Registry
+ nsCOMPtr<nsIProperties> fileLocator(
+ do_GetService("@mozilla.org/file/directory_service;1"));
+ nsCOMPtr<nsIFile> seamonkeyData;
+#undef EXTRA_PREPEND
+
+#ifdef XP_WIN
+# define NEW_FOLDER "SeaMonkey"
+# define EXTRA_PREPEND "Mozilla"
+
+ fileLocator->Get(NS_WIN_APPDATA_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(seamonkeyData));
+ NS_ENSURE_TRUE(seamonkeyData, NS_ERROR_FAILURE);
+
+#elif defined(XP_MACOSX)
+# define NEW_FOLDER "SeaMonkey"
+# define EXTRA_PREPEND "Application Support"
+ fileLocator->Get(NS_MAC_USER_LIB_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(seamonkeyData));
+ NS_ENSURE_TRUE(seamonkeyData, NS_ERROR_FAILURE);
+
+#elif defined(XP_UNIX)
+# define NEW_FOLDER "seamonkey"
+# define EXTRA_PREPEND ".mozilla"
+ fileLocator->Get(NS_UNIX_HOME_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(seamonkeyData));
+ NS_ENSURE_TRUE(seamonkeyData, NS_ERROR_FAILURE);
+
+#else
+ // On other OS just abort.
+ return NS_ERROR_FAILURE;
+#endif
+
+ nsCOMPtr<nsIFile> newSeamonkeyData;
+ seamonkeyData->Clone(getter_AddRefs(newSeamonkeyData));
+ NS_ENSURE_TRUE(newSeamonkeyData, NS_ERROR_FAILURE);
+
+#ifdef EXTRA_PREPEND
+ newSeamonkeyData->Append(NS_LITERAL_STRING_FROM_CSTRING(EXTRA_PREPEND));
+#endif
+ newSeamonkeyData->Append(NS_LITERAL_STRING_FROM_CSTRING(NEW_FOLDER));
+
+ nsresult rv = GetProfileDataFromProfilesIni(newSeamonkeyData, mProfileNames,
+ mProfileLocations);
+
+ return rv;
+}
+
+static nsSeamonkeyProfileMigrator::PrefTransform gTransforms[] = {
+
+ MAKESAMETYPEPREFTRANSFORM("signon.SignonFileName", String),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showUserAgent", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showOrganization", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.collect_addressbook", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.collect_email_address_outgoing", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.wrap_long_lines", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.customHeaders", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.default_html_action", Int),
+ MAKESAMETYPEPREFTRANSFORM("mail.forward_message_mode", Int),
+ MAKESAMETYPEPREFTRANSFORM("mail.SpellCheckBeforeSend", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.warn_on_send_accel_key", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showUserAgent", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mailnews.headers.showOrganization", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.play_sound", Bool),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.play_sound.type", Int),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.play_sound.url", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.biff.show_alert", Bool),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.type", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.http", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.http_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ftp", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ftp_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ssl", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.ssl_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.socks", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.socks_port", Int),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.no_proxies_on", String),
+ MAKESAMETYPEPREFTRANSFORM("network.proxy.autoconfig_url", String),
+
+ MAKESAMETYPEPREFTRANSFORM("mail.accountmanager.accounts", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.accountmanager.defaultaccount", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.accountmanager.localfoldersserver", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.smtp.defaultserver", String),
+ MAKESAMETYPEPREFTRANSFORM("mail.smtpservers", String),
+
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.font_face", String),
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.font_size", String),
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.text_color", String),
+ MAKESAMETYPEPREFTRANSFORM("msgcompose.background_color", String),
+
+ MAKEPREFTRANSFORM("mail.pane_config", "mail.pane_config.dynamic", Int,
+ Int)};
+
+/**
+ * Use the current Seamonkey's prefs.js as base, and transform some branches.
+ * Thunderbird's prefs.js is thrown away.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformPreferences(
+ const nsAString& aSourcePrefFileName,
+ const nsAString& aTargetPrefFileName) {
+ PrefTransform* transform;
+ PrefTransform* end =
+ gTransforms + sizeof(gTransforms) / sizeof(PrefTransform);
+
+ // Load the source pref file
+ nsCOMPtr<nsIPrefService> psvc(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ psvc->ResetPrefs();
+
+ nsCOMPtr<nsIFile> sourcePrefsFile;
+ mSourceProfile->Clone(getter_AddRefs(sourcePrefsFile));
+ sourcePrefsFile->Append(aSourcePrefFileName);
+ psvc->ReadUserPrefsFromFile(sourcePrefsFile);
+
+ nsCOMPtr<nsIPrefBranch> branch(do_QueryInterface(psvc));
+ for (transform = gTransforms; transform < end; ++transform)
+ transform->prefGetterFunc(transform, branch);
+
+ static const char* branchNames[] = {
+ // Keep the three below first, or change the indexes below
+ "mail.identity.", "mail.server.", "ldap_2.servers.",
+ "mail.account.", "mail.smtpserver.", "mailnews.labels.",
+ "mailnews.tags."};
+
+ // read in the various pref branch trees for accounts, identities, servers,
+ // etc.
+ PBStructArray branches[MOZ_ARRAY_LENGTH(branchNames)];
+ uint32_t i;
+ for (i = 0; i < MOZ_ARRAY_LENGTH(branchNames); ++i)
+ ReadBranch(branchNames[i], psvc, branches[i]);
+
+ // The signature file prefs may be paths to files in the seamonkey profile
+ // path so we need to copy them over and fix these paths up before we write
+ // them out to the new prefs.js.
+ CopySignatureFiles(branches[0], psvc);
+
+ // Certain mail prefs may actually be absolute paths instead of profile
+ // relative paths we need to fix these paths up before we write them out to
+ // the new prefs.js
+ CopyMailFolders(branches[1], psvc);
+
+ TransformAddressbooksForImport(psvc, branches[2], true);
+
+ // Now that we have all the pref data in memory, load the target pref file,
+ // and write it back out.
+ psvc->ResetPrefs();
+
+ // XXX Re-order this?
+
+ for (transform = gTransforms; transform < end; ++transform)
+ transform->prefSetterFunc(transform, branch);
+
+ for (i = 0; i < MOZ_ARRAY_LENGTH(branchNames); i++)
+ WriteBranch(branchNames[i], psvc, branches[i]);
+
+ nsCOMPtr<nsIFile> targetPrefsFile;
+ mTargetProfile->Clone(getter_AddRefs(targetPrefsFile));
+ targetPrefsFile->Append(aTargetPrefFileName);
+ psvc->SavePrefFile(targetPrefsFile);
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopySignatureFiles(
+ PBStructArray& aIdentities, nsIPrefService* aPrefService) {
+ nsresult rv = NS_OK;
+
+ uint32_t count = aIdentities.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ PrefBranchStruct* pref = aIdentities.ElementAt(i);
+ nsDependentCString prefName(pref->prefName);
+
+ // a partial fix for bug #255043
+ // if the user's signature file from seamonkey lives in the
+ // seamonkey profile root, we'll copy it over to the new
+ // thunderbird profile root and then set the pref to the new value
+ // note, this doesn't work for multiple signatures that live
+ // below the seamonkey profile root
+ if (StringEndsWith(prefName, ".sig_file"_ns)) {
+ // turn the pref into a nsIFile
+ nsCOMPtr<nsIFile> srcSigFile =
+ do_CreateInstance(NS_LOCAL_FILE_CONTRACTID);
+ rv = srcSigFile->SetPersistentDescriptor(
+ nsDependentCString(pref->stringValue));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIFile> targetSigFile;
+ rv = mTargetProfile->Clone(getter_AddRefs(targetSigFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // now make the copy
+ bool exists;
+ srcSigFile->Exists(&exists);
+ if (exists) {
+ nsAutoString leafName;
+ srcSigFile->GetLeafName(leafName);
+ srcSigFile->CopyTo(
+ targetSigFile,
+ leafName); // will fail if we've already copied a sig file here
+ targetSigFile->Append(leafName);
+
+ // now write out the new descriptor
+ nsAutoCString descriptorString;
+ rv = targetSigFile->GetPersistentDescriptor(descriptorString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ free(pref->stringValue);
+ pref->stringValue = ToNewCString(descriptorString);
+ }
+ }
+ }
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyMailFolders(
+ PBStructArray& aMailServers, nsIPrefService* aPrefService) {
+ // Each server has a .directory pref which points to the location of the mail
+ // data for that server. We need to do two things for that case...
+ // (1) Fix up the directory path for the new profile
+ // (2) copy the mail folder data from the source directory pref to the
+ // destination directory pref
+
+ nsresult rv;
+ uint32_t count = aMailServers.Length();
+ for (uint32_t i = 0; i < count; i++) {
+ PrefBranchStruct* pref = aMailServers.ElementAt(i);
+ nsDependentCString prefName(pref->prefName);
+
+ if (StringEndsWith(prefName, ".directory-rel"_ns)) {
+ // When the directories are modified below, we may change the .directory
+ // pref. As we don't have a pref branch to modify at this stage and set
+ // up the relative folders properly, we'll just remove all the
+ // *.directory-rel prefs. Mailnews will cope with this, creating them
+ // when it first needs them.
+ if (pref->type == nsIPrefBranch::PREF_STRING) free(pref->stringValue);
+
+ aMailServers.RemoveElementAt(i);
+ // Now decrease i and count to match the removed element
+ --i;
+ --count;
+ } else if (StringEndsWith(prefName, ".directory"_ns)) {
+ // let's try to get a branch for this particular server to simplify things
+ prefName.Cut(prefName.Length() - strlen("directory"),
+ strlen("directory"));
+ prefName.Insert("mail.server.", 0);
+
+ nsCOMPtr<nsIPrefBranch> serverBranch;
+ aPrefService->GetBranch(prefName.get(), getter_AddRefs(serverBranch));
+
+ if (!serverBranch)
+ break; // should we clear out this server pref from aMailServers?
+
+ nsCString serverType;
+ serverBranch->GetCharPref("type", serverType);
+
+ nsCOMPtr<nsIFile> sourceMailFolder;
+ serverBranch->GetComplexValue("directory", NS_GET_IID(nsIFile),
+ getter_AddRefs(sourceMailFolder));
+
+ // now based on type, we need to build a new destination path for the mail
+ // folders for this server
+ nsCOMPtr<nsIFile> targetMailFolder;
+ if (serverType.Equals("imap")) {
+ mTargetProfile->Clone(getter_AddRefs(targetMailFolder));
+ targetMailFolder->Append(IMAP_MAIL_DIR_50_NAME);
+ } else if (serverType.Equals("none") || serverType.Equals("pop3") ||
+ serverType.Equals("rss")) {
+ // local folders and POP3 servers go under <profile>\Mail
+ mTargetProfile->Clone(getter_AddRefs(targetMailFolder));
+ targetMailFolder->Append(MAIL_DIR_50_NAME);
+ } else if (serverType.Equals("nntp")) {
+ mTargetProfile->Clone(getter_AddRefs(targetMailFolder));
+ targetMailFolder->Append(NEWS_DIR_50_NAME);
+ }
+
+ if (targetMailFolder) {
+ // for all of our server types, append the host name to the directory as
+ // part of the new location
+ nsCString hostName;
+ serverBranch->GetCharPref("hostname", hostName);
+ targetMailFolder->Append(NS_ConvertASCIItoUTF16(hostName));
+
+ // we should make sure the host name based directory we are going to
+ // migrate the accounts into is unique. This protects against the case
+ // where the user has multiple servers with the same host name.
+ rv = targetMailFolder->CreateUnique(nsIFile::DIRECTORY_TYPE, 0777);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ (void)RecursiveCopy(sourceMailFolder, targetMailFolder);
+ // now we want to make sure the actual directory pref that gets
+ // transformed into the new profile's pref.js has the right file
+ // location.
+ nsAutoCString descriptorString;
+ rv = targetMailFolder->GetPersistentDescriptor(descriptorString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ free(pref->stringValue);
+ pref->stringValue = ToNewCString(descriptorString);
+ }
+ } else if (StringEndsWith(prefName, ".newsrc.file"_ns)) {
+ // copy the news RC file into \News. this won't work if the user has
+ // different newsrc files for each account I don't know what to do in that
+ // situation.
+
+ nsCOMPtr<nsIFile> targetNewsRCFile;
+ mTargetProfile->Clone(getter_AddRefs(targetNewsRCFile));
+ targetNewsRCFile->Append(NEWS_DIR_50_NAME);
+
+ // turn the pref into a nsIFile
+ nsCOMPtr<nsIFile> srcNewsRCFile =
+ do_CreateInstance(NS_LOCAL_FILE_CONTRACTID);
+ rv = srcNewsRCFile->SetPersistentDescriptor(
+ nsDependentCString(pref->stringValue));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // now make the copy
+ bool exists;
+ srcNewsRCFile->Exists(&exists);
+ if (exists) {
+ nsAutoString leafName;
+ srcNewsRCFile->GetLeafName(leafName);
+ srcNewsRCFile->CopyTo(
+ targetNewsRCFile,
+ leafName); // will fail if we've already copied a newsrc file here
+ targetNewsRCFile->Append(leafName);
+
+ // now write out the new descriptor
+ nsAutoCString descriptorString;
+ rv = targetNewsRCFile->GetPersistentDescriptor(descriptorString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ free(pref->stringValue);
+ pref->stringValue = ToNewCString(descriptorString);
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyPreferences(bool aReplace) {
+ nsresult rv = NS_OK;
+ nsresult tmp;
+
+ tmp = TransformPreferences(FILE_NAME_PREFS, FILE_NAME_PREFS);
+
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_USER_PREFS, FILE_NAME_USER_PREFS);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+
+ // Security Stuff
+ tmp = CopyFile(FILE_NAME_CERT9DB, FILE_NAME_CERT9DB);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_KEY4DB, FILE_NAME_KEY4DB);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_SECMODDB, FILE_NAME_SECMODDB);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+
+ tmp = CopyFile(FILE_NAME_PERSONALDICTIONARY, FILE_NAME_PERSONALDICTIONARY);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ tmp = CopyFile(FILE_NAME_MAILVIEWS, FILE_NAME_MAILVIEWS);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ return rv;
+}
+
+/**
+ * Use the current Thunderbird's prefs.js as base, transform branches of
+ * Seamonkey's prefs.js so that those branches can be imported without conflicts
+ * or overwriting.
+ */
+nsresult nsSeamonkeyProfileMigrator::ImportPreferences(uint16_t aItems) {
+ nsresult rv;
+ nsCOMPtr<nsIPrefService> psvc(do_GetService(NS_PREFSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Because all operations on nsIPrefService or nsIPrefBranch will update
+ // prefs.js directly, we need to backup the current pref file to be used as a
+ // base later.
+ nsCOMPtr<nsIFile> targetPrefsFile;
+ mTargetProfile->Clone(getter_AddRefs(targetPrefsFile));
+ targetPrefsFile->Append(FILE_NAME_PREFS + u".orig"_ns);
+ rv = psvc->SavePrefFile(targetPrefsFile);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Load the source pref file.
+ rv = psvc->ResetPrefs();
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIFile> sourcePrefsFile;
+ mSourceProfile->Clone(getter_AddRefs(sourcePrefsFile));
+ sourcePrefsFile->Append(FILE_NAME_PREFS);
+ rv = psvc->ReadUserPrefsFromFile(sourcePrefsFile);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Read in the various pref branch trees for accounts, identities, servers,
+ // etc.
+ static const char* branchNames[] = {"mail.identity.", "mail.server.",
+ "mail.account.", "mail.smtpserver.",
+ "mailnews.labels.", "mailnews.tags.",
+ "ldap_2.servers."};
+ PBStructArray sourceBranches[MOZ_ARRAY_LENGTH(branchNames)];
+ for (uint32_t i = 0; i < MOZ_ARRAY_LENGTH(branchNames); i++) {
+ if ((!(aItems & nsIMailProfileMigrator::SETTINGS) && i <= 5) ||
+ (!(aItems & nsIMailProfileMigrator::ADDRESSBOOK_DATA) && i == 6)) {
+ continue;
+ }
+ ReadBranch(branchNames[i], psvc, sourceBranches[i]);
+ }
+
+ // Read back the original prefs.
+ rv = psvc->ResetPrefs();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = psvc->ReadUserPrefsFromFile(targetPrefsFile);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIMsgAccountManager> accountManager(
+ do_GetService("@mozilla.org/messenger/account-manager;1", &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+ PrefKeyHashTable smtpServerKeyHashTable;
+ PrefKeyHashTable identityKeyHashTable;
+ PrefKeyHashTable serverKeyHashTable;
+
+ // Transforming order is important here.
+ TransformSmtpServersForImport(sourceBranches[3], smtpServerKeyHashTable);
+
+ // mail.identity.idN.smtpServer depends on previous step.
+ TransformIdentitiesForImport(sourceBranches[0], accountManager,
+ smtpServerKeyHashTable, identityKeyHashTable);
+
+ TransformMailServersForImport(branchNames[1], psvc, sourceBranches[1],
+ accountManager, serverKeyHashTable);
+
+ // mail.accountN.{identities,server} depends on previous steps.
+ TransformMailAccountsForImport(psvc, sourceBranches[2], accountManager,
+ identityKeyHashTable, serverKeyHashTable);
+
+ // CopyMailFolders requires mail.server.serverN branch exists.
+ WriteBranch(branchNames[1], psvc, sourceBranches[1], false);
+ CopyMailFolders(sourceBranches[1], psvc);
+
+ // TransformAddressbooksForImport writes the branch and migrates the files.
+ TransformAddressbooksForImport(psvc, sourceBranches[6], false);
+
+ for (uint32_t i = 0; i < MOZ_ARRAY_LENGTH(branchNames); i++)
+ WriteBranch(branchNames[i], psvc, sourceBranches[i]);
+
+ targetPrefsFile->Remove(false);
+ return rv;
+}
+
+/**
+ * Transform mail.identity branch.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformIdentitiesForImport(
+ PBStructArray& aIdentities, nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& smtpServerKeyHashTable, PrefKeyHashTable& keyHashTable) {
+ nsresult rv;
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aIdentities) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ } else if (StringEndsWith(prefName, ".smtpServer"_ns)) {
+ nsDependentCString serverKey(pref->stringValue);
+ nsCString newServerKey;
+ if (smtpServerKeyHashTable.Get(serverKey, &newServerKey)) {
+ pref->stringValue = moz_xstrdup(newServerKey.get());
+ }
+ }
+
+ // For every seamonkey identity, create a new one to avoid conflicts.
+ nsCString newKey;
+ if (!keyHashTable.Get(key, &newKey)) {
+ nsCOMPtr<nsIMsgIdentity> identity;
+ rv = accountManager->CreateIdentity(getter_AddRefs(identity));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ identity->GetKey(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+ return NS_OK;
+}
+
+/**
+ * Transform mail.account branch. Also update mail.accountmanager.accounts at
+ * the end.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformMailAccountsForImport(
+ nsIPrefService* aPrefService, PBStructArray& aAccounts,
+ nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& identityKeyHashTable,
+ PrefKeyHashTable& serverKeyHashTable) {
+ nsTHashMap<nsCStringHashKey, nsCString> keyHashTable;
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aAccounts) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ } else if (StringEndsWith(prefName, ".identities"_ns)) {
+ nsDependentCString identityKey(pref->stringValue);
+ nsCString newIdentityKey;
+ if (identityKeyHashTable.Get(identityKey, &newIdentityKey)) {
+ pref->stringValue = moz_xstrdup(newIdentityKey.get());
+ }
+ } else if (StringEndsWith(prefName, ".server"_ns)) {
+ nsDependentCString serverKey(pref->stringValue);
+ nsCString newServerKey;
+ if (serverKeyHashTable.Get(serverKey, &newServerKey)) {
+ pref->stringValue = moz_xstrdup(newServerKey.get());
+ }
+ }
+
+ // For every seamonkey account, create a new one to avoid conflicts.
+ nsCString newKey;
+ if (!keyHashTable.Get(key, &newKey)) {
+ accountManager->GetUniqueAccountKey(newKey);
+ newKeys.AppendElement(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+
+ // Append newly create accounts to mail.accountmanager.accounts.
+ nsCOMPtr<nsIPrefBranch> branch;
+ nsCString newAccounts;
+ uint32_t count = newKeys.Length();
+ if (count) {
+ nsresult rv =
+ aPrefService->GetBranch("mail.accountmanager.", getter_AddRefs(branch));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = branch->GetCharPref("accounts", newAccounts);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ for (uint32_t i = 0; i < count; i++) {
+ newAccounts.Append(',');
+ newAccounts.Append(newKeys[i]);
+ }
+ if (count) {
+ (void)branch->SetCharPref("accounts", newAccounts);
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Transform mail.server branch.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformMailServersForImport(
+ const char* branchName, nsIPrefService* aPrefService,
+ PBStructArray& aMailServers, nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& keyHashTable) {
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aMailServers) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ }
+ nsCString newKey;
+ bool exists = keyHashTable.Get(key, &newKey);
+ if (!exists) {
+ do {
+ // Since updating prefs.js is batched, GetUniqueServerKey may return the
+ // previous key. Sleep 500ms and check if the returned key already
+ // exists to workaround it.
+ PR_Sleep(PR_MillisecondsToInterval(500));
+ accountManager->GetUniqueServerKey(newKey);
+ } while (newKeys.Contains(newKey));
+ newKeys.AppendElement(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+
+ pref->prefName = moz_xstrdup(prefName.get());
+
+ // Set `mail.server.serverN.type` so that GetUniqueServerKey next time will
+ // get a new key.
+ if (!exists) {
+ nsCOMPtr<nsIPrefBranch> branch;
+ nsAutoCString serverTypeKey;
+ serverTypeKey.Assign(newKey.get());
+ serverTypeKey.AppendLiteral(".type");
+ nsresult rv = aPrefService->GetBranch(branchName, getter_AddRefs(branch));
+ NS_ENSURE_SUCCESS(rv, rv);
+ (void)branch->SetCharPref(serverTypeKey.get(), "placeholder"_ns);
+ }
+ }
+ return NS_OK;
+}
+
+/**
+ * Transform mail.smtpserver branch.
+ * CreateServer will update mail.smtpservers for us.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformSmtpServersForImport(
+ PBStructArray& aServers, PrefKeyHashTable& keyHashTable) {
+ nsresult rv;
+ nsCOMPtr<nsISmtpService> smtpService(
+ do_GetService("@mozilla.org/messengercompose/smtp;1", &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsTArray<nsCString> newKeys;
+
+ for (auto pref : aServers) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ }
+
+ // For every seamonkey smtp server, create a new one to avoid conflicts.
+ nsCString newKey;
+ if (!keyHashTable.Get(key, &newKey)) {
+ nsCOMPtr<nsISmtpServer> server;
+ rv = smtpService->CreateServer(getter_AddRefs(server));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ char* str;
+ server->GetKey(&str);
+ newKey.Assign(str);
+ newKeys.AppendElement(newKey);
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Transform ldap_2.servers branch.
+ */
+nsresult nsSeamonkeyProfileMigrator::TransformAddressbooksForImport(
+ nsIPrefService* aPrefService, PBStructArray& aAddressbooks, bool aReplace) {
+ nsTHashMap<nsCStringHashKey, nsCString> keyHashTable;
+ nsTHashMap<nsCStringHashKey, nsCString> pendingMigrations;
+ nsTArray<nsCString> newKeys;
+ nsresult rv;
+
+ nsCOMPtr<nsIPrefBranch> branch;
+ rv = aPrefService->GetBranch("ldap_2.servers.", getter_AddRefs(branch));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (auto pref : aAddressbooks) {
+ nsDependentCString prefName(pref->prefName);
+ nsTArray<nsCString> keys;
+ ParseString(prefName, '.', keys);
+ auto key = keys[0];
+ if (key == "default") {
+ continue;
+ }
+
+ nsCString newKey;
+ if (aReplace) {
+ newKey.Assign(key);
+ } else {
+ // For every addressbook, create a new one to avoid conflicts.
+ if (!keyHashTable.Get(key, &newKey)) {
+ uint32_t uniqueCount = 0;
+
+ while (true) {
+ nsAutoCString filenameKey;
+ nsAutoCString filename;
+ filenameKey.Assign(key);
+ filenameKey.AppendInt(++uniqueCount);
+ filenameKey.AppendLiteral(".filename");
+ nsresult rv = branch->GetCharPref(filenameKey.get(), filename);
+ if (NS_FAILED(rv)) {
+ newKey.Assign(key);
+ newKey.AppendInt(uniqueCount);
+ (void)branch->SetCharPref(filenameKey.get(), "placeholder"_ns);
+ break;
+ }
+ }
+ keyHashTable.InsertOrUpdate(key, newKey);
+ }
+ }
+
+ // Replace the prefName with the new key.
+ prefName.Assign(moz_xstrdup(newKey.get()));
+ for (uint32_t j = 1; j < keys.Length(); j++) {
+ prefName.Append('.');
+ prefName.Append(keys[j]);
+
+ if (j == 1) {
+ if (keys[j].Equals("dirType")) {
+ // Make sure we have the right type of directory.
+ pref->intValue = 101;
+ } else if (!aReplace && keys[j].Equals("description") &&
+ !strcmp(pref->stringValue,
+ "chrome://messenger/locale/addressbook/"
+ "addressBook.properties")) {
+ // We're importing the default directories, which have localized
+ // names. The names are tied to the pref's name, which we are
+ // changing, so the localization will fail. Instead, do the
+ // localization here and assign it to the directory being copied.
+ nsCOMPtr<nsIPrefLocalizedString> localizedString;
+ rv = branch->GetComplexValue(pref->prefName,
+ NS_GET_IID(nsIPrefLocalizedString),
+ getter_AddRefs(localizedString));
+ if (NS_SUCCEEDED(rv)) {
+ nsString localizedValue;
+ localizedString->GetData(localizedValue);
+ pref->stringValue =
+ moz_xstrdup(NS_ConvertUTF16toUTF8(localizedValue).get());
+ }
+ } else if (keys[j].Equals("filename")) {
+ // Update the prefs for the new filename of the directory.
+ nsCString oldFileName(pref->stringValue);
+ nsCString newFileName(pref->stringValue);
+
+ if (StringEndsWith(newFileName, nsCString("mab"))) {
+ newFileName.Cut(newFileName.Length() - strlen("mab"),
+ strlen("mab"));
+ newFileName.Append("sqlite");
+ pref->stringValue = moz_xstrdup(newFileName.get());
+ }
+
+ if (!aReplace) {
+ // Find an unused filename in the destination directory.
+ nsCOMPtr<nsIFile> targetAddrbook;
+ mTargetProfile->Clone(getter_AddRefs(targetAddrbook));
+ targetAddrbook->Append(NS_ConvertUTF8toUTF16(newFileName));
+ nsresult rv =
+ targetAddrbook->CreateUnique(nsIFile::NORMAL_FILE_TYPE, 0600);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsString leafName;
+ targetAddrbook->GetLeafName(leafName);
+
+ pref->stringValue =
+ moz_xstrdup(NS_ConvertUTF16toUTF8(leafName).get());
+ }
+
+ if (StringEndsWith(oldFileName, nsCString("sqlite"))) {
+ nsCOMPtr<nsIFile> oldFile;
+ mSourceProfile->Clone(getter_AddRefs(oldFile));
+ oldFile->Append(NS_ConvertUTF8toUTF16(oldFileName));
+ bool exists = false;
+ oldFile->Exists(&exists);
+ if (exists) {
+ // The source directory already has SQLite directories.
+ // Just copy them.
+ CopyFile(NS_ConvertUTF8toUTF16(oldFileName),
+ NS_ConvertUTF8toUTF16(newFileName));
+ continue;
+ }
+
+ oldFileName.Cut(oldFileName.Length() - strlen("sqlite"),
+ strlen("sqlite"));
+ oldFileName.Append("mab");
+ }
+
+ // Store the directories to be migrated for later.
+ pendingMigrations.InsertOrUpdate(newKey, oldFileName);
+ }
+ }
+ }
+ pref->prefName = moz_xstrdup(prefName.get());
+ }
+
+ // Write out the preferences and ask the address book manager to reload.
+ // This initializes the directories using the new prefs we've just set up.
+ WriteBranch("ldap_2.servers.", aPrefService, aAddressbooks, false);
+ NOTIFY_OBSERVERS("addrbook-reload", nullptr);
+
+ // Do the migration.
+ for (auto iter = pendingMigrations.Iter(); !iter.Done(); iter.Next()) {
+ nsCString dirPrefId = "ldap_2.servers."_ns;
+ dirPrefId.Append(iter.Key());
+ MigrateMABFile(dirPrefId, iter.UserData());
+ }
+
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::MigrateMABFile(
+ const nsCString& aDirPrefId, const nsCString& aSourceFileName) {
+ nsCOMPtr<nsIFile> sourceFile;
+ mSourceProfile->Clone(getter_AddRefs(sourceFile));
+
+ sourceFile->Append(NS_ConvertUTF8toUTF16(aSourceFileName));
+ bool exists = false;
+ sourceFile->Exists(&exists);
+ if (!exists) return NS_OK;
+
+ nsresult rv;
+
+ nsCOMPtr<nsIAbManager> abManager(
+ do_GetService("@mozilla.org/abmanager;1", &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIAbDirectory> directory;
+ rv = abManager->GetDirectoryFromId(aDirPrefId, getter_AddRefs(directory));
+ NS_ENSURE_SUCCESS(rv, NS_OK);
+
+ rv = ReadMABToDirectory(sourceFile, directory);
+
+ return NS_OK;
+}
+
+void nsSeamonkeyProfileMigrator::ReadBranch(const char* branchName,
+ nsIPrefService* aPrefService,
+ PBStructArray& aPrefs) {
+ // Enumerate the branch
+ nsCOMPtr<nsIPrefBranch> branch;
+ aPrefService->GetBranch(branchName, getter_AddRefs(branch));
+
+ nsTArray<nsCString> prefs;
+ nsresult rv = branch->GetChildList("", prefs);
+ if (NS_FAILED(rv)) return;
+
+ for (auto& pref : prefs) {
+ // Save each pref's value into an array
+ char* currPref = moz_xstrdup(pref.get());
+ int32_t type;
+ branch->GetPrefType(currPref, &type);
+ PrefBranchStruct* prefBranch = new PrefBranchStruct;
+ prefBranch->prefName = currPref;
+ prefBranch->type = type;
+ switch (type) {
+ case nsIPrefBranch::PREF_STRING: {
+ nsCString str;
+ rv = branch->GetCharPref(currPref, str);
+ prefBranch->stringValue = moz_xstrdup(str.get());
+ break;
+ }
+ case nsIPrefBranch::PREF_BOOL:
+ rv = branch->GetBoolPref(currPref, &prefBranch->boolValue);
+ break;
+ case nsIPrefBranch::PREF_INT:
+ rv = branch->GetIntPref(currPref, &prefBranch->intValue);
+ break;
+ default:
+ NS_WARNING(
+ "Invalid Pref Type in "
+ "nsNetscapeProfileMigratorBase::ReadBranch");
+ break;
+ }
+ if (NS_SUCCEEDED(rv))
+ aPrefs.AppendElement(prefBranch);
+ else
+ delete prefBranch;
+ }
+}
+
+void nsSeamonkeyProfileMigrator::WriteBranch(const char* branchName,
+ nsIPrefService* aPrefService,
+ PBStructArray& aPrefs,
+ bool deallocate) {
+ // Enumerate the branch
+ nsCOMPtr<nsIPrefBranch> branch;
+ aPrefService->GetBranch(branchName, getter_AddRefs(branch));
+
+ uint32_t count = aPrefs.Length();
+ for (uint32_t i = 0; i < count; i++) {
+ PrefBranchStruct* pref = aPrefs.ElementAt(i);
+ switch (pref->type) {
+ case nsIPrefBranch::PREF_STRING:
+ (void)branch->SetCharPref(pref->prefName,
+ nsDependentCString(pref->stringValue));
+ if (deallocate) {
+ free(pref->stringValue);
+ pref->stringValue = nullptr;
+ }
+ break;
+ case nsIPrefBranch::PREF_BOOL:
+ (void)branch->SetBoolPref(pref->prefName, pref->boolValue);
+ break;
+ case nsIPrefBranch::PREF_INT:
+ (void)branch->SetIntPref(pref->prefName, pref->intValue);
+ break;
+ default:
+ NS_WARNING(
+ "Invalid Pref Type in "
+ "nsNetscapeProfileMigratorBase::WriteBranch");
+ break;
+ }
+ if (deallocate) {
+ free(pref->prefName);
+ pref->prefName = nullptr;
+ delete pref;
+ }
+ pref = nullptr;
+ }
+ if (deallocate) {
+ aPrefs.Clear();
+ }
+}
+
+nsresult nsSeamonkeyProfileMigrator::DummyCopyRoutine(bool aReplace) {
+ // place holder function only to fake the UI out into showing some migration
+ // process.
+ return NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyJunkTraining(bool aReplace) {
+ return aReplace ? CopyFile(FILE_NAME_JUNKTRAINING, FILE_NAME_JUNKTRAINING)
+ : NS_OK;
+}
+
+nsresult nsSeamonkeyProfileMigrator::CopyPasswords(bool aReplace) {
+ nsresult rv = NS_OK;
+
+ nsCString signonsFileName;
+ GetSignonFileName(aReplace, signonsFileName);
+
+ if (signonsFileName.IsEmpty()) return NS_ERROR_FILE_NOT_FOUND;
+
+ nsAutoString fileName;
+ CopyASCIItoUTF16(signonsFileName, fileName);
+ if (aReplace)
+ rv = CopyFile(fileName, fileName);
+ else {
+ // don't do anything right now
+ }
+ return rv;
+}
diff --git a/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h
new file mode 100644
index 0000000000..e085e1b2cd
--- /dev/null
+++ b/comm/mail/components/migration/src/nsSeamonkeyProfileMigrator.h
@@ -0,0 +1,84 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef seamonkeyprofilemigrator___h___
+#define seamonkeyprofilemigrator___h___
+
+#include "nsTHashMap.h"
+#include "nsIMailProfileMigrator.h"
+#include "nsIMsgAccountManager.h"
+#include "nsNetscapeProfileMigratorBase.h"
+
+class nsIPrefBranch;
+class nsIPrefService;
+
+class nsSeamonkeyProfileMigrator : public nsNetscapeProfileMigratorBase {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+
+ nsSeamonkeyProfileMigrator();
+
+ // nsIMailProfileMigrator methods
+ NS_IMETHOD Migrate(uint16_t aItems, nsIProfileStartup* aStartup,
+ const char16_t* aProfile) override;
+ NS_IMETHOD GetMigrateData(const char16_t* aProfile, bool aReplace,
+ uint16_t* aResult) override;
+ NS_IMETHOD GetSourceProfiles(nsTArray<nsString>& aResult) override;
+ NS_IMETHOD GetSourceProfileLocations(
+ nsTArray<RefPtr<nsIFile>>& aResult) override;
+
+ protected:
+ virtual ~nsSeamonkeyProfileMigrator();
+ nsresult FillProfileDataFromSeamonkeyRegistry();
+ nsresult GetSourceProfile(const char16_t* aProfile);
+
+ nsresult MigrateMABFile(const nsCString& aDirPrefId,
+ const nsCString& aSourceFileName);
+
+ nsresult CopyPreferences(bool aReplace);
+ nsresult ImportPreferences(uint16_t aItems);
+ nsresult TransformPreferences(const nsAString& aSourcePrefFileName,
+ const nsAString& aTargetPrefFileName);
+
+ nsresult DummyCopyRoutine(bool aReplace);
+ nsresult CopyJunkTraining(bool aReplace);
+ nsresult CopyPasswords(bool aReplace);
+ nsresult CopyMailFolders(PBStructArray& aMailServers,
+ nsIPrefService* aPrefBranch);
+ nsresult CopySignatureFiles(PBStructArray& aIdentities,
+ nsIPrefService* aPrefBranch);
+
+ typedef nsTHashMap<nsCStringHashKey, nsCString> PrefKeyHashTable;
+
+ nsresult TransformIdentitiesForImport(
+ PBStructArray& aIdentities, nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& smtpServerKeyHashTable, PrefKeyHashTable& keyHashTable);
+ nsresult TransformMailAccountsForImport(
+ nsIPrefService* aPrefService, PBStructArray& aAccounts,
+ nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& identityKeyHashTable,
+ PrefKeyHashTable& serverKeyHashTable);
+ nsresult TransformMailServersForImport(const char* branchName,
+ nsIPrefService* aPrefService,
+ PBStructArray& aMailServers,
+ nsIMsgAccountManager* accountManager,
+ PrefKeyHashTable& keyHashTable);
+ nsresult TransformSmtpServersForImport(PBStructArray& aServers,
+ PrefKeyHashTable& keyHashTable);
+ nsresult TransformAddressbooksForImport(nsIPrefService* aPrefService,
+ PBStructArray& aAddressbooks,
+ bool aReplace);
+
+ void ReadBranch(const char* branchName, nsIPrefService* aPrefService,
+ PBStructArray& aPrefs);
+ void WriteBranch(const char* branchName, nsIPrefService* aPrefService,
+ PBStructArray& aPrefs, bool deallocate = true);
+
+ private:
+ nsTArray<nsString> mProfileNames;
+ nsTArray<RefPtr<nsIFile>> mProfileLocations;
+};
+
+#endif
diff --git a/comm/mail/components/moz.build b/comm/mail/components/moz.build
new file mode 100644
index 0000000000..9e4a8c3fe4
--- /dev/null
+++ b/comm/mail/components/moz.build
@@ -0,0 +1,53 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Only Mac and Windows have search integration components, but we include at
+# least one module from search/ on all platforms
+DIRS += [
+ "about-support",
+ "accountcreation",
+ "activity",
+ "addrbook",
+ "cloudfile",
+ "compose",
+ "customizableui",
+ "devtools",
+ "downloads",
+ "enterprisepolicies",
+ "extensions",
+ "im",
+ "migration",
+ "newmailaccount",
+ "preferences",
+ "prompts",
+ "search",
+ "shell",
+ "unifiedtoolbar",
+]
+
+EXTRA_COMPONENTS += [
+ "MailComponents.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "AboutRedirector.jsm",
+ "AppIdleManager.jsm",
+ "MailGlue.jsm",
+ "MessengerContentHandler.jsm",
+]
+
+if CONFIG["MOZ_DEBUG"] or CONFIG["NIGHTLY_BUILD"]:
+ EXTRA_JS_MODULES += [
+ "StartupRecorder.jsm",
+ ]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+
+Library("mailcomps")
+FINAL_LIBRARY = "xul"
diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.js b/comm/mail/components/newmailaccount/content/accountProvisioner.js
new file mode 100644
index 0000000000..14ba69c515
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/accountProvisioner.js
@@ -0,0 +1,892 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MsgAccountManager, MozElements */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AccountCreationUtils:
+ "resource:///modules/accountcreation/AccountCreationUtils.jsm",
+
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+var { gAccountSetupLogger } = AccountCreationUtils;
+
+// AbortController to handle timeouts and abort the fetch requests.
+var gAbortController;
+var RETRY_TIMEOUT = 5000; // 5 seconds
+var CONNECTION_TIMEOUT = 15000; // 15 seconds
+var MAX_SMALL_ADDRESSES = 2;
+
+// Keep track of the prefers-reduce-motion media query for JS based animations.
+var gReducedMotion;
+
+// The main 3 Pane Window that we need to define on load in order to properly
+// update the UI when a new account is created.
+var gMainWindow;
+
+// Define window event listeners.
+window.addEventListener("load", () => {
+ gAccountProvisioner.onLoad();
+});
+window.addEventListener("unload", () => {
+ gAccountProvisioner.onUnload();
+});
+
+// Object to collect all the extra providers attributes to be used when
+// building the URL for the API call to purchase an item.
+var storedData = {};
+
+/**
+ * Helper method to split a value based on its first available blank space.
+ *
+ * @param {string} str - The string to split.
+ * @returns {Array} - An array with the generated first and last name.
+ */
+function splitName(str) {
+ let i = str.lastIndexOf(" ");
+ if (i >= 1) {
+ return [str.substring(0, i), str.substring(i + 1)];
+ }
+ return [str, ""];
+}
+
+/**
+ * Quick and simple HTML sanitization.
+ *
+ * @param {string} inputID - The ID of the currently used input field.
+ * @returns {string} - The HTML sanitized input value.
+ */
+function sanitizeName(inputID) {
+ let div = document.createElement("div");
+ div.textContent = document.getElementById(inputID).value;
+ return div.innerHTML.trim();
+}
+
+/**
+ * Replace occurrences of placeholder with the given node
+ *
+ * @param aTextContainer {Node} - DOM node containing the text child
+ * @param aTextNode {Node} - Text node containing the text, child of the aTextContainer
+ * @param aPlaceholder {String} - String to look for in aTextNode's textContent
+ * @param aReplacement {Node} - DOM node to insert instead of the found replacement
+ */
+function insertHTMLReplacement(
+ aTextContainer,
+ aTextNode,
+ aPlaceholder,
+ aReplacement
+) {
+ if (aTextNode.textContent.includes(aPlaceholder)) {
+ let placeIndex = aTextNode.textContent.indexOf(aPlaceholder);
+ let restNode = aTextNode.splitText(placeIndex + aPlaceholder.length);
+ aTextContainer.insertBefore(aReplacement, restNode);
+ let placeholderNode = aTextNode.splitText(placeIndex);
+ placeholderNode.remove();
+ }
+}
+
+/**
+ * This is our controller for the entire account provisioner setup process.
+ */
+var gAccountProvisioner = {
+ // If the setup wizard has already been initialized.
+ _isInited: false,
+ // If the data fetching of the providers is currently in progress.
+ _isLoadingProviders: false,
+ // If the providers have already been loaded.
+ _isLoadedProviders: false,
+ // Store a timeout retry in case fetching the providers fails.
+ _loadProviderRetryId: null,
+ // Array containing all fetched providers.
+ allProviders: [],
+ // Array containing all fetched provider names that only offer email.
+ mailProviders: [],
+ // Array containing all fetched provider names that also offer custom domain.
+ domainProviders: [],
+ // Handle a timeout to abort the fetch requests.
+ timeoutId: null,
+
+ /**
+ * Returns the URL for retrieving suggested names from the selected providers.
+ */
+ get suggestFromName() {
+ return Services.prefs.getCharPref("mail.provider.suggestFromName");
+ },
+
+ /**
+ * Initialize the main notification box for the account setup process.
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document
+ .getElementById("accountProvisionerNotifications")
+ .append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ /**
+ * Clear currently running async fetches and reset important variables.
+ */
+ onUnload() {
+ this.clearAbortTimeout();
+ gAbortController.abort();
+ gAbortController = null;
+ },
+
+ async onLoad() {
+ // We can only init once, so bail out if we've been called again.
+ if (this._isInited) {
+ return;
+ }
+
+ gAccountSetupLogger.debug("Initializing provisioner wizard");
+ gReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+
+ // Store the main window.
+ gMainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // Initialize the fetch abort controller.
+ gAbortController = new AbortController();
+
+ // If we have a name stored, populate the search field with it.
+ if ("@mozilla.org/userinfo;1" in Cc) {
+ let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo);
+ // Assume that it's a genuine full name if it includes a space.
+ if (userInfo.fullname.includes(" ")) {
+ document.getElementById("mailName").value = userInfo.fullname;
+ document.getElementById("domainName").value = userInfo.fullname;
+ }
+ }
+
+ this.setupEventListeners();
+ await this.tryToFetchProviderList();
+
+ gAccountSetupLogger.debug("Provisioner wizard init complete.");
+
+ // Move the focus on the first available field.
+ document.getElementById("mailName").focus();
+ this._isInited = true;
+
+ Services.telemetry.scalarAdd("tb.account.opened_account_provisioner", 1);
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+ },
+
+ /**
+ * Set up the event listeners for the static elements in the page.
+ */
+ setupEventListeners() {
+ document.getElementById("cancelButton").onclick = () => {
+ window.close();
+ };
+
+ document.getElementById("existingButton").onclick = () => {
+ window.close();
+ gMainWindow.postMessage("open-account-setup-tab", "*");
+ };
+
+ document.getElementById("backButton").onclick = () => {
+ this.backToSetupView();
+ };
+ },
+
+ /**
+ * Return to the initial view without resetting any existing data.
+ */
+ backToSetupView() {
+ this.clearAbortTimeout();
+ this.clearNotifications();
+
+ // Clear search results.
+ let mailResultsArea = document.getElementById("mailResultsArea");
+ while (mailResultsArea.hasChildNodes()) {
+ mailResultsArea.lastChild.remove();
+ }
+ let domainResultsArea = document.getElementById("domainResultsArea");
+ while (domainResultsArea.hasChildNodes()) {
+ domainResultsArea.lastChild.remove();
+ }
+
+ // Update the UI to show the initial view.
+ document.getElementById("mailSearch").hidden = false;
+ document.getElementById("domainSearch").hidden = false;
+ document.getElementById("mailSearchResults").hidden = true;
+ document.getElementById("domainSearchResults").hidden = true;
+
+ // Update the buttons visibility.
+ document.getElementById("backButton").hidden = true;
+ document.getElementById("cancelButton").hidden = false;
+ document.getElementById("existingButton").hidden = false;
+
+ // Move the focus back on the first available field.
+ document.getElementById("mailName").focus();
+ },
+
+ /**
+ * Show a loading notification.
+ */
+ async startLoadingState(stringName) {
+ this.clearNotifications();
+
+ let notificationMessage = await document.l10n.formatValue(stringName);
+
+ gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountSetupLoading",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_INFO_LOW,
+ },
+ null
+ );
+ notification.setAttribute("align", "center");
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Show an error notification in case something went wrong.
+ *
+ * @param {string} stringName - The name of the fluent string that needs to
+ * be attached to the notification.
+ * @param {boolean} isMsgError - True if the message comes from a server error
+ * response or try/catch.
+ */
+ async showErrorNotification(stringName, isMsgError) {
+ gAccountSetupLogger.debug(`Status error: ${stringName}`);
+
+ // Always remove any leftover notification before creating a new one.
+ this.clearNotifications();
+
+ // Fetch the fluent string only if this is not an error message coming from
+ // a previous method.
+ let notificationMessage = isMsgError
+ ? stringName
+ : await document.l10n.formatValue(stringName);
+
+ let notification = this.notificationBox.appendNotification(
+ "accountProvisionerError",
+ {
+ label: notificationMessage,
+ priority: this.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ async showSuccessNotification(stringName) {
+ // Always remove any leftover notification before creating a new one.
+ this.clearNotifications();
+
+ let notification = this.notificationBox.appendNotification(
+ "accountProvisionerSuccess",
+ {
+ label: await document.l10n.formatValue(stringName),
+ priority: this.notificationBox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ notification.setAttribute("type", "success");
+ // Hide the close button to prevent dismissing the notification.
+ notification.removeAttribute("dismissable");
+
+ this.ensureVisibleNotification();
+ },
+
+ /**
+ * Clear all leftover notifications.
+ */
+ clearNotifications() {
+ this.notificationBox.removeAllNotifications();
+ },
+
+ /**
+ * Event handler for when the user selects an address by clicking on the price
+ * button for that address. This function spawns the content tab for the
+ * address order form, and then closes the Account Provisioner tab.
+ *
+ * @param {string} providerId - The ID of the chosen provider.
+ * @param {string} email - The chosen email address.
+ * @param {boolean} [isDomain=false] - If the fetched data comes from a domain
+ * search form.
+ */
+ onAddressSelected(providerId, email, isDomain = false) {
+ gAccountSetupLogger.debug("An address was selected by the user.");
+ let provider = this.allProviders.find(p => p.id == providerId);
+
+ let url = provider.api;
+ let inputID = isDomain ? "domainName" : "mailName";
+ let [firstName, lastName] = splitName(sanitizeName(inputID));
+ // Replace the variables in the API url.
+ url = url.replace("{firstname}", firstName);
+ url = url.replace("{lastname}", lastName);
+ url = url.replace("{email}", email);
+
+ // And add the extra data.
+ let data = storedData[providerId];
+ delete data.provider;
+ for (let name in data) {
+ url += `${!url.includes("?") ? "?" : "&"}${name}=${encodeURIComponent(
+ data[name]
+ )}`;
+ }
+
+ gAccountSetupLogger.debug("Opening up a contentTab with the order form.");
+ // Open the checkout content tab.
+ let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = mail3Pane.document.getElementById("tabmail");
+ tabmail.openTab("provisionerCheckoutTab", {
+ url,
+ realName: (firstName + " " + lastName).trim(),
+ email,
+ });
+
+ let providerHostname = new URL(url).hostname;
+ // Collect telemetry on which provider was selected for a new email account.
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.selected_account_from_provisioner",
+ providerHostname,
+ 1
+ );
+
+ // The user has made a selection. Close the provisioner window and let the
+ // provider setup process take place in a dedicated tab.
+ window.close();
+ },
+
+ /**
+ * Attempt to fetch the provider list from the server.
+ */
+ async tryToFetchProviderList() {
+ // If we're already in the middle of getting the provider list, or we
+ // already got it before, bail out.
+ if (this._isLoadingProviders || this._isLoadedProviders) {
+ return;
+ }
+
+ this._isLoadingProviders = true;
+
+ // If there's a timeout ID for waking the account provisioner, clear it.
+ if (this._loadProviderRetryId) {
+ window.clearTimeout(this._loadProviderRetryId);
+ this._loadProviderRetryId = null;
+ }
+
+ await this.startLoadingState("account-provisioner-fetching-provisioners");
+
+ let providerListUrl = Services.prefs.getCharPref(
+ "mail.provider.providerList"
+ );
+
+ gAccountSetupLogger.debug(
+ `Trying to populate provider list from ${providerListUrl}…`
+ );
+
+ try {
+ let res = await fetch(providerListUrl, {
+ signal: gAbortController.signal,
+ });
+ this.startAbortTimeout();
+ let data = await res.json();
+ this.populateProvidersLists(data);
+ } catch (error) {
+ // Ugh, we couldn't get the JSON file. Maybe we're not online. Or maybe
+ // the server is down, or the file isn't being served. Regardless, if
+ // we get here, none of this stuff is going to work.
+ this._loadProviderRetryId = window.setTimeout(
+ () => this.tryToFetchProviderList(),
+ RETRY_TIMEOUT
+ );
+ this._isLoadingProviders = false;
+ this.showErrorNotification("account-provisioner-connection-issues");
+ gAccountSetupLogger.warn(`Failed to populate providers: ${error}`);
+ }
+ },
+
+ /**
+ * Validate a provider fetched during an API request to be sure we have all
+ * the necessary fields to complete a setup process.
+ *
+ * @param {object} provider - The fetched provider.
+ * @returns {boolean} - True if all the fields in the provider match the
+ * required fields.
+ */
+ providerHasCorrectFields(provider) {
+ let result = true;
+
+ let required = [
+ "id",
+ "label",
+ "paid",
+ "languages",
+ "api",
+ "tos_url",
+ "privacy_url",
+ "sells_domain",
+ ];
+
+ for (let field of required) {
+ let fieldExists = field in provider;
+ result &= fieldExists;
+
+ if (!fieldExists) {
+ gAccountSetupLogger.warn(
+ `A provider did not have the field ${field}, and will be skipped.`
+ );
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Take the fetched providers, create checkboxes, icons and labels, and insert
+ * them below the corresponding search input.
+ *
+ * @param {?object} data - The object containing all fetched providers.
+ */
+ populateProvidersLists(data) {
+ gAccountSetupLogger.debug("Populating the provider list");
+ this.clearAbortTimeout();
+
+ if (!data || !data.length) {
+ gAccountSetupLogger.warn(
+ "The provider list we got back from the server was empty!"
+ );
+ this.showErrorNotification("account-provisioner-connection-issues");
+ return;
+ }
+
+ let mailProviderList = document.getElementById("mailProvidersList");
+ let domainProviderList = document.getElementById("domainProvidersList");
+
+ this.allProviders = data;
+ this.mailProviders = [];
+ this.domainProviders = [];
+
+ for (let provider of data) {
+ if (!this.providerHasCorrectFields(provider)) {
+ gAccountSetupLogger.warn(
+ "A provider had incorrect fields, and has been skipped"
+ );
+ continue;
+ }
+
+ let entry = document.createElement("li");
+ entry.setAttribute("id", provider.id);
+
+ if (provider.icon) {
+ let icon = document.createElement("img");
+ icon.setAttribute("src", provider.icon);
+ icon.setAttribute("alt", "");
+ entry.appendChild(icon);
+ }
+
+ let name = document.createElement("span");
+ name.textContent = provider.label;
+ entry.appendChild(name);
+
+ if (provider.sells_domain) {
+ domainProviderList.appendChild(entry);
+ this.domainProviders.push(provider.id);
+ } else {
+ mailProviderList.appendChild(entry);
+ this.mailProviders.push(provider.id);
+ }
+ }
+
+ this._isLoadedProviders = true;
+ this.clearNotifications();
+ },
+
+ /**
+ * Enable or disable the form fields when a fetch request starts or ends.
+ *
+ * @param {boolean} state - True if a fetch request is in progress.
+ */
+ updateSearchingState(state) {
+ for (let element of document.querySelectorAll(".disable-on-submit")) {
+ element.disabled = state;
+ }
+ },
+
+ /**
+ * Search for available email accounts.
+ *
+ * @param {DOMEvent} event - The form submit event.
+ */
+ async onMailFormSubmit(event) {
+ // Always prevent the actual form submission.
+ event.preventDefault();
+
+ // Quick HTML sanitization.
+ let name = sanitizeName("mailName");
+
+ // Bail out if the user didn't type anything.
+ if (!name) {
+ return;
+ }
+
+ let resultsArea = document.getElementById("mailSearchResults");
+ resultsArea.hidden = true;
+
+ this.startLoadingState("account-provisioner-searching-email");
+ let data = await this.submitFormRequest(name, this.mailProviders.join(","));
+ this.clearAbortTimeout();
+
+ let count = this.populateSearchResults(data);
+ if (!count) {
+ // Bail out if we didn't get any usable data.
+ gAccountSetupLogger.warn(
+ "We got nothing back from the server for search results!"
+ );
+ this.showErrorNotification("account-provisioner-searching-error");
+ return;
+ }
+
+ let resultsTitle = document.getElementById("mailResultsTitle");
+ let resultsString = await document.l10n.formatValue(
+ "account-provisioner-results-title",
+ { count }
+ );
+ // Attach the sanitized search terms to avoid HTML conversion in fluent.
+ resultsTitle.textContent = `${resultsString} "${name}"`;
+
+ // Hide the domain section.
+ document.getElementById("domainSearch").hidden = true;
+ // Show the results area.
+ resultsArea.hidden = false;
+ // Update the buttons visibility.
+ document.getElementById("cancelButton").hidden = true;
+ document.getElementById("existingButton").hidden = true;
+ // Show the back button.
+ document.getElementById("backButton").hidden = false;
+ },
+
+ /**
+ * Search for available domain names.
+ *
+ * @param {DOMEvent} event - The form submit event.
+ */
+ async onDomainFormSubmit(event) {
+ // Always prevent the actual form submission.
+ event.preventDefault();
+
+ // Quick HTML sanitization.
+ let name = sanitizeName("domainName");
+
+ // Bail out if the user didn't type anything.
+ if (!name) {
+ return;
+ }
+
+ let resultsArea = document.getElementById("domainSearchResults");
+ resultsArea.hidden = true;
+
+ this.startLoadingState("account-provisioner-searching-domain");
+ let data = await this.submitFormRequest(
+ name,
+ this.domainProviders.join(",")
+ );
+ this.clearAbortTimeout();
+
+ let count = this.populateSearchResults(data, true);
+ if (!count) {
+ // Bail out if we didn't get any usable data.
+ gAccountSetupLogger.warn(
+ "We got nothing back from the server for search results!"
+ );
+ this.showErrorNotification("account-provisioner-searching-error");
+ return;
+ }
+
+ let resultsTitle = document.getElementById("domainResultsTitle");
+ let resultsString = await document.l10n.formatValue(
+ "account-provisioner-results-title",
+ { count }
+ );
+ // Attach the sanitized search terms to avoid HTML conversion in fluent.
+ resultsTitle.textContent = `${resultsString} "${name}"`;
+
+ // Hide the mail section.
+ document.getElementById("mailSearch").hidden = true;
+ // Show the results area.
+ resultsArea.hidden = false;
+ // Update the buttons visibility.
+ document.getElementById("cancelButton").hidden = true;
+ document.getElementById("existingButton").hidden = true;
+ // Show the back button.
+ document.getElementById("backButton").hidden = false;
+ },
+
+ /**
+ * Update the UI to show the fetched address data.
+ *
+ * @param {object} data - The fetched data from an email or domain search.
+ * @param {boolean} [isDomain=false] - If the fetched data comes from a domain
+ * search form.
+ */
+ populateSearchResults(data, isDomain = false) {
+ if (!data || !data.length) {
+ return 0;
+ }
+
+ this.clearNotifications();
+
+ let resultsArea = isDomain
+ ? document.getElementById("domainResultsArea")
+ : document.getElementById("mailResultsArea");
+ // Clear previously generated content.
+ while (resultsArea.hasChildNodes()) {
+ resultsArea.lastChild.remove();
+ }
+
+ // Filter out possible errors or empty lists.
+ let validData = data.filter(
+ result => result.succeeded && result.addresses.length
+ );
+
+ if (!validData || !validData.length) {
+ return 0;
+ }
+
+ let providersList = isDomain ? this.domainProviders : this.mailProviders;
+
+ let count = 0;
+ for (let provider of validData) {
+ count += provider.addresses.length;
+
+ // Don't add a provider header if only 1 is currently available.
+ if (providersList.length > 1) {
+ let header = document.createElement("h5");
+ header.classList.add("result-list-header");
+ header.textContent = this.allProviders.find(
+ p => p.id == provider.provider
+ ).label;
+ resultsArea.appendChild(header);
+ }
+
+ let list = document.createElement("ul");
+
+ // Only show a chink of addresses if we got a long list.
+ let isLongList = provider.addresses.length > 5;
+ let addresses = isLongList
+ ? provider.addresses.slice(0, 4)
+ : provider.addresses;
+
+ for (let address of addresses) {
+ list.appendChild(this.createAddressRow(address, provider, isDomain));
+ }
+
+ resultsArea.appendChild(list);
+
+ // If we got more than 5 addresses, create an hidden bug expandable list
+ // with the rest of the data.
+ if (isLongList) {
+ let hiddenList = document.createElement("ul");
+ hiddenList.hidden = true;
+
+ for (let address of provider.addresses.slice(5)) {
+ hiddenList.appendChild(
+ this.createAddressRow(address, provider, isDomain)
+ );
+ }
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+ button.classList.add("btn-link", "self-center");
+ document.l10n.setAttributes(
+ button,
+ "account-provisioner-all-results-button"
+ );
+ button.onclick = () => {
+ hiddenList.hidden = false;
+ button.hidden = true;
+ };
+
+ resultsArea.appendChild(button);
+ resultsArea.appendChild(hiddenList);
+ }
+ }
+
+ for (let provider of data) {
+ delete provider.succeeded;
+ delete provider.addresses;
+ delete provider.price;
+ storedData[provider.provider] = provider;
+ }
+
+ return count;
+ },
+
+ /**
+ * Create the list item to show the suggested address returned from a search.
+ *
+ * @param {object} address - The address returned from the provider search.
+ * @param {object} provider - The provider from which the address is
+ * @param {boolean} [isDomain=false] - If the fetched data comes from a domain
+ * search form.
+ * available.
+ * @returns {HTMLLIElement}
+ */
+ createAddressRow(address, provider, isDomain = false) {
+ let row = document.createElement("li");
+ row.classList.add("result-item");
+
+ let suggestedAddress = address.address || address;
+
+ let button = document.createElement("button");
+ button.setAttribute("type", "button");
+ button.onclick = () => {
+ this.onAddressSelected(provider.provider, suggestedAddress, isDomain);
+ };
+
+ let leftArea = document.createElement("span");
+ leftArea.classList.add("result-data");
+
+ let name = document.createElement("span");
+ name.classList.add("result-name");
+ name.textContent = suggestedAddress;
+ leftArea.appendChild(name);
+ row.setAttribute("data-label", suggestedAddress);
+
+ let price = document.createElement("span");
+ price.classList.add("result-price");
+
+ // Build the pricing text and handle possible free trials.
+ if (address.price) {
+ if (address.price != 0) {
+ // Some pricing is defined.
+ document.l10n.setAttributes(price, "account-provision-price-per-year", {
+ price: address.price,
+ });
+ } else if (address.price == 0) {
+ // Price is defined by it's zero.
+ document.l10n.setAttributes(price, "account-provisioner-free-account");
+ }
+ } else if (provider.price && provider.price != 0) {
+ // We don't have a price for the current result so let's try to use
+ // the general Provider's price.
+ document.l10n.setAttributes(price, "account-provision-price-per-year", {
+ price: provider.price,
+ });
+ } else {
+ // No price was specified, let's return "Free".
+ document.l10n.setAttributes(price, "account-provisioner-free-account");
+ }
+ leftArea.appendChild(price);
+
+ button.appendChild(leftArea);
+
+ let img = document.createElement("img");
+ document.l10n.setAttributes(img, "account-provisioner-open-in-tab-img");
+ img.setAttribute("alt", "");
+ img.setAttribute("src", "chrome://global/skin/icons/open-in-new.svg");
+ button.appendChild(img);
+
+ row.appendChild(button);
+
+ return row;
+ },
+
+ /**
+ * Fetches a list of suggested email addresses or domain names from a list of
+ * selected providers.
+ *
+ * @param {string} name - The search value typed by the user.
+ * @param {Array} providers - Array of providers to search for.
+ * @returns {object} - A list of available emails or domains.
+ */
+ async submitFormRequest(name, providers) {
+ // If the focused element is disabled by `updateSearchingState`, focus is
+ // lost. Save the focused element to restore it later.
+ let activeElement = document.activeElement;
+ this.updateSearchingState(true);
+
+ let [firstName, lastName] = splitName(name);
+ let url = `${this.suggestFromName}?first_name=${encodeURIComponent(
+ firstName
+ )}&last_name=${encodeURIComponent(lastName)}&providers=${encodeURIComponent(
+ providers
+ )}&version=2`;
+
+ let data;
+ try {
+ let res = await fetch(url, { signal: gAbortController.signal });
+ this.startAbortTimeout();
+ data = await res.json();
+ } catch (error) {
+ gAccountSetupLogger.warn(`Failed to fetch address data: ${error}`);
+ }
+
+ this.updateSearchingState(false);
+ // Restore focus.
+ activeElement.focus();
+ return data;
+ },
+
+ /**
+ * Start a timeout to abort a fetch request based on a time limit.
+ */
+ startAbortTimeout() {
+ this.timeoutId = setTimeout(() => {
+ gAbortController.abort();
+ this.showErrorNotification("account-provisioner-connection-timeout");
+ gAccountSetupLogger.warn("Connection timed out");
+ }, CONNECTION_TIMEOUT);
+ },
+
+ /**
+ * Clear any leftover timeout to prevent an unnecessary fetch abort.
+ */
+ clearAbortTimeout() {
+ if (this.timeoutId) {
+ window.clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+ },
+
+ /**
+ * Always ensure the notification area is visible when a new notification is
+ * created.
+ */
+ ensureVisibleNotification() {
+ document.getElementById("accountProvisionerNotifications").scrollIntoView({
+ behavior: gReducedMotion ? "auto" : "smooth",
+ block: "start",
+ inline: "nearest",
+ });
+ },
+};
diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml b/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml
new file mode 100644
index 0000000000..37f4be1422
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml
@@ -0,0 +1,226 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/accountSetup.css" type="text/css"?>
+
+<!DOCTYPE html>
+
+<html id="accountProvisioner" xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title data-l10n-id="account-provisioner-tab-title"></title>
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="icon"
+ href="chrome://messenger/skin/icons/new/compact/new-mail.svg"
+ />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/accountProvisioner.ftl" />
+
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/accountUtils.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/newmailaccount/accountProvisioner.js"
+ ></script>
+ </head>
+
+ <body>
+ <header>
+ <h1
+ id="accountProvisionerTitle"
+ data-l10n-id="account-provisioner-title"
+ class="title"
+ ></h1>
+ <p
+ id="accountProvisionerDescription"
+ data-l10n-id="account-provisioner-description"
+ class="description"
+ ></p>
+ </header>
+
+ <section class="main-container">
+ <aside id="setupView" class="column column-wide">
+ <section id="mailSearch">
+ <h3
+ class="service-title"
+ data-l10n-id="account-provisioner-mail-account-title"
+ ></h3>
+
+ <p
+ class="service-description"
+ data-l10n-id="account-provisioner-mail-account-description"
+ >
+ <a
+ href="https://mailfence.com/"
+ data-l10n-name="mailfence-home-link"
+ ></a>
+ </p>
+
+ <form
+ id="mailForm"
+ class="service-form"
+ onsubmit="gAccountProvisioner.onMailFormSubmit(event);"
+ >
+ <div class="service-form-container">
+ <input
+ id="mailName"
+ type="text"
+ data-l10n-id="account-provisioner-mail-input"
+ class="disable-on-submit"
+ autocomplete="off"
+ required="required"
+ />
+ <button
+ type="submit"
+ class="disable-on-submit"
+ data-l10n-id="account-provisioner-search-button"
+ ></button>
+ </div>
+
+ <ul id="mailProvidersList" class="providers-list">
+ <!-- This will be populated in JS. -->
+ </ul>
+ </form>
+
+ <div id="mailSearchResults" hidden="hidden">
+ <h4 id="mailResultsTitle" class="results-title"></h4>
+ <section class="provisioner-results-area">
+ <div id="mailResultsArea" class="results-list"></div>
+ </section>
+ <p
+ data-l10n-id="account-provisioner-mail-results-caption"
+ class="tip-caption"
+ ></p>
+ </div>
+ </section>
+
+ <section id="domainSearch">
+ <h3
+ class="service-title"
+ data-l10n-id="account-provisioner-domain-title"
+ ></h3>
+
+ <p
+ class="service-description"
+ data-l10n-id="account-provisioner-domain-description"
+ >
+ <a href="https://gandi.net/" data-l10n-name="gandi-home-link"></a>
+ </p>
+
+ <form
+ id="domainForm"
+ class="service-form"
+ onsubmit="gAccountProvisioner.onDomainFormSubmit(event);"
+ >
+ <div class="service-form-container">
+ <input
+ id="domainName"
+ type="text"
+ data-l10n-id="account-provisioner-domain-input"
+ class="disable-on-submit"
+ autocomplete="off"
+ required="required"
+ />
+ <button
+ type="submit"
+ class="disable-on-submit"
+ data-l10n-id="account-provisioner-search-button"
+ ></button>
+ </div>
+
+ <ul id="domainProvidersList" class="providers-list">
+ <!-- This will be populated in JS. -->
+ </ul>
+ </form>
+
+ <div id="domainSearchResults" hidden="hidden">
+ <h4 id="domainResultsTitle" class="results-title"></h4>
+ <section class="provisioner-results-area">
+ <div id="domainResultsArea" class="results-list"></div>
+ </section>
+ <p
+ data-l10n-id="account-provisioner-domain-results-caption"
+ class="tip-caption"
+ ></p>
+ </div>
+ </section>
+
+ <div
+ id="accountProvisionerNotifications"
+ class="account-setup-notifications"
+ >
+ <!-- Notifications will be lazily loaded here. -->
+ </div>
+
+ <section class="action-buttons-container provisioner-buttons">
+ <button
+ id="backButton"
+ type="button"
+ data-l10n-id="account-provisioner-button-back"
+ data-l10n-attrs="accesskey"
+ hidden="hidden"
+ ></button>
+ <button
+ id="cancelButton"
+ type="button"
+ data-l10n-id="account-provisioner-button-cancel"
+ data-l10n-attrs="accesskey"
+ ></button>
+ <button
+ id="existingButton"
+ type="button"
+ data-l10n-id="account-provisioner-button-existing"
+ data-l10n-attrs="accesskey"
+ ></button>
+ </section>
+ </aside>
+ <!-- END setupView column -->
+
+ <aside class="column second-column">
+ <article id="step1" class="tip-caption">
+ <img
+ src="chrome://messenger/skin/illustrations/octopus-setup.svg"
+ data-l10n-id="account-provisioner-step1-image"
+ alt=""
+ />
+ <p data-l10n-id="account-provisioner-start-help">
+ <a
+ href="https://www.mozilla.org/privacy/"
+ data-l10n-name="mozilla-privacy-link"
+ ></a>
+ <a
+ href="https://mailfence.com/en/privacy.jsp"
+ data-l10n-name="mailfence-privacy-link"
+ ></a>
+ <a
+ href="https://mailfence.com/en/terms.jsp"
+ data-l10n-name="mailfence-tou-link"
+ ></a>
+ <a
+ href="https://www.gandi.net/contracts/privacy-policy"
+ data-l10n-name="gandi-privacy-link"
+ ></a>
+ <a
+ href="https://www.gandi.net/contracts/terms-of-use"
+ data-l10n-name="gandi-tou-link"
+ ></a>
+ </p>
+ </article>
+ </aside>
+ <!-- END second column-->
+ </section>
+ </body>
+</html>
diff --git a/comm/mail/components/newmailaccount/content/provisionerCheckout.js b/comm/mail/components/newmailaccount/content/provisionerCheckout.js
new file mode 100644
index 0000000000..0fc38c87e5
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/provisionerCheckout.js
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// mail/base/content/contentAreaClick.js
+/* globals hRefForClickEvent, openLinkExternally */
+// mail/base/content/specialTabs.js
+/* globals specialTabs */
+
+var { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+);
+
+/**
+ * A content tab for the account provisioner. We use Javascript-y magic to
+ * "subclass" specialTabs.contentTabType, and then override the appropriate
+ * members.
+ *
+ * Also note that provisionerCheckoutTab is a singleton (hence the maxTabs: 1).
+ */
+var provisionerCheckoutTabType = Object.create(specialTabs.contentTabType, {
+ name: { value: "provisionerCheckoutTab" },
+ modes: {
+ value: {
+ provisionerCheckoutTab: {
+ type: "provisionerCheckoutTab",
+ maxTabs: 1,
+ },
+ },
+ },
+ _log: {
+ value: new ConsoleAPI({
+ prefix: "mail.provider",
+ maxLogLevel: "warn",
+ maxLogLevelPref: "mail.provider.loglevel",
+ }),
+ },
+});
+
+/**
+ * Here, we're overriding openTab - first we call the openTab of contentTab
+ * (for the context of this provisionerCheckoutTab "aTab") and then passing
+ * special arguments "realName", "email" and "searchEngine" from the caller
+ * of openTab, and passing those to our _setMonitoring function.
+ */
+provisionerCheckoutTabType.openTab = function (aTab, aArgs) {
+ specialTabs.contentTabType.openTab.call(this, aTab, aArgs);
+
+ // Since there's only one tab of this type ever (see the mode definition),
+ // we're OK to stash this stuff here.
+ this._realName = aArgs.realName;
+ this._email = aArgs.email;
+ this._searchEngine = aArgs.searchEngine || "";
+
+ this._setMonitoring(
+ aTab.browser,
+ aArgs.realName,
+ aArgs.email,
+ aArgs.searchEngine
+ );
+};
+
+/**
+ * We're overriding closeTab - first, we call the closeTab of contentTab,
+ * (for the context of this provisionerCheckoutTab "aTab"), and then we
+ * unregister our observer that was registered in _setMonitoring.
+ */
+provisionerCheckoutTabType.closeTab = function (aTab) {
+ specialTabs.contentTabType.closeTab.call(this, aTab);
+ this._log.info("Performing account provisioner cleanup");
+ this._log.info("Removing httpRequestObserver");
+ Services.obs.removeObserver(this._observer, "http-on-examine-response");
+ Services.obs.removeObserver(
+ this._observer,
+ "http-on-examine-cached-response"
+ );
+ Services.obs.removeObserver(this.quitObserver, "mail-unloading-messenger");
+ delete this._observer;
+ this._log.info("Account provisioner cleanup is done.");
+};
+
+/**
+ * Serialize our tab into something we can restore later.
+ */
+provisionerCheckoutTabType.persistTab = function (aTab) {
+ return {
+ tabURI: aTab.browser.currentURI.spec,
+ realName: this._realName,
+ email: this._email,
+ searchEngine: this._searchEngine,
+ };
+};
+
+/**
+ * Re-open the provisionerCheckoutTab with all of the stuff we stashed in
+ * persistTab. This will automatically hook up our monitoring again.
+ */
+provisionerCheckoutTabType.restoreTab = function (aTabmail, aPersistedState) {
+ aTabmail.openTab("provisionerCheckoutTab", {
+ url: aPersistedState.tabURI,
+ realName: aPersistedState.realName,
+ email: aPersistedState.email,
+ searchEngine: aPersistedState.searchEngine,
+ background: true,
+ });
+};
+
+/**
+ * This function registers an observer to watch for HTTP requests where the
+ * contentType contains text/xml.
+ */
+provisionerCheckoutTabType._setMonitoring = function (
+ aBrowser,
+ aRealName,
+ aEmail,
+ aSearchEngine
+) {
+ let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane");
+
+ // We'll construct our special observer (defined in urlListener.js)
+ // that will watch for requests where the contentType contains
+ // text/xml.
+ this._observer = new mail3Pane.httpRequestObserver(aBrowser, {
+ realName: aRealName,
+ email: aEmail,
+ searchEngine: aSearchEngine,
+ });
+
+ // Register our observer
+ Services.obs.addObserver(this._observer, "http-on-examine-response");
+ Services.obs.addObserver(this._observer, "http-on-examine-cached-response");
+ Services.obs.addObserver(this.quitObserver, "mail-unloading-messenger");
+
+ this._log.info("httpRequestObserver wired up.");
+};
+
+/**
+ * This observer listens for the mail-unloading-messenger event fired by each
+ * mail window before they unload. If the mail window is the same window that
+ * this provisionerCheckoutTab belongs to, then we stash a pref so that when
+ * the session restarts, we go straight to the tab, as opposed to showing the
+ * dialog again.
+ */
+provisionerCheckoutTabType.quitObserver = {
+ observe(aSubject, aTopic, aData) {
+ // Make sure we saw the right topic, and that the window that is closing
+ // is the 3pane window that the provisionerCheckoutTab belongs to.
+ if (aTopic == "mail-unloading-messenger" && aSubject === window) {
+ // We quit while the provisionerCheckoutTab was opened. Set our sneaky
+ // pref so that we suppress the dialog on startup.
+ Services.prefs.setBoolPref(
+ "mail.provider.suppress_dialog_on_startup",
+ true
+ );
+ }
+ },
+};
diff --git a/comm/mail/components/newmailaccount/content/uriListener.js b/comm/mail/components/newmailaccount/content/uriListener.js
new file mode 100644
index 0000000000..c4d9177ebe
--- /dev/null
+++ b/comm/mail/components/newmailaccount/content/uriListener.js
@@ -0,0 +1,281 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAccountSetupTabWithAccount, openAccountProvisionerTab */
+
+/**
+ * This object takes care of intercepting page loads and creating the
+ * corresponding account if the page load turns out to be a text/xml file from
+ * one of our account providers.
+ */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
+
+/**
+ * This is an observer that watches all HTTP requests for one where the
+ * response contentType contains text/xml. Once that observation is
+ * made, we ensure that the associated window for that request matches
+ * the window belonging to the content tab for the account order form.
+ * If so, we attach an nsITraceableListener to read the contents of the
+ * request response, and react accordingly if the contents can be turned
+ * into an email account.
+ *
+ * @param aBrowser The XUL <browser> the request lives in.
+ * @param aParams An object containing various bits of information.
+ * @param aParams.realName The real name of the person
+ * @param aParams.email The email address the person picked.
+ * @param aParams.searchEngine The search engine associated to that provider.
+ */
+function httpRequestObserver(aBrowser, aParams) {
+ this.browser = aBrowser;
+ this.params = aParams;
+}
+
+httpRequestObserver.prototype = {
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic != "http-on-examine-response" &&
+ aTopic != "http-on-examine-cached-response"
+ ) {
+ return;
+ }
+
+ if (!(aSubject instanceof Ci.nsIHttpChannel)) {
+ console.error(
+ "Failed to get a nsIHttpChannel when " +
+ "observing http-on-examine-response"
+ );
+ return;
+ }
+ // Helper function to get header values.
+ let getHttpHeader = (httpChannel, header) => {
+ // getResponseHeader throws when header is not set.
+ try {
+ return httpChannel.getResponseHeader(header);
+ } catch (e) {
+ return null;
+ }
+ };
+
+ let contentType = getHttpHeader(aSubject, "Content-Type");
+ if (!contentType || !contentType.toLowerCase().startsWith("text/xml")) {
+ return;
+ }
+
+ // It's possible the account information changed during the setup at the
+ // provider. Check some headers and set them if needed.
+ let name = getHttpHeader(aSubject, "x-thunderbird-account-name");
+ if (name) {
+ this.params.realName = name;
+ }
+ let email = getHttpHeader(aSubject, "x-thunderbird-account-email");
+ if (email) {
+ this.params.email = email;
+ }
+
+ let requestWindow = this._getWindowForRequest(aSubject);
+ if (!requestWindow || requestWindow !== this.browser.innerWindowID) {
+ return;
+ }
+
+ // Ok, we've got a request that looks like a decent candidate.
+ // Let's attach our TracingListener.
+ if (aSubject instanceof Ci.nsITraceableChannel) {
+ let newListener = new TracingListener(this.browser, this.params);
+ newListener.oldListener = aSubject.setNewListener(newListener);
+ }
+ },
+
+ /**
+ * _getWindowForRequest is an internal function that takes an nsIRequest,
+ * and returns the associated window for that request. If it cannot find
+ * an associated window, the function returns null. On exception, the
+ * exception message is logged to the Error Console and null is returned.
+ *
+ * @param aRequest the nsIRequest to analyze
+ */
+ _getWindowForRequest(aRequest) {
+ try {
+ if (aRequest && aRequest.notificationCallbacks) {
+ return aRequest.notificationCallbacks.getInterface(Ci.nsILoadContext)
+ .currentWindowContext.innerWindowId;
+ }
+ if (
+ aRequest &&
+ aRequest.loadGroup &&
+ aRequest.loadGroup.notificationCallbacks
+ ) {
+ return aRequest.loadGroup.notificationCallbacks.getInterface(
+ Ci.nsILoadContext
+ ).currentWindowContext.innerWindowId;
+ }
+ } catch (e) {
+ console.error(
+ "Could not find an associated window " +
+ "for an HTTP request. Error: " +
+ e
+ );
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * TracingListener is an nsITracableChannel implementation that copies
+ * an incoming stream of data from a request. The data flows through this
+ * nsITracableChannel transparently to the original listener. Once the
+ * response data is fully downloaded, an attempt is made to parse it
+ * as XML, and derive email account data from it.
+ *
+ * @param aBrowser The XUL <browser> the request lives in.
+ * @param aParams An object containing various bits of information.
+ * @param aParams.realName The real name of the person
+ * @param aParams.email The email address the person picked.
+ * @param aParams.searchEngine The search engine associated to that provider.
+ */
+function TracingListener(aBrowser, aParams) {
+ this.chunks = [];
+ this.browser = aBrowser;
+ this.params = aParams;
+ this.oldListener = null;
+}
+
+TracingListener.prototype = {
+ onStartRequest(/* nsIRequest */ aRequest) {
+ this.oldListener.onStartRequest(aRequest);
+ },
+
+ onStopRequest(/* nsIRequest */ aRequest, /* int */ aStatusCode) {
+ const { CreateInBackend } = ChromeUtils.import(
+ "resource:///modules/accountcreation/CreateInBackend.jsm"
+ );
+ const { readFromXML } = ChromeUtils.import(
+ "resource:///modules/accountcreation/readFromXML.jsm"
+ );
+ const { AccountConfig } = ChromeUtils.import(
+ "resource:///modules/accountcreation/AccountConfig.jsm"
+ );
+
+ let newAccount;
+ try {
+ // Construct the downloaded data (we'll assume UTF-8 bytes) into XML.
+ let xml = this.chunks.join("");
+ let bytes = new Uint8Array(xml.length);
+ for (let i = 0; i < xml.length; i++) {
+ bytes[i] = xml.charCodeAt(i);
+ }
+ xml = new TextDecoder().decode(bytes);
+
+ // Attempt to derive email account information.
+ let domParser = new DOMParser();
+ let accountConfig = readFromXML(
+ JXON.build(domParser.parseFromString(xml, "text/xml"))
+ );
+ AccountConfig.replaceVariables(
+ accountConfig,
+ this.params.realName,
+ this.params.email
+ );
+
+ let host = aRequest.getRequestHeader("Host");
+ let providerHostname = new URL("http://" + host).hostname;
+ // Collect telemetry on which provider the new address was purchased from.
+ Services.telemetry.keyedScalarAdd(
+ "tb.account.new_account_from_provisioner",
+ providerHostname,
+ 1
+ );
+
+ // Create the new account in the back end.
+ newAccount = CreateInBackend.createAccountInBackend(accountConfig);
+
+ let tabmail = document.getElementById("tabmail");
+ // Find the tab associated with this browser, and close it.
+ let myTabInfo = tabmail.tabInfo.filter(
+ function (x) {
+ return "browser" in x && x.browser == this.browser;
+ }.bind(this)
+ )[0];
+ tabmail.closeTab(myTabInfo);
+
+ // Trigger the first login to download the folder structure and messages.
+ newAccount.incomingServer.getNewMessages(
+ newAccount.incomingServer.rootFolder,
+ this._msgWindow,
+ null
+ );
+ } catch (e) {
+ // Something went wrong with account set up. Dump the error out to the
+ // error console, reopen the account provisioner tab, and show an error
+ // dialog to the user.
+ console.error("Problem interpreting provider XML:" + e);
+ openAccountProvisionerTab();
+ Services.prompt.alert(window, null, e);
+
+ this.oldListener.onStopRequest(aRequest, aStatusCode);
+ return;
+ }
+
+ // Open the account setup tab and show the success view or an error if we
+ // weren't able to create the new account.
+ openAccountSetupTabWithAccount(
+ newAccount,
+ this.params.realName,
+ this.params.email
+ );
+
+ this.oldListener.onStopRequest(aRequest, aStatusCode);
+ },
+
+ onDataAvailable(
+ /* nsIRequest */ aRequest,
+ /* nsIInputStream */ aStream,
+ /* int */ aOffset,
+ /* int */ aCount
+ ) {
+ // We want to read the stream of incoming data, but we also want
+ // to make sure it gets passed to the original listener. We do this
+ // by passing the input stream through an nsIStorageStream, writing
+ // the data to that stream, and passing it along to the next listener.
+ let binaryInputStream = Cc[
+ "@mozilla.org/binaryinputstream;1"
+ ].createInstance(Ci.nsIBinaryInputStream);
+ let storageStream = Cc["@mozilla.org/storagestream;1"].createInstance(
+ Ci.nsIStorageStream
+ );
+ let outStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
+ Ci.nsIBinaryOutputStream
+ );
+
+ binaryInputStream.setInputStream(aStream);
+
+ // The segment size of 8192 is a little magical - more or less
+ // copied from nsITraceableChannel example code strewn about the
+ // web.
+ storageStream.init(8192, aCount, null);
+ outStream.setOutputStream(storageStream.getOutputStream(0));
+
+ let data = binaryInputStream.readBytes(aCount);
+ this.chunks.push(data);
+
+ outStream.writeBytes(data, aCount);
+ this.oldListener.onDataAvailable(
+ aRequest,
+ storageStream.newInputStream(0),
+ aOffset,
+ aCount
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+};
diff --git a/comm/mail/components/newmailaccount/jar.mn b/comm/mail/components/newmailaccount/jar.mn
new file mode 100644
index 0000000000..23554a9584
--- /dev/null
+++ b/comm/mail/components/newmailaccount/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/newmailaccount/accountProvisioner.xhtml (content/accountProvisioner.xhtml)
+ content/messenger/newmailaccount/accountProvisioner.js (content/accountProvisioner.js)
+ content/messenger/newmailaccount/provisionerCheckout.js (content/provisionerCheckout.js)
+ content/messenger/newmailaccount/uriListener.js (content/uriListener.js)
diff --git a/comm/mail/components/newmailaccount/moz.build b/comm/mail/components/newmailaccount/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/mail/components/newmailaccount/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/mail/components/preferences/actionsshared.js b/comm/mail/components/preferences/actionsshared.js
new file mode 100644
index 0000000000..aae709d957
--- /dev/null
+++ b/comm/mail/components/preferences/actionsshared.js
@@ -0,0 +1,23 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var FILEACTION_SAVE_TO_DISK = 1;
+var FILEACTION_OPEN_INTERNALLY = 2;
+var FILEACTION_OPEN_DEFAULT = 3;
+var FILEACTION_OPEN_CUSTOM = 4;
+function FileAction() {}
+FileAction.prototype = {
+ type: "",
+ extension: "",
+ hasExtension: true,
+ editable: true,
+ smallIcon: "",
+ bigIcon: "",
+ typeName: "",
+ action: "",
+ mimeInfo: null,
+ customHandler: "",
+ handleMode: false,
+};
diff --git a/comm/mail/components/preferences/applicationManager.js b/comm/mail/components/preferences/applicationManager.js
new file mode 100644
index 0000000000..5ebc0f077b
--- /dev/null
+++ b/comm/mail/components/preferences/applicationManager.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// applications.js
+/* globals gGeneralPane */
+
+var gAppManagerDialog = {
+ _removed: [],
+
+ init() {
+ this.handlerInfo = window.arguments[0];
+ var bundle = document.getElementById("appManagerBundle");
+ gGeneralPane._prefsBundle = document.getElementById("bundlePreferences");
+ var description = this.handlerInfo.typeDescription;
+ var key =
+ this.handlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo
+ ? "handleFile"
+ : "handleProtocol";
+ var contentText = bundle.getFormattedString(key, [description]);
+ contentText = bundle.getFormattedString("descriptionApplications", [
+ contentText,
+ ]);
+ document.getElementById("appDescription").textContent = contentText;
+
+ let list = document.getElementById("appList");
+ let listFragment = document.createDocumentFragment();
+ for (let app of this.handlerInfo.possibleApplicationHandlers.enumerate()) {
+ if (!gGeneralPane.isValidHandlerApp(app)) {
+ continue;
+ }
+
+ let item = document.createXULElement("richlistitem");
+ item.classList.add("typeLabel");
+ listFragment.append(item);
+ item.app = app;
+
+ let image = document.createElement("img");
+ image.classList.add("typeIcon");
+ image.setAttribute("src", gGeneralPane._getIconURLForHandlerApp(app));
+ image.setAttribute("alt", "");
+ item.appendChild(image);
+
+ let label = document.createElement("span");
+ label.classList.add("typeDescription");
+ label.textContent = app.name;
+ item.appendChild(label);
+ }
+ list.append(listFragment);
+
+ // Triggers onSelect which populates label.
+ list.selectedIndex = 0;
+ },
+
+ onOK() {
+ if (!this._removed.length) {
+ // return early to avoid calling the |store| method.
+ return;
+ }
+
+ for (var i = 0; i < this._removed.length; ++i) {
+ this.handlerInfo.removePossibleApplicationHandler(this._removed[i]);
+ }
+
+ this.handlerInfo.store();
+ },
+
+ remove() {
+ var list = document.getElementById("appList");
+ this._removed.push(list.selectedItem.app);
+ var index = list.selectedIndex;
+ list.selectedItem.remove();
+ if (list.getRowCount() == 0) {
+ // The list is now empty, make the bottom part disappear
+ document.getElementById("appDetails").hidden = true;
+ } else {
+ // Select the item at the same index, if we removed the last
+ // item of the list, select the previous item
+ if (index == list.getRowCount()) {
+ --index;
+ }
+ list.selectedIndex = index;
+ }
+ },
+
+ onSelect() {
+ var list = document.getElementById("appList");
+ if (!list.selectedItem) {
+ document.getElementById("remove").disabled = true;
+ return;
+ }
+ document.getElementById("remove").disabled = false;
+ var app = list.selectedItem.app;
+ var address = "";
+ if (app instanceof Ci.nsILocalHandlerApp) {
+ address = app.executable.path;
+ } else if (app instanceof Ci.nsIWebHandlerApp) {
+ address = app.uriTemplate;
+ } else if (app instanceof Ci.nsIWebContentHandlerInfo) {
+ address = app.uri;
+ }
+ document.getElementById("appLocation").value = address;
+ var bundle = document.getElementById("appManagerBundle");
+ var appType =
+ app instanceof Ci.nsILocalHandlerApp
+ ? "descriptionLocalApp"
+ : "descriptionWebApp";
+ document.getElementById("appType").value = bundle.getString(appType);
+ },
+};
+
+document.addEventListener("dialogaccept", () => gAppManagerDialog.onOK());
diff --git a/comm/mail/components/preferences/applicationManager.xhtml b/comm/mail/components/preferences/applicationManager.xhtml
new file mode 100644
index 0000000000..10c9346c0f
--- /dev/null
+++ b/comm/mail/components/preferences/applicationManager.xhtml
@@ -0,0 +1,76 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/preferences/applications.css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="gAppManagerDialog.init();"
+ data-l10n-id="app-manager-window-dialog2"
+ style="min-width: 30em"
+>
+ <dialog id="appManager" buttons="accept,cancel">
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/general.js" />
+ <script src="chrome://messenger/content/preferences/applicationManager.js" />
+
+ <commandset id="appManagerCommandSet">
+ <command
+ id="cmd_delete"
+ oncommand="gAppManagerDialog.remove();"
+ disabled="true"
+ />
+ </commandset>
+
+ <keyset id="appManagerKeyset">
+ <key id="delete" keycode="VK_DELETE" command="cmd_delete" />
+ </keyset>
+
+ <stringbundle
+ id="appManagerBundle"
+ src="chrome://messenger/locale/preferences/applicationManager.properties"
+ />
+ <stringbundle
+ id="bundlePreferences"
+ src="chrome://messenger/locale/preferences/preferences.properties"
+ />
+
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/application-manager.ftl"
+ />
+ </linkset>
+
+ <description id="appDescription" />
+ <separator class="thin" />
+ <hbox flex="1">
+ <richlistbox
+ id="appList"
+ onselect="gAppManagerDialog.onSelect();"
+ flex="1"
+ style="min-height: 150px"
+ />
+ <vbox>
+ <button
+ id="remove"
+ data-l10n-id="remove-app-button"
+ command="cmd_delete"
+ />
+ <spacer flex="1" />
+ </vbox>
+ </hbox>
+ <vbox id="appDetails">
+ <separator class="thin" />
+ <label id="appType" />
+ <html:input id="appLocation" type="text" readonly="readonly" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/attachmentReminder.js b/comm/mail/components/preferences/attachmentReminder.js
new file mode 100644
index 0000000000..da01364b4e
--- /dev/null
+++ b/comm/mail/components/preferences/attachmentReminder.js
@@ -0,0 +1,100 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var gAttachmentReminderOptionsDialog = {
+ keywordListBox: null,
+
+ init() {
+ this.keywordListBox = document.getElementById("keywordList");
+ this.buildKeywordList();
+ },
+
+ buildKeywordList() {
+ var keywordsInCsv = Services.prefs.getComplexValue(
+ "mail.compose.attachment_reminder_keywords",
+ Ci.nsIPrefLocalizedString
+ );
+ if (!keywordsInCsv) {
+ return;
+ }
+ keywordsInCsv = keywordsInCsv.data;
+ var keywordsInArr = keywordsInCsv.split(",");
+ for (let i = 0; i < keywordsInArr.length; i++) {
+ if (keywordsInArr[i]) {
+ this.keywordListBox.appendItem(keywordsInArr[i], keywordsInArr[i]);
+ }
+ }
+ if (keywordsInArr.length) {
+ this.keywordListBox.selectedIndex = 0;
+ }
+ },
+
+ async addKeyword() {
+ var input = { value: "" }; // Default to empty.
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: "new-keyword-title" },
+ { id: "new-keyword-label" },
+ ]);
+
+ var ok = Services.prompt.prompt(window, title, message, input, null, {
+ value: 0,
+ });
+ if (ok && input.value) {
+ let newKey = this.keywordListBox.appendItem(input.value, input.value);
+ this.keywordListBox.ensureElementIsVisible(newKey);
+ this.keywordListBox.selectItem(newKey);
+ }
+ },
+
+ async editKeyword() {
+ if (this.keywordListBox.selectedIndex < 0) {
+ return;
+ }
+ var keywordToEdit = this.keywordListBox.selectedItem;
+ var input = { value: keywordToEdit.getAttribute("value") };
+
+ let [title, message] = await document.l10n.formatValues([
+ { id: "edit-keyword-title" },
+ { id: "edit-keyword-label" },
+ ]);
+
+ var ok = Services.prompt.prompt(window, title, message, input, null, {
+ value: 0,
+ });
+ if (ok && input.value) {
+ this.keywordListBox.selectedItem.value = input.value;
+ this.keywordListBox.selectedItem.label = input.value;
+ }
+ },
+
+ removeKeyword() {
+ if (this.keywordListBox.selectedIndex < 0) {
+ return;
+ }
+ this.keywordListBox.selectedItem.remove();
+ },
+
+ saveKeywords() {
+ var keywordList = "";
+ for (var i = 0; i < this.keywordListBox.getRowCount(); i++) {
+ keywordList += this.keywordListBox
+ .getItemAtIndex(i)
+ .getAttribute("value");
+ if (i != this.keywordListBox.getRowCount() - 1) {
+ keywordList += ",";
+ }
+ }
+
+ Services.prefs.setStringPref(
+ "mail.compose.attachment_reminder_keywords",
+ keywordList
+ );
+ },
+};
+
+document.addEventListener("dialogaccept", () =>
+ gAttachmentReminderOptionsDialog.saveKeywords()
+);
diff --git a/comm/mail/components/preferences/attachmentReminder.xhtml b/comm/mail/components/preferences/attachmentReminder.xhtml
new file mode 100644
index 0000000000..7f2f7b19ce
--- /dev/null
+++ b/comm/mail/components/preferences/attachmentReminder.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="attachment-reminder-window"
+ onload="gAttachmentReminderOptionsDialog.init();"
+ style="min-width: 38em"
+>
+ <dialog id="attachmentReminderOptionsDialog" dlgbuttons="accept,cancel">
+ <script src="chrome://messenger/content/preferences/attachmentReminder.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/attachment-reminder.ftl"
+ />
+ </linkset>
+
+ <vbox>
+ <label control="keywordList" data-l10n-id="attachment-reminder-label" />
+ <hbox>
+ <richlistbox
+ id="keywordList"
+ flex="1"
+ ondblclick="gAttachmentReminderOptionsDialog.editKeyword();"
+ />
+ <vbox>
+ <button
+ data-l10n-id="keyword-new-button"
+ oncommand="gAttachmentReminderOptionsDialog.addKeyword();"
+ />
+ <button
+ data-l10n-id="keyword-edit-button"
+ oncommand="gAttachmentReminderOptionsDialog.editKeyword();"
+ />
+ <button
+ data-l10n-id="keyword-remove-button"
+ oncommand="gAttachmentReminderOptionsDialog.removeKeyword();"
+ />
+ </vbox>
+ </hbox>
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/chat.inc.xhtml b/comm/mail/components/preferences/chat.inc.xhtml
new file mode 100644
index 0000000000..22b0cd3c4a
--- /dev/null
+++ b/comm/mail/components/preferences/chat.inc.xhtml
@@ -0,0 +1,198 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ <script src="chrome://messenger/content/preferences/chat.js"/>
+ <script src="chrome://messenger/content/preferences/messagestyle.js"/>
+
+ <stringbundle id="themesBundle"
+ src="chrome://messenger/locale/preferences/messagestyle.properties"/>
+ <html:template id="paneChat">
+ <hbox id="chatPaneCategory"
+ class="subcategory"
+ data-category="paneChat">
+ <html:h1 data-l10n-id="chat-pane-header"/>
+ </hbox>
+
+ <html:div data-category="paneChat">
+ <html:fieldset data-category="paneChat">
+ <html:legend data-l10n-id="chat-status-title"></html:legend>
+ <!-- Startup -->
+ <hbox align="center">
+ <label id="chatStartupAction"
+ data-l10n-id="startup-label"
+ control="messengerStartupAction"/>
+ <hbox>
+ <menulist id="messengerStartupAction" preference="messenger.startup.action">
+ <menupopup>
+ <menuitem data-l10n-id="offline-label" value="0"/>
+ <menuitem data-l10n-id="auto-connect-label" value="1"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <separator/>
+
+ <!-- Status -->
+ <hbox align="center">
+ <checkbox id="reportIdle" data-l10n-id="idle-label"
+ preference="messenger.status.reportIdle"/>
+ <html:input id="timeBeforeAway" type="number"
+ class="size2 idle-reporting-enabled"
+ min="1" max="720"
+ preference="messenger.status.timeBeforeIdle"/>
+ <label data-l10n-id="idle-time-label" control="timeBeforeAway"/>
+ </hbox>
+ <vbox class="indent">
+ <hbox>
+ <checkbox id="autoAway"
+ data-l10n-id="away-message-label"
+ class="idle-reporting-enabled"
+ preference="messenger.status.awayWhenIdle"/>
+ <spacer flex="1"/>
+ </hbox>
+ <html:input id="defaultIdleAwayMessage"
+ type="text"
+ class="idle-reporting-enabled indent"
+ preference="messenger.status.defaultIdleAwayMessage"/>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneChat">
+ <html:fieldset data-category="paneChat">
+ <html:legend data-l10n-id="chat-notifications-title"></html:legend>
+ <hbox>
+ <checkbox id="sendTyping"
+ data-l10n-id="send-typing-label"
+ preference="purple.conversations.im.send_typing"/>
+ <spacer flex="1"/>
+ </hbox>
+
+ <separator/>
+
+ <hbox>
+ <label data-l10n-id="notification-label"/>
+ </hbox>
+ <hbox>
+ <checkbox id="desktopChatNotifications"
+ data-l10n-id="show-notification-label"
+ preference="mail.chat.show_desktop_notifications"/>
+ <hbox>
+ <menulist id="chatNotificationInfo" preference="mail.chat.notification_info">
+ <menupopup>
+ <menuitem data-l10n-id="notification-all" value="0"/>
+ <menuitem data-l10n-id="notification-name" value="1"/>
+ <menuitem data-l10n-id="notification-empty" value="2"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <checkbox id="getAttention"
+ preference="messenger.options.getAttentionOnNewMessages"
+ data-l10n-id="notification-type-label"/>
+ <hbox align="center">
+ <checkbox id="chatNotification"
+ data-l10n-id="chat-play-sound-label"
+ preference="mail.chat.play_sound"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="playChatSound"
+ data-l10n-id="chat-play-button"
+ oncommand="gChatPane.previewSound();"/>
+ </hbox>
+ <radiogroup id="chatSoundType"
+ class="indent"
+ orient="vertical"
+ preference="mail.chat.play_sound.type"
+ aria-labelledby="chatNotification">
+ <hbox>
+ <radio id="chatSoundSystemSound"
+ data-l10n-id="chat-system-sound-label"
+ value="0"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <radio id="chatSoundCustom"
+ data-l10n-id="chat-custom-sound-label"
+ value="1"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input id="chatSoundUrlLocation"
+ type="text"
+ class="input-filefield indent"
+ readonly="readonly"
+ preference="mail.chat.play_sound.url"
+ preference-editable="true"
+ aria-labelledby="chatSoundCustom"/>
+ <button is="highlightable-button" id="browseForChatSound"
+ data-l10n-id="chat-browse-sound-button"
+ oncommand="gChatPane.browseForSoundFile();"/>
+ </hbox>
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="chatPaneStylingCategory"
+ class="subcategory"
+ data-category="paneChat">
+ <html:h1 data-l10n-id="chat-pane-styling-header"/>
+ </hbox>
+
+ <html:div data-category="paneChat">
+ <html:fieldset data-category="paneChat">
+ <separator/>
+ <hbox align="center">
+ <label data-l10n-id="theme-label" control="messagestyle-themename"/>
+ <hbox flex="1">
+ <menulist id="messagestyle-themename"
+ flex="1" crop="end"
+ preference="messenger.options.messagesStyle.theme"
+ onselect="previewObserver.currentThemeChanged();">
+ <menupopup id="theme-menupopup">
+ <menuitem id="mail-menuitem"
+ data-l10n-id="style-mail"
+ value="mail"/>
+ <menuitem id="bubbles-menuitem"
+ data-l10n-id="style-bubbles"
+ value="bubbles"/>
+ <menuitem id="dark-menuitem"
+ data-l10n-id="style-dark"
+ value="dark"/>
+ <menuitem id="papersheets-menuitem"
+ data-l10n-id="style-paper"
+ value="papersheets"/>
+ <menuitem id="simple-menuitem"
+ data-l10n-id="style-simple"
+ value="simple"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <separator class="thin"/>
+ <hbox align="start">
+ <label data-l10n-id="preview-label"/>
+ <tooltip id="aHTMLTooltip" page="true"/>
+ <vbox id="previewBox" flex="1">
+ <vbox id="noPreviewScreen" flex="1" align="center" pack="center">
+ <hbox id="noPreviewBox" align="start">
+ <vbox id="noPreviewInnerBox" flex="1">
+ <label id="noPreviewTitle" data-l10n-id="no-preview-label"/>
+ <description id="noAccountDesc"
+ data-l10n-id="no-preview-description"/>
+ </vbox>
+ </hbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ <hbox align="center">
+ <label data-l10n-id="chat-variant-label" control="themevariant"/>
+ <hbox>
+ <menulist id="themevariant"
+ preference="messenger.options.messagesStyle.variant"
+ onselect="previewObserver.currentVariantChanged();"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ </html:template>
diff --git a/comm/mail/components/preferences/chat.js b/comm/mail/components/preferences/chat.js
new file mode 100644
index 0000000000..e6e7b660c8
--- /dev/null
+++ b/comm/mail/components/preferences/chat.js
@@ -0,0 +1,193 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from preferences.js */
+// messagestyle.js
+/* globals previewObserver */
+
+Preferences.addAll([
+ { id: "messenger.startup.action", type: "int" },
+ { id: "purple.conversations.im.send_typing", type: "bool" },
+ { id: "messenger.status.reportIdle", type: "bool" },
+ { id: "messenger.status.timeBeforeIdle", type: "int" },
+ { id: "messenger.status.awayWhenIdle", type: "bool" },
+ { id: "messenger.status.defaultIdleAwayMessage", type: "wstring" },
+ { id: "purple.logging.log_chats", type: "bool" },
+ { id: "purple.logging.log_ims", type: "bool" },
+ { id: "purple.logging.log_system", type: "bool" },
+ { id: "mail.chat.show_desktop_notifications", type: "bool" },
+ { id: "mail.chat.notification_info", type: "int" },
+ { id: "mail.chat.play_sound", type: "bool" },
+ { id: "mail.chat.play_sound.type", type: "int" },
+ { id: "mail.chat.play_sound.url", type: "string" },
+ { id: "messenger.options.getAttentionOnNewMessages", type: "bool" },
+ { id: "messenger.options.messagesStyle.theme", type: "string" },
+ { id: "messenger.options.messagesStyle.variant", type: "string" },
+]);
+
+var gChatPane = {
+ init() {
+ this.updateDisabledState();
+ this.updateMessageDisabledState();
+ this.updatePlaySound();
+ this.initPreview();
+
+ let element = document.getElementById("timeBeforeAway");
+ Preferences.addSyncFromPrefListener(
+ element,
+ () =>
+ Preferences.get("messenger.status.timeBeforeIdle")
+ .valueFromPreferences / 60
+ );
+ Preferences.addSyncToPrefListener(element, element => element.value * 60);
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("chatSoundUrlLocation"),
+ () => this.readSoundLocation()
+ );
+ },
+
+ initPreview() {
+ // We add this browser only when really necessary.
+ let previewBox = document.getElementById("previewBox");
+ if (previewBox.querySelector("browser")) {
+ return;
+ }
+
+ document.getElementById("noPreviewScreen").hidden = true;
+ let browser = document.createXULElement("browser", {
+ is: "conversation-browser",
+ });
+ browser.setAttribute("id", "previewbrowser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("flex", "1");
+ browser.setAttribute("tooltip", "aHTMLTooltip");
+ previewBox.appendChild(browser);
+ previewObserver.load();
+ },
+
+ updateDisabledState() {
+ let checked = Preferences.get("messenger.status.reportIdle").value;
+ document.querySelectorAll(".idle-reporting-enabled").forEach(e => {
+ e.disabled = !checked;
+ });
+ },
+
+ updateMessageDisabledState() {
+ let textbox = document.getElementById("defaultIdleAwayMessage");
+ textbox.toggleAttribute(
+ "disabled",
+ !Preferences.get("messenger.status.awayWhenIdle").value
+ );
+ },
+
+ convertURLToLocalFile(aFileURL) {
+ // convert the file url into a nsIFile
+ if (aFileURL) {
+ return Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(aFileURL);
+ }
+ return null;
+ },
+
+ readSoundLocation() {
+ let chatSoundUrlLocation = document.getElementById("chatSoundUrlLocation");
+ chatSoundUrlLocation.value = Preferences.get(
+ "mail.chat.play_sound.url"
+ ).value;
+ if (chatSoundUrlLocation.value) {
+ chatSoundUrlLocation.label = this.convertURLToLocalFile(
+ chatSoundUrlLocation.value
+ ).leafName;
+ chatSoundUrlLocation.style.backgroundImage =
+ "url(moz-icon://" + chatSoundUrlLocation.label + "?size=16)";
+ }
+ },
+
+ previewSound() {
+ let sound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound);
+
+ let soundLocation =
+ document.getElementById("chatSoundType").value == 1
+ ? document.getElementById("chatSoundUrlLocation").value
+ : "";
+
+ // This should be in sync with the code in nsStatusBarBiffManager::PlayBiffSound.
+ if (!soundLocation.startsWith("file://")) {
+ if (Services.appinfo.OS == "Darwin") {
+ // OS X
+ sound.beep();
+ } else {
+ sound.playEventSound(Ci.nsISound.EVENT_NEW_MAIL_RECEIVED);
+ }
+ } else {
+ sound.play(Services.io.newURI(soundLocation));
+ }
+ },
+
+ browseForSoundFile() {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ // If we already have a sound file, then use the path for that sound file
+ // as the initial path in the dialog.
+ let localFile = this.convertURLToLocalFile(
+ document.getElementById("chatSoundUrlLocation").value
+ );
+ if (localFile) {
+ fp.displayDirectory = localFile.parent;
+ }
+
+ // XXX todo, persist the last sound directory and pass it in
+ fp.init(
+ window,
+ document
+ .getElementById("bundlePreferences")
+ .getString("soundFilePickerTitle"),
+ Ci.nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterAudio);
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK) {
+ return;
+ }
+
+ // convert the nsIFile into a nsIFile url
+ Preferences.get("mail.chat.play_sound.url").value = fp.fileURL.spec;
+ this.readSoundLocation(); // XXX We shouldn't have to be doing this by hand
+ this.updatePlaySound();
+ });
+ },
+
+ updatePlaySound() {
+ let soundsEnabled = Preferences.get("mail.chat.play_sound").value;
+ let soundTypeValue = Preferences.get("mail.chat.play_sound.type").value;
+ let soundUrlLocation = Preferences.get("mail.chat.play_sound.url").value;
+ let soundDisabled = !soundsEnabled || soundTypeValue != 1;
+
+ document.getElementById("chatSoundType").disabled = !soundsEnabled;
+ document.getElementById("chatSoundUrlLocation").disabled = soundDisabled;
+ document.getElementById("browseForChatSound").disabled = soundDisabled;
+ document.getElementById("playChatSound").disabled =
+ !soundsEnabled || (!soundUrlLocation && soundTypeValue != 0);
+ },
+};
+
+Preferences.get("messenger.status.reportIdle").on(
+ "change",
+ gChatPane.updateDisabledState
+);
+Preferences.get("messenger.status.awayWhenIdle").on(
+ "change",
+ gChatPane.updateMessageDisabledState
+);
+Preferences.get("mail.chat.play_sound").on("change", gChatPane.updatePlaySound);
+Preferences.get("mail.chat.play_sound.type").on(
+ "change",
+ gChatPane.updatePlaySound
+);
diff --git a/comm/mail/components/preferences/colors.js b/comm/mail/components/preferences/colors.js
new file mode 100644
index 0000000000..cd45f8ca44
--- /dev/null
+++ b/comm/mail/components/preferences/colors.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "browser.display.document_color_use", type: "int" },
+ { id: "browser.anchor_color", type: "string" },
+ { id: "browser.visited_color", type: "string" },
+ { id: "browser.underline_anchors", type: "bool" },
+ { id: "browser.display.foreground_color", type: "string" },
+ { id: "browser.display.background_color", type: "string" },
+ { id: "browser.display.use_system_colors", type: "bool" },
+]);
diff --git a/comm/mail/components/preferences/colors.xhtml b/comm/mail/components/preferences/colors.xhtml
new file mode 100644
index 0000000000..827078ba4c
--- /dev/null
+++ b/comm/mail/components/preferences/colors.xhtml
@@ -0,0 +1,90 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="colors-dialog-window2">
+ <dialog id="ColorsDialog"
+ dlgbuttons="accept,cancel">
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/colors.ftl"/>
+ </linkset>
+
+ <hbox>
+ <hbox flex="1">
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="colors-dialog-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="text-color-label" control="foregroundtextmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="foregroundtextmenu" preference="browser.display.foreground_color"/>
+ </hbox>
+ <hbox align="center" style="margin-top: 5px">
+ <label data-l10n-id="background-color-label" control="backgroundmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="backgroundmenu" preference="browser.display.background_color"/>
+ </hbox>
+ <separator class="thin"/>
+ <hbox align="center">
+ <checkbox id="browserUseSystemColors" data-l10n-id="use-system-colors"
+ preference="browser.display.use_system_colors"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+ </hbox>
+ <hbox flex="1">
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="colors-link-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="link-color-label" control="unvisitedlinkmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="unvisitedlinkmenu" preference="browser.anchor_color"/>
+ </hbox>
+ <hbox align="center" style="margin-top: 5px">
+ <label data-l10n-id="visited-link-color-label" control="visitedlinkmenu"/>
+ <spacer flex="1"/>
+ <html:input type="color" id="visitedlinkmenu" preference="browser.visited_color"/>
+ </hbox>
+ <separator class="thin"/>
+ <hbox align="center">
+ <checkbox id="browserUnderlineAnchors" data-l10n-id="underline-link-checkbox"
+ preference="browser.underline_anchors"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+ </hbox>
+ </hbox>
+#ifdef XP_WIN
+ <vbox align="start">
+#else
+ <vbox>
+#endif
+ <label data-l10n-id="override-color-label"
+ control="useDocumentColors"/>
+ <menulist id="useDocumentColors"
+ preference="browser.display.document_color_use">
+ <menupopup>
+ <menuitem data-l10n-id="override-color-always"
+ value="2" id="documentColorAlways"/>
+ <menuitem data-l10n-id="override-color-auto"
+ value="0" id="documentColorAutomatic"/>
+ <menuitem data-l10n-id="override-color-never"
+ value="1" id="documentColorNever"/>
+ </menupopup>
+ </menulist>
+ </vbox>
+
+ <script src="chrome://global/content/preferencesBindings.js"/>
+ <script src="chrome://messenger/content/preferences/colors.js"/>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/compose.inc.xhtml b/comm/mail/components/preferences/compose.inc.xhtml
new file mode 100644
index 0000000000..3ba7063084
--- /dev/null
+++ b/comm/mail/components/preferences/compose.inc.xhtml
@@ -0,0 +1,354 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ <script src="chrome://messenger/content/preferences/compose.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://messenger/content/preferences/downloads.js"/>
+ <script src="chrome://communicator/content/utilityOverlay.js"/>
+
+ <stringbundle id="bundle_addressBook" src="chrome://messenger/locale/addressbook/addressBook.properties"/>
+ <html:template id="paneCompose">
+ <hbox id="compositionMainCategory"
+ class="subcategory"
+ data-category="paneCompose">
+ <html:h1 data-l10n-id="composition-category-header"/>
+ </hbox>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <separator class="thin"/>
+ <hbox align="center">
+ <label data-l10n-id="forward-label" control="forwardMessageMode"/>
+ <hbox>
+ <menulist id="forwardMessageMode" preference="mail.forward_message_mode">
+ <menupopup>
+ <menuitem value="2" data-l10n-id="inline-label"/>
+ <menuitem value="0" data-l10n-id="as-attachment-label"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator orient="vertical" class="thin"/>
+ <checkbox id="addExtension" preference="mail.forward_add_extension"
+ data-l10n-id="extension-label"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center" pack="start">
+ <checkbox id="autoSave" preference="mail.compose.autosave"
+ data-l10n-id="auto-save-label"/>
+ <html:input id="autoSaveInterval" type="number" class="size2"
+ min="1" max="35790"
+ preference="mail.compose.autosaveinterval"
+ aria-labelledby="autoSave autoSaveInterval autoSaveEnd"/>
+ <label id="autoSaveEnd" data-l10n-id="auto-save-end"/>
+ </hbox>
+ <hbox>
+ <checkbox id="mailWarnOnSendAccelKey"
+ data-l10n-id="warn-on-send-accel-key"
+ preference="mail.warn_on_send_accel_key"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <checkbox id="addLinkPreviews"
+ data-l10n-id="add-link-previews"
+ preference="mail.compose.add_link_preview"/>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <html:legend data-l10n-id="composition-spelling-title"></html:legend>
+ <hbox>
+ <checkbox id="spellCheckBeforeSend"
+ data-l10n-id="spellcheck-label"
+ preference="mail.SpellCheckBeforeSend"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <checkbox id="inlineSpellCheck"
+ data-l10n-id="spellcheck-inline-label"
+ preference="mail.spellcheck.inline"/>
+ <spacer flex="1"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <vbox flex="1">
+ <label data-l10n-id="language-popup-label" control="dictionaryList"/>
+
+ <html:ul id="dictionaryList" class="indent">
+ <html:template id="dictionaryListItem">
+ <html:li>
+ <html:label>
+ <html:input type="checkbox" />
+ <html:span class="checkbox-label"></html:span>
+ </html:label>
+ </html:li>
+ </html:template>
+ </html:ul>
+
+ <label id="downloadDictionaries" class="text-link"
+ onclick="if (event.button == 0) { openDictionaryList('tab'); }"
+ data-l10n-id="download-dictionaries-link"/>
+
+ <spacer flex="1"/>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <html:legend data-l10n-id="compose-html-style-title"></html:legend>
+ <hbox>
+ <vbox flex="1">
+ <hbox align="center">
+ <label control="FontSelect" data-l10n-id="font-label"/>
+ <hbox flex="1">
+ <menulist id="FontSelect" preference="msgcompose.font_face"
+ sizetopopup="pref" crop="center" flex="1">
+ <menupopup>
+ <menuitem value="" label="&fontVarWidth.label;"/>
+ <menuitem value="monospace" label="&fontFixedWidth.label;"/>
+ <menuseparator/>
+ <menuitem value="Helvetica, Arial, sans-serif" label="&fontHelvetica.label;"/>
+ <menuitem value="Times New Roman, Times, serif" label="&fontTimes.label;"/>
+ <menuitem value="Courier New, Courier, monospace" label="&fontCourier.label;"/>
+ <menuseparator/>
+ </menupopup>
+ </menulist>
+ </hbox>
+
+ <label control="fontSizeSelect" data-l10n-id="font-size-label"/>
+ <hbox>
+ <menulist id="fontSizeSelect" preference="msgcompose.font_size" value="3">
+ <menupopup>
+ <menuitem value="1" label="&size-tinyCmd.label;"/>
+ <menuitem value="2" label="&size-smallCmd.label;"/>
+ <menuitem value="3" label="&size-mediumCmd.label;"/>
+ <menuitem value="4" label="&size-largeCmd.label;"/>
+ <menuitem value="5" label="&size-extraLargeCmd.label;"/>
+ <menuitem value="6" label="&size-hugeCmd.label;"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <checkbox id="useReaderDefaults"
+ data-l10n-id="default-colors-label"
+ preference="msgcompose.default_colors"/>
+ </hbox>
+ <hbox align="center" class="indent">
+ <label id="textColorLabel"
+ control="textColorButton"
+ data-l10n-id="font-color-label"/>
+ <html:input type="color" id="textColorButton" preference="msgcompose.text_color"/>
+ <separator orient="vertical" class="thin"/>
+ <label id="backgroundColorLabel"
+ control="backgroundColorButton"
+ data-l10n-id="bg-color-label"/>
+ <html:input type="color" id="backgroundColorButton" preference="msgcompose.background_color"/>
+ </hbox>
+ </vbox>
+ <vbox>
+ <spacer flex="1"/>
+ <button is="highlightable-button"
+ data-l10n-id="restore-html-label"
+ oncommand="gComposePane.restoreHTMLDefaults();"/>
+ </vbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <checkbox id="defaultToParagraph"
+ data-l10n-id="default-format-label"
+ preference="mail.compose.default_to_paragraph"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <html:legend data-l10n-id="compose-send-format-title"></html:legend>
+ <radiogroup class="indent"
+ preference="mail.default_send_format">
+ <radio value="0"
+ aria-describedby="composeSendAutomaticDescription"
+ data-l10n-id="compose-send-automatic-option" />
+ <description id="composeSendAutomaticDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-automatic-description" />
+ <radio value="3"
+ aria-describedby="composeSendBothDescription"
+ data-l10n-id="compose-send-both-option" />
+ <description id="composeSendBothDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-both-description" />
+ <radio value="2"
+ aria-describedby="composeSendHTMLDescription"
+ data-l10n-id="compose-send-html-option" />
+ <description id="composeSendHTMLDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-html-description" />
+ <radio value="1"
+ aria-describedby="composeSendPlainDescription"
+ data-l10n-id="compose-send-plain-option" />
+ <description id="composeSendPlainDescription"
+ class="indent tip-caption"
+ data-l10n-id="compose-send-plain-description" />
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="compositionAddressingCategory"
+ class="subcategory"
+ data-category="paneCompose">
+ <html:h1 data-l10n-id="composition-addressing-header"/>
+ </hbox>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <!-- Address Autocomplete -->
+ <separator class="thin"/>
+
+ <description data-l10n-id="autocomplete-description"/>
+
+ <hbox align="center">
+ <checkbox id="addressingAutocomplete" data-l10n-id="ab-label"
+ preference="mail.enable_autocomplete"/>
+ </hbox>
+
+ <hbox align="center">
+ <checkbox id="autocompleteLDAP" data-l10n-id="directories-label"
+ preference="ldap_2.autoComplete.useDirectory"/>
+ <hbox flex="1">
+ <menulist is="menulist-addrbooks" id="directoriesList"
+ aria-labelledby="autocompleteLDAP"
+ preference="ldap_2.autoComplete.directoryServer"
+ data-l10n-id="directories-none-label"
+ data-l10n-attrs="none"
+ remoteonly="true"
+ flex="1"/>
+ </hbox>
+ <button is="highlightable-button" id="editButton"
+ data-l10n-id="edit-directories-label"
+ oncommand="gComposePane.editDirectories();"
+ preference="pref.ldap.disable_button.edit_directories"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center" pack="start">
+ <checkbox id="emailCollectionOutgoing" data-l10n-id="email-picker-label"
+ preference="mail.collect_email_address_outgoing"/>
+ <hbox flex="1">
+ <menulist is="menulist-addrbooks" id="localDirectoriesList"
+ aria-labelledby="emailCollectionOutgoing"
+ preference="mail.collect_addressbook"
+ localonly="true"
+ writable="true"
+ flex="1"/>
+ </hbox>
+ </hbox>
+
+ <hbox align="center" pack="start">
+ <label data-l10n-id="default-directory-label"
+ control="defaultStartupDirList"/>
+ <hbox flex="1">
+ <menulist is="menulist-addrbooks" id="defaultStartupDirList"
+ oncommand="gComposePane.setDefaultStartupDir(this.value);"
+ data-l10n-id="default-last-label"
+ data-l10n-attrs="none"
+ alladdressbooks="true"
+ mailinglists="true"
+ flex="1"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="compositionAttachmentsCategory"
+ class="subcategory"
+ data-category="paneCompose">
+ <html:h1 data-l10n-id="composition-attachments-header"/>
+ </hbox>
+
+ <html:div data-category="paneCompose">
+ <html:fieldset data-category="paneCompose">
+ <hbox align="center">
+ <checkbox id="attachment_reminder_label"
+ data-l10n-id="attachment-label"
+ preference="mail.compose.attachment_reminder"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="attachment_reminder_button"
+ data-l10n-id="attachment-options-label"
+ oncommand="gComposePane.attachmentReminderOptionsDialog();"
+ search-l10n-ids="
+ attachment-reminder-window.title,
+ attachment-reminder-label,
+ keyword-new-button.label,
+ keyword-edit-button.label,
+ keyword-remove-button.label,
+ new-keyword-title,
+ new-keyword-label,
+ edit-keyword-title,
+ edit-keyword-label"/>
+ </hbox>
+ </hbox>
+ <vbox id="cloudFileBox">
+ <hbox id="cloudFileToggleAndThreshold" align="center">
+ <checkbox id="enableThreshold"
+ data-l10n-id="enable-cloud-share"
+ preference="mail.compose.big_attachments.notify"/>
+ <html:input id="cloudFileThreshold" type="number" min="0" class="size3"
+ preference="mail.compose.big_attachments.threshold_kb"/>
+ <label control="cloudFileThreshold" data-l10n-id="cloud-share-size"/>
+ </hbox>
+ <hbox style="height: 480px; flex: 1 auto;">
+ <vbox id="provider-listing">
+ <richlistbox id="cloudFileView" orient="vertical"
+ seltype="single"
+ onoverflow="gCloudFile.onListOverflow();"
+ onselect="gCloudFile.onSelectionChanged(event);">
+ </richlistbox>
+ <vbox id="addCloudFileAccountButtons">
+ </vbox>
+ <hbox>
+ <menulist id="addCloudFileAccount"
+ hidden="true"
+ data-l10n-id="add-cloud-account"
+ data-l10n-attrs="defaultlabel"
+ oncommand="gCloudFile.addCloudFileAccount(this.value);">
+ <menupopup id="addCloudFileAccountListItems"/>
+ </menulist>
+ </hbox>
+ <button is="highlightable-button" id="removeCloudFileAccount"
+ disabled="true"
+ data-l10n-id="remove-cloud-account"
+ oncommand="gCloudFile.removeCloudFileAccount();"/>
+ <label is="text-link"
+ id="moreProvidersLink"
+ href="https://addons.thunderbird.net/thunderbird/tag/filelink"
+ data-l10n-id="find-cloud-providers"/>
+ </vbox>
+ <separator class="thin" orient="vertical"/>
+ <vbox flex="1">
+ <vbox id="cloudFileDefaultPanel" flex="1">
+ <description data-l10n-id="cloud-account-description"/>
+ </vbox>
+ <vbox id="cloudFileSettingsWrapper" flex="1">
+ </vbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ </html:template>
diff --git a/comm/mail/components/preferences/compose.js b/comm/mail/components/preferences/compose.js
new file mode 100644
index 0000000000..7d9acf0622
--- /dev/null
+++ b/comm/mail/components/preferences/compose.js
@@ -0,0 +1,776 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from preferences.js */
+
+var { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+);
+
+// CloudFile account tools used by gCloudFile.
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+var { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+Preferences.addAll([
+ { id: "mail.forward_message_mode", type: "int" },
+ { id: "mail.forward_add_extension", type: "bool" },
+ { id: "mail.SpellCheckBeforeSend", type: "bool" },
+ { id: "mail.spellcheck.inline", type: "bool" },
+ { id: "mail.warn_on_send_accel_key", type: "bool" },
+ { id: "mail.compose.autosave", type: "bool" },
+ { id: "mail.compose.autosaveinterval", type: "int" },
+ { id: "mail.enable_autocomplete", type: "bool" },
+ { id: "ldap_2.autoComplete.useDirectory", type: "bool" },
+ { id: "ldap_2.autoComplete.directoryServer", type: "string" },
+ { id: "pref.ldap.disable_button.edit_directories", type: "bool" },
+ { id: "mail.collect_email_address_outgoing", type: "bool" },
+ { id: "mail.collect_addressbook", type: "string" },
+ { id: "spellchecker.dictionary", type: "unichar" },
+ { id: "msgcompose.default_colors", type: "bool" },
+ { id: "msgcompose.font_face", type: "string" },
+ { id: "msgcompose.font_size", type: "string" },
+ { id: "msgcompose.text_color", type: "string" },
+ { id: "msgcompose.background_color", type: "string" },
+ { id: "mail.compose.attachment_reminder", type: "bool" },
+ { id: "mail.compose.default_to_paragraph", type: "bool" },
+ { id: "mail.compose.big_attachments.notify", type: "bool" },
+ { id: "mail.compose.big_attachments.threshold_kb", type: "int" },
+ { id: "mail.default_send_format", type: "int" },
+ { id: "mail.compose.add_link_preview", type: "bool" },
+]);
+
+var gComposePane = {
+ mSpellChecker: null,
+
+ init() {
+ this.enableAutocomplete();
+
+ this.initLanguages();
+
+ this.populateFonts();
+
+ this.updateAutosave();
+
+ this.updateUseReaderDefaults();
+
+ this.updateAttachmentCheck();
+
+ this.updateEmailCollection();
+
+ this.initAbDefaultStartupDir();
+
+ this.setButtonColors();
+
+ // If BigFiles is disabled, hide the "Outgoing" tab, and the tab
+ // selectors, and bail out.
+ if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) {
+ // Hide the tab selector
+ let cloudFileBox = document.getElementById("cloudFileBox");
+ cloudFileBox.hidden = true;
+ return;
+ }
+
+ gCloudFile.init();
+ },
+
+ attachmentReminderOptionsDialog() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/attachmentReminder.xhtml",
+ { features: "resizable=no" }
+ );
+ },
+
+ updateAutosave() {
+ gComposePane.enableElement(
+ document.getElementById("autoSaveInterval"),
+ Preferences.get("mail.compose.autosave").value
+ );
+ },
+
+ updateUseReaderDefaults() {
+ let useReaderDefaultsChecked = Preferences.get(
+ "msgcompose.default_colors"
+ ).value;
+ gComposePane.enableElement(
+ document.getElementById("textColorLabel"),
+ !useReaderDefaultsChecked
+ );
+ gComposePane.enableElement(
+ document.getElementById("backgroundColorLabel"),
+ !useReaderDefaultsChecked
+ );
+ gComposePane.enableElement(
+ document.getElementById("textColorButton"),
+ !useReaderDefaultsChecked
+ );
+ gComposePane.enableElement(
+ document.getElementById("backgroundColorButton"),
+ !useReaderDefaultsChecked
+ );
+ },
+
+ updateAttachmentCheck() {
+ gComposePane.enableElement(
+ document.getElementById("attachment_reminder_button"),
+ Preferences.get("mail.compose.attachment_reminder").value
+ );
+ },
+
+ updateEmailCollection() {
+ gComposePane.enableElement(
+ document.getElementById("localDirectoriesList"),
+ Preferences.get("mail.collect_email_address_outgoing").value
+ );
+ },
+
+ enableElement(aElement, aEnable) {
+ let pref = aElement.getAttribute("preference");
+ let prefIsLocked = pref ? Preferences.get(pref).locked : false;
+ aElement.disabled = !aEnable || prefIsLocked;
+ },
+
+ enableAutocomplete() {
+ let acLDAPPref = Preferences.get("ldap_2.autoComplete.useDirectory").value;
+ gComposePane.enableElement(
+ document.getElementById("directoriesList"),
+ acLDAPPref
+ );
+ gComposePane.enableElement(
+ document.getElementById("editButton"),
+ acLDAPPref
+ );
+ },
+
+ editDirectories() {
+ gSubDialog.open(
+ "chrome://messenger/content/addressbook/pref-editdirectories.xhtml"
+ );
+ },
+
+ initAbDefaultStartupDir() {
+ if (!this.startupDirListener.inited) {
+ this.startupDirListener.load();
+ }
+
+ let dirList = document.getElementById("defaultStartupDirList");
+ if (Services.prefs.getBoolPref("mail.addr_book.view.startupURIisDefault")) {
+ // Some directory is the default.
+ let startupURI = Services.prefs.getCharPref(
+ "mail.addr_book.view.startupURI"
+ );
+ dirList.value = startupURI;
+ } else {
+ // Choose item meaning there is no default startup directory any more.
+ dirList.value = "";
+ }
+ },
+
+ setButtonColors() {
+ document.getElementById("textColorButton").value = Preferences.get(
+ "msgcompose.text_color"
+ ).value;
+ document.getElementById("backgroundColorButton").value = Preferences.get(
+ "msgcompose.background_color"
+ ).value;
+ },
+
+ setDefaultStartupDir(aDirURI) {
+ if (aDirURI) {
+ // Some AB directory was selected. Set prefs to make this directory
+ // the default view when starting up the main AB.
+ Services.prefs.setCharPref("mail.addr_book.view.startupURI", aDirURI);
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ true
+ );
+ } else {
+ // Set pref that there's no default startup view directory any more.
+ Services.prefs.setBoolPref(
+ "mail.addr_book.view.startupURIisDefault",
+ false
+ );
+ }
+ },
+
+ async initLanguages() {
+ let languageList = document.getElementById("dictionaryList");
+ this.mSpellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ // Get the list of dictionaries from the spellchecker.
+
+ let dictList = this.mSpellChecker.getDictionaryList();
+
+ // HACK: calling sortDictionaryList may fail the first time due to
+ // synchronous loading of the .ftl files. If we load the files and wait
+ // for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+ let sortedList = new InlineSpellChecker().sortDictionaryList(dictList);
+ let activeDictionaries = Services.prefs
+ .getCharPref("spellchecker.dictionary")
+ .split(",");
+ let template = document.getElementById("dictionaryListItem");
+ languageList.replaceChildren(
+ ...sortedList.map(({ displayName, localeCode }) => {
+ let item = template.content.cloneNode(true).firstElementChild;
+ item.querySelector(".checkbox-label").textContent = displayName;
+ let input = item.querySelector("input");
+ input.setAttribute("value", localeCode);
+ input.addEventListener("change", event => {
+ let language = event.target.value;
+ let dicts = Services.prefs
+ .getCharPref("spellchecker.dictionary")
+ .split(",")
+ .filter(Boolean);
+ if (!event.target.checked) {
+ dicts = dicts.filter(item => item != language);
+ } else {
+ dicts.push(language);
+ }
+ Services.prefs.setCharPref(
+ "spellchecker.dictionary",
+ dicts.join(",")
+ );
+ });
+ input.checked = activeDictionaries.includes(localeCode);
+ return item;
+ })
+ );
+ },
+
+ populateFonts() {
+ var fontsList = document.getElementById("FontSelect");
+ try {
+ var enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].getService(
+ Ci.nsIFontEnumerator
+ );
+ var localFonts = enumerator.EnumerateAllFonts();
+ for (let i = 0; i < localFonts.length; ++i) {
+ // Remove Linux system generic fonts that collide with CSS generic fonts.
+ if (
+ localFonts[i] != "" &&
+ localFonts[i] != "serif" &&
+ localFonts[i] != "sans-serif" &&
+ localFonts[i] != "monospace"
+ ) {
+ fontsList.appendItem(localFonts[i], localFonts[i]);
+ }
+ }
+ } catch (e) {}
+ // Choose the item after the list is completely generated.
+ var preference = Preferences.get(fontsList.getAttribute("preference"));
+ fontsList.value = preference.value;
+ },
+
+ restoreHTMLDefaults() {
+ // reset throws an exception if the pref value is already the default so
+ // work around that with some try/catch exception handling
+ try {
+ Preferences.get("msgcompose.font_face").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.font_size").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.text_color").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.background_color").reset();
+ } catch (ex) {}
+
+ try {
+ Preferences.get("msgcompose.default_colors").reset();
+ } catch (ex) {}
+
+ this.updateUseReaderDefaults();
+ this.setButtonColors();
+ },
+
+ startupDirListener: {
+ inited: false,
+ domain: "mail.addr_book.view.startupURI",
+ observe(subject, topic, prefName) {
+ if (topic != "nsPref:changed") {
+ return;
+ }
+
+ // If the default startup directory prefs have changed,
+ // reinitialize the default startup dir picker to show the new value.
+ gComposePane.initAbDefaultStartupDir();
+ },
+ load() {
+ // Observe changes of our prefs.
+ Services.prefs.addObserver(this.domain, this);
+ // Unload the pref observer when preferences window is closed.
+ window.addEventListener("unload", () => this.unload(), true);
+ this.inited = true;
+ },
+
+ unload(event) {
+ Services.prefs.removeObserver(
+ gComposePane.startupDirListener.domain,
+ gComposePane.startupDirListener
+ );
+ },
+ },
+};
+
+var gCloudFile = {
+ _initialized: false,
+ _list: null,
+ _buttonContainer: null,
+ _listContainer: null,
+ _settings: null,
+ _tabpanel: null,
+ _settingsPanelWrap: null,
+ _defaultPanel: null,
+
+ get _strings() {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/preferences/applications.properties"
+ );
+ },
+
+ init() {
+ this._list = document.getElementById("cloudFileView");
+ this._buttonContainer = document.getElementById(
+ "addCloudFileAccountButtons"
+ );
+ this._addAccountButton = document.getElementById("addCloudFileAccount");
+ this._listContainer = document.getElementById(
+ "addCloudFileAccountListItems"
+ );
+ this._removeAccountButton = document.getElementById(
+ "removeCloudFileAccount"
+ );
+ this._defaultPanel = document.getElementById("cloudFileDefaultPanel");
+ this._settingsPanelWrap = document.getElementById(
+ "cloudFileSettingsWrapper"
+ );
+
+ this.updateThreshold();
+ this.rebuildView();
+
+ window.addEventListener("unload", this, { capture: false, once: true });
+
+ this._onAccountConfigured = this._onAccountConfigured.bind(this);
+ this._onProviderRegistered = this._onProviderRegistered.bind(this);
+ this._onProviderUnregistered = this._onProviderUnregistered.bind(this);
+ cloudFileAccounts.on("accountConfigured", this._onAccountConfigured);
+ cloudFileAccounts.on("providerRegistered", this._onProviderRegistered);
+ cloudFileAccounts.on("providerUnregistered", this._onProviderUnregistered);
+
+ let element = document.getElementById("cloudFileThreshold");
+ Preferences.addSyncFromPrefListener(element, () => this.readThreshold());
+ Preferences.addSyncToPrefListener(element, () => this.writeThreshold());
+
+ this._initialized = true;
+ },
+
+ destroy() {
+ // Remove any controllers or observers here.
+ cloudFileAccounts.off("accountConfigured", this._onAccountConfigured);
+ cloudFileAccounts.off("providerRegistered", this._onProviderRegistered);
+ cloudFileAccounts.off("providerUnregistered", this._onProviderUnregistered);
+ },
+
+ _onAccountConfigured(event, account) {
+ for (let item of this._list.children) {
+ if (item.value == account.accountKey) {
+ item.querySelector(".configuredWarning").hidden = account.configured;
+ }
+ }
+ },
+
+ _onProviderRegistered(event, provider) {
+ let accounts = cloudFileAccounts.getAccountsForType(provider.type);
+ accounts.sort(this._sortDisplayNames);
+
+ // Always add newly-enabled accounts to the end of the list, this makes
+ // it clearer to users what's happening.
+ for (let account of accounts) {
+ let item = this.makeRichListItemForAccount(account);
+ this._list.appendChild(item);
+ }
+
+ this._buttonContainer.appendChild(this.makeButtonForProvider(provider));
+ this._listContainer.appendChild(this.makeListItemForProvider(provider));
+ },
+
+ _onProviderUnregistered(event, type) {
+ for (let item of [...this._list.children]) {
+ // If the provider is unregistered, getAccount returns null.
+ if (!cloudFileAccounts.getAccount(item.value)) {
+ if (item.hasAttribute("selected")) {
+ this._defaultPanel.hidden = false;
+ this._settingsPanelWrap.hidden = true;
+ if (this._settings) {
+ this._settings.remove();
+ }
+ this._removeAccountButton.disabled = true;
+ }
+ item.remove();
+ }
+ }
+
+ for (let button of this._buttonContainer.children) {
+ if (button.getAttribute("value") == type) {
+ button.remove();
+ }
+ }
+
+ for (let item of this._listContainer.children) {
+ if (item.getAttribute("value") == type) {
+ item.remove();
+ }
+ }
+
+ if (this._buttonContainer.childElementCount < 1) {
+ this._buttonContainer.hidden = false;
+ this._addAccountButton.hidden = true;
+ }
+ },
+
+ makeRichListItemForAccount(aAccount) {
+ let rli = document.createXULElement("richlistitem");
+ rli.setAttribute("align", "center");
+ rli.classList.add("cloudfileAccount", "input-container");
+ rli.setAttribute("value", aAccount.accountKey);
+
+ let icon = document.createElement("img");
+ icon.classList.add("typeIcon");
+ if (aAccount.iconURL) {
+ icon.setAttribute("src", aAccount.iconURL);
+ }
+ icon.setAttribute("alt", "");
+ rli.appendChild(icon);
+
+ let label = document.createXULElement("label");
+ label.setAttribute("crop", "end");
+ label.setAttribute("flex", "1");
+ label.setAttribute(
+ "value",
+ cloudFileAccounts.getDisplayName(aAccount.accountKey)
+ );
+ label.addEventListener("click", this, true);
+ rli.appendChild(label);
+
+ let input = document.createElement("input");
+ input.setAttribute("type", "text");
+ input.setAttribute("hidden", "hidden");
+ input.addEventListener("blur", this);
+ input.addEventListener("keypress", this);
+ rli.appendChild(input);
+
+ let warningIcon = document.createElement("img");
+ warningIcon.setAttribute("class", "configuredWarning typeIcon");
+ warningIcon.setAttribute("src", "chrome://global/skin/icons/warning.svg");
+ // "title" provides the accessible name, not "alt".
+ warningIcon.setAttribute(
+ "title",
+ this._strings.GetStringFromName("notConfiguredYet")
+ );
+ if (aAccount.configured) {
+ warningIcon.hidden = true;
+ }
+ rli.appendChild(warningIcon);
+
+ return rli;
+ },
+
+ makeButtonForProvider(provider) {
+ let button = document.createXULElement("button");
+ button.setAttribute("value", provider.type);
+ button.setAttribute(
+ "label",
+ this._strings.formatStringFromName("addProvider", [provider.displayName])
+ );
+ button.setAttribute(
+ "oncommand",
+ `gCloudFile.addCloudFileAccount("${provider.type}")`
+ );
+ button.style.listStyleImage = `url("${provider.iconURL}")`;
+ return button;
+ },
+
+ makeListItemForProvider(provider) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add("menuitem-iconic");
+ menuitem.setAttribute("value", provider.type);
+ menuitem.setAttribute("label", provider.displayName);
+ menuitem.setAttribute("image", provider.iconURL);
+ return menuitem;
+ },
+
+ // Sort the accounts by displayName.
+ _sortDisplayNames(a, b) {
+ let aName = a.displayName.toLowerCase();
+ let bName = b.displayName.toLowerCase();
+ return aName.localeCompare(bName);
+ },
+
+ rebuildView() {
+ // Clear the list of entries.
+ while (this._list.hasChildNodes()) {
+ this._list.lastChild.remove();
+ }
+
+ let accounts = cloudFileAccounts.accounts;
+ accounts.sort(this._sortDisplayNames);
+
+ for (let account of accounts) {
+ let rli = this.makeRichListItemForAccount(account);
+ this._list.appendChild(rli);
+ }
+
+ while (this._buttonContainer.hasChildNodes()) {
+ this._buttonContainer.lastChild.remove();
+ }
+
+ let providers = cloudFileAccounts.providers;
+ providers.sort(this._sortDisplayNames);
+ for (let provider of providers) {
+ this._buttonContainer.appendChild(this.makeButtonForProvider(provider));
+ this._listContainer.appendChild(this.makeListItemForProvider(provider));
+ }
+ },
+
+ onSelectionChanged(aEvent) {
+ if (!this._initialized || aEvent.target != this._list) {
+ return;
+ }
+
+ // Get the selected item
+ let selection = this._list.selectedItem;
+ this._removeAccountButton.disabled = !selection;
+ if (!selection) {
+ this._defaultPanel.hidden = false;
+ this._settingsPanelWrap.hidden = true;
+ if (this._settings) {
+ this._settings.remove();
+ }
+ return;
+ }
+
+ this._showAccountInfo(selection.value);
+ },
+
+ _showAccountInfo(aAccountKey) {
+ let account = cloudFileAccounts.getAccount(aAccountKey);
+ this._defaultPanel.hidden = true;
+ this._settingsPanelWrap.hidden = false;
+
+ let url = account.managementURL + `?accountId=${account.accountKey}`;
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+ browser.setAttribute("forcemessagemanager", "true");
+ if (account.extension) {
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ account.extension.policy.browsingContextGroupId
+ );
+ }
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+
+ browser.setAttribute("flex", "1");
+ // Allows keeping dialog background color without hoops.
+ browser.setAttribute("transparent", "true");
+
+ // If we have a past browser, we replace it. Else append to the wrapper.
+ if (this._settings) {
+ this._settings.remove();
+ }
+
+ this._settingsPanelWrap.appendChild(browser);
+ this._settings = browser;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+ browser.messageManager.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+
+ let options = account.browserStyle
+ ? { stylesheets: ExtensionParent.extensionStylesheets }
+ : {};
+ browser.messageManager.sendAsyncMessage("Extension:InitBrowser", options);
+
+ browser.fixupAndLoadURIString(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ onListOverflow() {
+ if (this._buttonContainer.childElementCount > 1) {
+ this._buttonContainer.hidden = true;
+ this._addAccountButton.hidden = false;
+ }
+ },
+
+ addCloudFileAccount(aType) {
+ let account = cloudFileAccounts.createAccount(aType);
+ if (!account) {
+ return;
+ }
+
+ let rli = this.makeRichListItemForAccount(account);
+ this._list.appendChild(rli);
+ this._list.selectItem(rli);
+ this._addAccountButton.removeAttribute("image");
+ this._addAccountButton.setAttribute(
+ "label",
+ this._addAccountButton.getAttribute("defaultlabel")
+ );
+ this._removeAccountButton.disabled = false;
+ },
+
+ removeCloudFileAccount() {
+ // Get the selected account key
+ let selection = this._list.selectedItem;
+ if (!selection) {
+ return;
+ }
+
+ let accountKey = selection.value;
+ let accountName = cloudFileAccounts.getDisplayName(accountKey);
+ // Does the user really want to remove this account?
+ let confirmMessage = this._strings.formatStringFromName(
+ "dialog_removeAccount",
+ [accountName]
+ );
+
+ if (Services.prompt.confirm(null, "", confirmMessage)) {
+ this._list.clearSelection();
+ cloudFileAccounts.removeAccount(accountKey);
+ let rli = this._list.querySelector(
+ "richlistitem[value='" + accountKey + "']"
+ );
+ rli.remove();
+ this._defaultPanel.hidden = false;
+ this._settingsPanelWrap.hidden = true;
+ if (this._settings) {
+ this._settings.remove();
+ }
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this.destroy();
+ break;
+ case "click": {
+ let label = aEvent.target;
+ let item = label.parentNode;
+ let input = item.querySelector("input");
+ if (!item.selected) {
+ return;
+ }
+ label.hidden = true;
+ input.value = label.value;
+ input.removeAttribute("hidden");
+ input.focus();
+ break;
+ }
+ case "blur": {
+ let input = aEvent.target;
+ let item = input.parentNode;
+ let label = item.querySelector("label");
+ cloudFileAccounts.setDisplayName(item.value, input.value);
+ label.value = input.value;
+ label.hidden = false;
+ input.setAttribute("hidden", "hidden");
+ break;
+ }
+ case "keypress": {
+ let input = aEvent.target;
+ let item = input.parentNode;
+ let label = item.querySelector("label");
+
+ if (aEvent.key == "Enter") {
+ cloudFileAccounts.setDisplayName(item.value, input.value);
+ label.value = input.value;
+ label.hidden = false;
+ input.setAttribute("hidden", "hidden");
+ gCloudFile._list.focus();
+
+ aEvent.preventDefault();
+ } else if (aEvent.key == "Escape") {
+ input.value = label.value;
+ label.hidden = false;
+ input.setAttribute("hidden", "hidden");
+ gCloudFile._list.focus();
+
+ aEvent.preventDefault();
+ }
+ }
+ }
+ },
+
+ readThreshold() {
+ let pref = Preferences.get("mail.compose.big_attachments.threshold_kb");
+ return pref.value / 1024;
+ },
+
+ writeThreshold() {
+ let threshold = document.getElementById("cloudFileThreshold");
+ let intValue = parseInt(threshold.value, 10);
+ return isNaN(intValue) ? 0 : intValue * 1024;
+ },
+
+ updateThreshold() {
+ document.getElementById("cloudFileThreshold").disabled = !Preferences.get(
+ "mail.compose.big_attachments.notify"
+ ).value;
+ },
+};
+
+Preferences.get("mail.compose.autosave").on(
+ "change",
+ gComposePane.updateAutosave
+);
+Preferences.get("mail.compose.attachment_reminder").on(
+ "change",
+ gComposePane.updateAttachmentCheck
+);
+Preferences.get("msgcompose.default_colors").on(
+ "change",
+ gComposePane.updateUseReaderDefaults
+);
+Preferences.get("ldap_2.autoComplete.useDirectory").on(
+ "change",
+ gComposePane.enableAutocomplete
+);
+Preferences.get("mail.collect_email_address_outgoing").on(
+ "change",
+ gComposePane.updateEmailCollection
+);
+Preferences.get("mail.compose.big_attachments.notify").on(
+ "change",
+ gCloudFile.updateThreshold
+);
diff --git a/comm/mail/components/preferences/connection.js b/comm/mail/components/preferences/connection.js
new file mode 100644
index 0000000000..686c2950cf
--- /dev/null
+++ b/comm/mail/components/preferences/connection.js
@@ -0,0 +1,597 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+/* import-globals-from ./extensionControlled.js */
+
+Preferences.addAll([
+ // Add network.proxy.autoconfig_url before network.proxy.type so they're
+ // both initialized when network.proxy.type initialization triggers a call to
+ // gConnectionsDialog.updateReloadButton().
+ { id: "network.proxy.autoconfig_url", type: "string" },
+ { id: "network.proxy.type", type: "int" },
+ { id: "network.proxy.http", type: "string" },
+ { id: "network.proxy.http_port", type: "int" },
+ { id: "network.proxy.ssl", type: "string" },
+ { id: "network.proxy.ssl_port", type: "int" },
+ { id: "network.proxy.socks", type: "string" },
+ { id: "network.proxy.socks_port", type: "int" },
+ { id: "network.proxy.socks_version", type: "int" },
+ { id: "network.proxy.socks_remote_dns", type: "bool" },
+ { id: "network.proxy.no_proxies_on", type: "string" },
+ { id: "network.proxy.share_proxy_settings", type: "bool" },
+ { id: "signon.autologin.proxy", type: "bool" },
+ { id: "pref.advanced.proxies.disable_button.reload", type: "bool" },
+ { id: "network.proxy.backup.ssl", type: "string" },
+ { id: "network.proxy.backup.ssl_port", type: "int" },
+ { id: "network.trr.mode", type: "int" },
+ { id: "network.trr.uri", type: "string" },
+ { id: "network.trr.resolvers", type: "string" },
+ { id: "network.trr.custom_uri", type: "string" },
+]);
+
+window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ Preferences.get("network.proxy.type").on(
+ "change",
+ gConnectionsDialog.proxyTypeChanged.bind(gConnectionsDialog)
+ );
+ Preferences.get("network.proxy.socks_version").on(
+ "change",
+ gConnectionsDialog.updateDNSPref.bind(gConnectionsDialog)
+ );
+
+ Preferences.get("network.trr.uri").on("change", () => {
+ gConnectionsDialog.updateDnsOverHttpsUI();
+ });
+
+ Preferences.get("network.trr.resolvers").on("change", () => {
+ gConnectionsDialog.initDnsOverHttpsUI();
+ });
+
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxyType"),
+ () => gConnectionsDialog.readProxyType()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxyHTTP"),
+ () => gConnectionsDialog.readHTTPProxyServer()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxyHTTP_Port"),
+ () => gConnectionsDialog.readHTTPProxyPort()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("shareAllProxies"),
+ () => gConnectionsDialog.updateProtocolPrefs()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySSL"),
+ () => gConnectionsDialog.readProxyProtocolPref("ssl", false)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySSL_Port"),
+ () => gConnectionsDialog.readProxyProtocolPref("ssl", true)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySOCKS"),
+ () => gConnectionsDialog.readProxyProtocolPref("socks", false)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySOCKS_Port"),
+ () => gConnectionsDialog.readProxyProtocolPref("socks", true)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("networkProxySOCKSVersion"),
+ () => gConnectionsDialog.updateDNSPref()
+ );
+
+ // XXX: We can't init the DNS-over-HTTPs UI until the syncfrompref for network.trr.mode
+ // has been called. The uiReady promise will be resolved after the first call to
+ // readDnsOverHttpsMode and the subsequent call to initDnsOverHttpsUI has happened.
+ gConnectionsDialog.uiReady = new Promise(resolve => {
+ gConnectionsDialog._areTrrPrefsReady = false;
+ gConnectionsDialog._handleTrrPrefsReady = resolve;
+ }).then(() => {
+ gConnectionsDialog.initDnsOverHttpsUI();
+ });
+
+ let element = document.getElementById("networkDnsOverHttps");
+ Preferences.addSyncFromPrefListener(element, () =>
+ gConnectionsDialog.readDnsOverHttpsMode()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ gConnectionsDialog.writeDnsOverHttpsMode()
+ );
+ document.documentElement.addEventListener("beforeaccept", e =>
+ gConnectionsDialog.beforeAccept(e)
+ );
+
+ document
+ .getElementById("proxyExtensionDisable")
+ .addEventListener("click", disableControllingProxyExtension);
+ gConnectionsDialog.updateProxySettingsUI();
+ initializeProxyUI(gConnectionsDialog);
+ },
+ { once: true, capture: true }
+);
+
+var gConnectionsDialog = {
+ beforeAccept(event) {
+ let dnsOverHttpsResolverChoice = document.getElementById(
+ "networkDnsOverHttpsResolverChoices"
+ ).value;
+ if (dnsOverHttpsResolverChoice == "custom") {
+ let customValue = document
+ .getElementById("networkCustomDnsOverHttpsInput")
+ .value.trim();
+ if (customValue) {
+ Services.prefs.setStringPref("network.trr.uri", customValue);
+ } else {
+ Services.prefs.clearUserPref("network.trr.uri");
+ }
+ } else {
+ Services.prefs.setStringPref(
+ "network.trr.uri",
+ dnsOverHttpsResolverChoice
+ );
+ }
+
+ var proxyTypePref = Preferences.get("network.proxy.type");
+ if (proxyTypePref.value == 2) {
+ this.doAutoconfigURLFixup();
+ return;
+ }
+
+ if (proxyTypePref.value != 1) {
+ return;
+ }
+
+ var httpProxyURLPref = Preferences.get("network.proxy.http");
+ var httpProxyPortPref = Preferences.get("network.proxy.http_port");
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+
+ // If the port is 0 and the proxy server is specified, focus on the port and cancel submission.
+ for (let prefName of ["http", "ssl", "socks"]) {
+ let proxyPortPref = Preferences.get(
+ "network.proxy." + prefName + "_port"
+ );
+ let proxyPref = Preferences.get("network.proxy." + prefName);
+ // Only worry about ports which are currently active. If the share option is on, then ignore
+ // all ports except the HTTP and SOCKS port
+ if (
+ proxyPref.value != "" &&
+ proxyPortPref.value == 0 &&
+ (prefName == "http" || prefName == "socks" || !shareProxiesPref.value)
+ ) {
+ document
+ .getElementById("networkProxy" + prefName.toUpperCase() + "_Port")
+ .focus();
+ event.preventDefault();
+ return;
+ }
+ }
+
+ // In the case of a shared proxy preference, backup the current values and update with the HTTP value
+ if (shareProxiesPref.value) {
+ var proxyServerURLPref = Preferences.get("network.proxy.ssl");
+ var proxyPortPref = Preferences.get("network.proxy.ssl_port");
+ var backupServerURLPref = Preferences.get("network.proxy.backup.ssl");
+ var backupPortPref = Preferences.get("network.proxy.backup.ssl_port");
+ backupServerURLPref.value =
+ backupServerURLPref.value || proxyServerURLPref.value;
+ backupPortPref.value = backupPortPref.value || proxyPortPref.value;
+ proxyServerURLPref.value = httpProxyURLPref.value;
+ proxyPortPref.value = httpProxyPortPref.value;
+ }
+
+ this.sanitizeNoProxiesPref();
+ },
+
+ checkForSystemProxy() {
+ if ("@mozilla.org/system-proxy-settings;1" in Cc) {
+ document.getElementById("systemPref").removeAttribute("hidden");
+ }
+ },
+
+ proxyTypeChanged() {
+ var proxyTypePref = Preferences.get("network.proxy.type");
+
+ // Update http
+ var httpProxyURLPref = Preferences.get("network.proxy.http");
+ httpProxyURLPref.updateControlDisabledState(proxyTypePref.value != 1);
+ var httpProxyPortPref = Preferences.get("network.proxy.http_port");
+ httpProxyPortPref.updateControlDisabledState(proxyTypePref.value != 1);
+
+ // Now update the other protocols
+ this.updateProtocolPrefs();
+
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ shareProxiesPref.updateControlDisabledState(proxyTypePref.value != 1);
+ var autologinProxyPref = Preferences.get("signon.autologin.proxy");
+ autologinProxyPref.updateControlDisabledState(proxyTypePref.value == 0);
+ var noProxiesPref = Preferences.get("network.proxy.no_proxies_on");
+ noProxiesPref.updateControlDisabledState(proxyTypePref.value == 0);
+
+ var autoconfigURLPref = Preferences.get("network.proxy.autoconfig_url");
+ autoconfigURLPref.updateControlDisabledState(proxyTypePref.value != 2);
+
+ this.updateReloadButton();
+
+ document.getElementById("networkProxyNoneLocalhost").hidden =
+ Services.prefs.getBoolPref(
+ "network.proxy.allow_hijacking_localhost",
+ false
+ );
+ },
+
+ updateDNSPref() {
+ var socksVersionPref = Preferences.get("network.proxy.socks_version");
+ var socksDNSPref = Preferences.get("network.proxy.socks_remote_dns");
+ var proxyTypePref = Preferences.get("network.proxy.type");
+ var isDefinitelySocks4 =
+ proxyTypePref.value == 1 && socksVersionPref.value == 4;
+ socksDNSPref.updateControlDisabledState(
+ isDefinitelySocks4 || proxyTypePref.value == 0
+ );
+ return undefined;
+ },
+
+ updateReloadButton() {
+ // Disable the "Reload PAC" button if the selected proxy type is not PAC or
+ // if the current value of the PAC textbox does not match the value stored
+ // in prefs. Likewise, disable the reload button if PAC is not configured
+ // in prefs.
+
+ var typedURL = document.getElementById("networkProxyAutoconfigURL").value;
+ var proxyTypeCur = Preferences.get("network.proxy.type").value;
+
+ var pacURL = Services.prefs.getCharPref("network.proxy.autoconfig_url");
+ var proxyType = Services.prefs.getIntPref("network.proxy.type");
+
+ var disableReloadPref = Preferences.get(
+ "pref.advanced.proxies.disable_button.reload"
+ );
+ disableReloadPref.updateControlDisabledState(
+ proxyTypeCur != 2 || proxyType != 2 || typedURL != pacURL
+ );
+ },
+
+ readProxyType() {
+ this.proxyTypeChanged();
+ return undefined;
+ },
+
+ updateProtocolPrefs() {
+ var proxyTypePref = Preferences.get("network.proxy.type");
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ var proxyPrefs = ["ssl", "socks"];
+ for (var i = 0; i < proxyPrefs.length; ++i) {
+ var proxyServerURLPref = Preferences.get(
+ "network.proxy." + proxyPrefs[i]
+ );
+ var proxyPortPref = Preferences.get(
+ "network.proxy." + proxyPrefs[i] + "_port"
+ );
+
+ // Restore previous per-proxy custom settings, if present.
+ if (proxyPrefs[i] != "socks" && !shareProxiesPref.value) {
+ var backupServerURLPref = Preferences.get(
+ "network.proxy.backup." + proxyPrefs[i]
+ );
+ var backupPortPref = Preferences.get(
+ "network.proxy.backup." + proxyPrefs[i] + "_port"
+ );
+ if (backupServerURLPref.hasUserValue) {
+ proxyServerURLPref.value = backupServerURLPref.value;
+ backupServerURLPref.reset();
+ }
+ if (backupPortPref.hasUserValue) {
+ proxyPortPref.value = backupPortPref.value;
+ backupPortPref.reset();
+ }
+ }
+
+ proxyServerURLPref.updateElements();
+ proxyPortPref.updateElements();
+ let prefIsShared = proxyPrefs[i] != "socks" && shareProxiesPref.value;
+ proxyServerURLPref.updateControlDisabledState(
+ proxyTypePref.value != 1 || prefIsShared
+ );
+ proxyPortPref.updateControlDisabledState(
+ proxyTypePref.value != 1 || prefIsShared
+ );
+ }
+ var socksVersionPref = Preferences.get("network.proxy.socks_version");
+ socksVersionPref.updateControlDisabledState(proxyTypePref.value != 1);
+ this.updateDNSPref();
+ return undefined;
+ },
+
+ readProxyProtocolPref(aProtocol, aIsPort) {
+ if (aProtocol != "socks") {
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ if (shareProxiesPref.value) {
+ var pref = Preferences.get(
+ "network.proxy.http" + (aIsPort ? "_port" : "")
+ );
+ return pref.value;
+ }
+
+ var backupPref = Preferences.get(
+ "network.proxy.backup." + aProtocol + (aIsPort ? "_port" : "")
+ );
+ return backupPref.hasUserValue ? backupPref.value : undefined;
+ }
+ return undefined;
+ },
+
+ reloadPAC() {
+ Cc["@mozilla.org/network/protocol-proxy-service;1"]
+ .getService()
+ .reloadPAC();
+ },
+
+ doAutoconfigURLFixup() {
+ var autoURL = document.getElementById("networkProxyAutoconfigURL");
+ var autoURLPref = Preferences.get("network.proxy.autoconfig_url");
+ try {
+ autoURLPref.value = autoURL.value = Services.uriFixup.getFixupURIInfo(
+ autoURL.value,
+ 0
+ ).preferredURI.spec;
+ } catch (ex) {}
+ },
+
+ sanitizeNoProxiesPref() {
+ var noProxiesPref = Preferences.get("network.proxy.no_proxies_on");
+ // replace substrings of ; and \n with commas if they're neither immediately
+ // preceded nor followed by a valid separator character
+ noProxiesPref.value = noProxiesPref.value.replace(
+ /([^, \n;])[;\n]+(?![,\n;])/g,
+ "$1,"
+ );
+ // replace any remaining ; and \n since some may follow commas, etc.
+ noProxiesPref.value = noProxiesPref.value.replace(/[;\n]/g, "");
+ },
+
+ readHTTPProxyServer() {
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ if (shareProxiesPref.value) {
+ this.updateProtocolPrefs();
+ }
+ return undefined;
+ },
+
+ readHTTPProxyPort() {
+ var shareProxiesPref = Preferences.get(
+ "network.proxy.share_proxy_settings"
+ );
+ if (shareProxiesPref.value) {
+ this.updateProtocolPrefs();
+ }
+ return undefined;
+ },
+
+ getProxyControls() {
+ let controlGroup = document.getElementById("networkProxyType");
+ return [
+ ...controlGroup.querySelectorAll(":scope > radio"),
+ ...controlGroup.querySelectorAll("label"),
+ ...controlGroup.querySelectorAll("input"),
+ ...controlGroup.querySelectorAll("checkbox"),
+ ...document.querySelectorAll("#networkProxySOCKSVersion > radio"),
+ ...document.querySelectorAll("#ConnectionsDialogPane > checkbox"),
+ ];
+ },
+
+ // Update the UI to show/hide the extension controlled message for
+ // proxy settings.
+ async updateProxySettingsUI() {
+ let isLocked = API_PROXY_PREFS.some(pref =>
+ Services.prefs.prefIsLocked(pref)
+ );
+
+ function setInputsDisabledState(isControlled) {
+ for (let element of gConnectionsDialog.getProxyControls()) {
+ element.disabled = isControlled;
+ }
+ gConnectionsDialog.proxyTypeChanged();
+ }
+
+ if (isLocked) {
+ // An extension can't control this setting if any pref is locked.
+ hideControllingProxyExtension();
+ } else {
+ handleControllingProxyExtension().then(setInputsDisabledState);
+ }
+ },
+
+ get dnsOverHttpsResolvers() {
+ let rawValue = Preferences.get("network.trr.resolvers", "").value;
+ // if there's no default, we'll hold its position with an empty string
+ let defaultURI = Preferences.get("network.trr.uri", "").defaultValue;
+ let providers = [];
+ if (rawValue) {
+ try {
+ providers = JSON.parse(rawValue);
+ } catch (ex) {
+ console.error(
+ `Bad JSON data in pref network.trr.resolvers: ${rawValue}`
+ );
+ }
+ }
+ if (!Array.isArray(providers)) {
+ console.error(
+ `Expected a JSON array in network.trr.resolvers: ${rawValue}`
+ );
+ providers = [];
+ }
+ let defaultIndex = providers.findIndex(p => p.url == defaultURI);
+ if (defaultIndex == -1 && defaultURI) {
+ // the default value for the pref isn't included in the resolvers list
+ // so we'll make a stub for it. Without an id, we'll have to use the url as the label
+ providers.unshift({ url: defaultURI });
+ }
+ return providers;
+ },
+
+ isDnsOverHttpsLocked() {
+ return Services.prefs.prefIsLocked("network.trr.mode");
+ },
+
+ isDnsOverHttpsEnabled() {
+ // values outside 1:4 are considered falsey/disabled in this context
+ let trrPref = Preferences.get("network.trr.mode");
+ let enabled = trrPref.value > 0 && trrPref.value < 5;
+ return enabled;
+ },
+
+ readDnsOverHttpsMode() {
+ // called to update checked element property to reflect current pref value
+ let enabled = this.isDnsOverHttpsEnabled();
+ let uriPref = Preferences.get("network.trr.uri");
+ uriPref.updateControlDisabledState(!enabled || this.isDnsOverHttpsLocked());
+ // this is the first signal we get when the prefs are available, so
+ // lazy-init if appropriate
+ if (!this._areTrrPrefsReady) {
+ this._areTrrPrefsReady = true;
+ this._handleTrrPrefsReady();
+ } else {
+ this.updateDnsOverHttpsUI();
+ }
+ return enabled;
+ },
+
+ writeDnsOverHttpsMode() {
+ // called to update pref with user change
+ let trrModeCheckbox = document.getElementById("networkDnsOverHttps");
+ // we treat checked/enabled as mode 2
+ return trrModeCheckbox.checked ? 2 : 0;
+ },
+
+ updateDnsOverHttpsUI() {
+ // init and update of the UI must wait until the pref values are ready
+ if (!this._areTrrPrefsReady) {
+ return;
+ }
+ let [menu, customInput] = this.getDnsOverHttpsControls();
+ let customContainer = document.getElementById(
+ "customDnsOverHttpsContainer"
+ );
+ let customURI = Preferences.get("network.trr.custom_uri").value;
+ let currentURI = Preferences.get("network.trr.uri").value;
+ let resolvers = this.dnsOverHttpsResolvers;
+ let isCustom = menu.value == "custom";
+
+ if (this.isDnsOverHttpsEnabled()) {
+ this.toggleDnsOverHttpsUI(false);
+ if (isCustom) {
+ // if the current and custom_uri values mismatch, update the uri pref
+ if (
+ currentURI &&
+ !customURI &&
+ !resolvers.find(r => r.url == currentURI)
+ ) {
+ Services.prefs.setStringPref("network.trr.custom_uri", currentURI);
+ }
+ }
+ } else {
+ this.toggleDnsOverHttpsUI(true);
+ }
+
+ if (!menu.disabled && isCustom) {
+ customContainer.hidden = false;
+ customInput.disabled = false;
+ } else {
+ customContainer.hidden = true;
+ customInput.disabled = true;
+ }
+
+ // The height has likely changed, find our SubDialog and tell it to resize.
+ requestAnimationFrame(() => {
+ let dialogs = window.opener.gSubDialog._dialogs;
+ let dialog = dialogs.find(d => d._frame.contentDocument == document);
+ if (dialog) {
+ dialog.resizeVertically();
+ }
+ });
+ },
+
+ getDnsOverHttpsControls() {
+ return [
+ document.getElementById("networkDnsOverHttpsResolverChoices"),
+ document.getElementById("networkCustomDnsOverHttpsInput"),
+ document.getElementById("networkDnsOverHttpsResolverChoicesLabel"),
+ document.getElementById("networkCustomDnsOverHttpsInputLabel"),
+ ];
+ },
+
+ toggleDnsOverHttpsUI(disabled) {
+ for (let element of this.getDnsOverHttpsControls()) {
+ element.disabled = disabled;
+ }
+ },
+
+ initDnsOverHttpsUI() {
+ let resolvers = this.dnsOverHttpsResolvers;
+ let defaultURI = Preferences.get("network.trr.uri").defaultValue;
+ let currentURI = Preferences.get("network.trr.uri").value;
+ let menu = document.getElementById("networkDnsOverHttpsResolverChoices");
+
+ // populate the DNS-Over-HTTPs resolver list
+ menu.removeAllItems();
+ for (let resolver of resolvers) {
+ let item = menu.appendItem(undefined, resolver.url);
+ if (resolver.url == defaultURI) {
+ document.l10n.setAttributes(
+ item,
+ "connection-dns-over-https-url-item-default",
+ {
+ name: resolver.name || resolver.url,
+ }
+ );
+ } else {
+ item.label = resolver.name || resolver.url;
+ }
+ }
+ let lastItem = menu.appendItem(undefined, "custom");
+ document.l10n.setAttributes(
+ lastItem,
+ "connection-dns-over-https-url-custom"
+ );
+
+ // set initial selection in the resolver provider picker
+ let selectedIndex = currentURI
+ ? resolvers.findIndex(r => r.url == currentURI)
+ : 0;
+ if (selectedIndex == -1) {
+ // select the last "Custom" item
+ selectedIndex = menu.itemCount - 1;
+ }
+ menu.selectedIndex = selectedIndex;
+
+ if (this.isDnsOverHttpsLocked()) {
+ // disable all the options and the checkbox itself to disallow enabling them
+ this.toggleDnsOverHttpsUI(true);
+ document.getElementById("networkDnsOverHttps").disabled = true;
+ } else {
+ this.toggleDnsOverHttpsUI(false);
+ this.updateDnsOverHttpsUI();
+ document.getElementById("networkDnsOverHttps").disabled = false;
+ }
+ },
+};
diff --git a/comm/mail/components/preferences/connection.xhtml b/comm/mail/components/preferences/connection.xhtml
new file mode 100644
index 0000000000..1bbb822f66
--- /dev/null
+++ b/comm/mail/components/preferences/connection.xhtml
@@ -0,0 +1,264 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE window>
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="connection-dialog-window2"
+ style="min-width: 49em"
+ onload="gConnectionsDialog.checkForSystemProxy();"
+>
+ <dialog id="ConnectionsDialog" dlgbuttons="accept,cancel">
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/connection.ftl"
+ />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/preferences.ftl"
+ />
+ <html:link rel="localization" href="branding/brand.ftl" />
+ </linkset>
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/extensionControlled.js" />
+ <script src="chrome://messenger/content/preferences/connection.js" />
+
+ <!-- Need a wrapper div within the xul:dialog, which otherwise does not give
+ - enough height for the flex display.
+ - REMOVE when we use HTML only. -->
+ <html:div>
+ <html:div id="proxyExtensionContent" hidden="hidden">
+ <html:p id="proxyExtensionDescription">
+ <html:img data-l10n-name="extension-icon" />
+ </html:p>
+ <html:button
+ id="proxyExtensionDisable"
+ data-l10n-id="disable-extension-button"
+ >
+ </html:button>
+ </html:div>
+ </html:div>
+
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="connection-proxy-legend"></html:legend>
+
+ <radiogroup id="networkProxyType" preference="network.proxy.type">
+ <radio value="0" data-l10n-id="proxy-type-no" />
+ <radio value="4" data-l10n-id="proxy-type-wpad" />
+ <radio
+ value="5"
+ data-l10n-id="proxy-type-system"
+ id="systemPref"
+ hidden="true"
+ />
+ <radio value="1" data-l10n-id="proxy-type-manual" />
+ <box id="proxy-grid" class="indent" flex="1">
+ <html:div class="proxy-grid-row">
+ <hbox pack="end">
+ <label
+ data-l10n-id="proxy-http-label"
+ control="networkProxyHTTP"
+ />
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input
+ id="networkProxyHTTP"
+ type="text"
+ preference="network.proxy.http"
+ />
+ <label
+ data-l10n-id="http-port-label"
+ control="networkProxyHTTP_Port"
+ />
+ <html:input
+ id="networkProxyHTTP_Port"
+ type="number"
+ class="size5"
+ max="65535"
+ preference="network.proxy.http_port"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="proxy-grid-row">
+ <hbox />
+ <hbox>
+ <checkbox
+ id="shareAllProxies"
+ data-l10n-id="proxy-http-sharing"
+ preference="network.proxy.share_proxy_settings"
+ class="align-no-label"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="proxy-grid-row">
+ <hbox pack="end">
+ <label
+ data-l10n-id="proxy-https-label"
+ control="networkProxySSL"
+ />
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input
+ id="networkProxySSL"
+ type="text"
+ preference="network.proxy.ssl"
+ />
+ <label
+ data-l10n-id="ssl-port-label"
+ control="networkProxySSL_Port"
+ />
+ <html:input
+ id="networkProxySSL_Port"
+ type="number"
+ class="size5"
+ max="65535"
+ preference="network.proxy.ssl_port"
+ />
+ </hbox>
+ </html:div>
+ <separator class="thin" />
+ <html:div class="proxy-grid-row">
+ <hbox pack="end">
+ <label
+ data-l10n-id="proxy-socks-label"
+ control="networkProxySOCKS"
+ />
+ </hbox>
+ <hbox align="center" class="input-container">
+ <html:input
+ id="networkProxySOCKS"
+ type="text"
+ preference="network.proxy.socks"
+ />
+ <label
+ data-l10n-id="socks-port-label"
+ control="networkProxySOCKS_Port"
+ />
+ <html:input
+ id="networkProxySOCKS_Port"
+ type="number"
+ class="size5"
+ max="65535"
+ preference="network.proxy.socks_port"
+ />
+ </hbox>
+ </html:div>
+ <html:div class="proxy-grid-row">
+ <spacer />
+ <radiogroup
+ id="networkProxySOCKSVersion"
+ orient="horizontal"
+ class="align-no-label"
+ preference="network.proxy.socks_version"
+ >
+ <radio
+ id="networkProxySOCKSVersion4"
+ value="4"
+ data-l10n-id="proxy-socks4-label"
+ />
+ <radio
+ id="networkProxySOCKSVersion5"
+ value="5"
+ data-l10n-id="proxy-socks5-label"
+ />
+ </radiogroup>
+ </html:div>
+ </box>
+ <radio value="2" data-l10n-id="proxy-type-auto" />
+ <hbox class="indent input-container" flex="1" align="center">
+ <html:input
+ id="networkProxyAutoconfigURL"
+ type="url"
+ preference="network.proxy.autoconfig_url"
+ oninput="gConnectionsDialog.updateReloadButton();"
+ />
+ <button
+ id="autoReload"
+ data-l10n-id="proxy-reload-label"
+ oncommand="gConnectionsDialog.reloadPAC();"
+ preference="pref.advanced.proxies.disable_button.reload"
+ />
+ </hbox>
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+ <separator class="thin" />
+ <label data-l10n-id="no-proxy-label" control="networkProxyNone" />
+ <html:textarea
+ id="networkProxyNone"
+ rows="2"
+ preference="network.proxy.no_proxies_on"
+ />
+ <label data-l10n-id="no-proxy-example" control="networkProxyNone" />
+ <label
+ id="networkProxyNoneLocalhost"
+ control="networkProxyNone"
+ data-l10n-id="connection-proxy-noproxy-localhost-desc-2"
+ />
+ <separator class="thin" />
+ <checkbox
+ id="autologinProxy"
+ data-l10n-id="proxy-password-prompt"
+ preference="signon.autologin.proxy"
+ />
+ <checkbox
+ id="networkProxySOCKSRemoteDNS"
+ preference="network.proxy.socks_remote_dns"
+ data-l10n-id="proxy-remote-dns"
+ />
+ <separator class="thin" />
+ <checkbox
+ id="networkDnsOverHttps"
+ data-l10n-id="proxy-enable-doh"
+ preference="network.trr.mode"
+ />
+ <box id="dnsOverHttps-grid" class="indent" flex="1">
+ <html:div class="dnsOverHttps-grid-row">
+ <hbox pack="end">
+ <label
+ id="networkDnsOverHttpsResolverChoicesLabel"
+ data-l10n-id="connection-dns-over-https-url-resolver"
+ control="networkDnsOverHttpsResolverChoices"
+ />
+ </hbox>
+ <menulist
+ id="networkDnsOverHttpsResolverChoices"
+ flex="1"
+ oncommand="gConnectionsDialog.updateDnsOverHttpsUI()"
+ />
+ </html:div>
+ <html:div
+ class="dnsOverHttps-grid-row"
+ id="customDnsOverHttpsContainer"
+ hidden="hidden"
+ >
+ <hbox>
+ <label
+ id="networkCustomDnsOverHttpsInputLabel"
+ data-l10n-id="connection-dns-over-https-custom-label"
+ control="networkCustomDnsOverHttpsInput"
+ />
+ </hbox>
+ <html:input
+ id="networkCustomDnsOverHttpsInput"
+ type="url"
+ style="flex: 1"
+ preference="network.trr.custom_uri"
+ />
+ </html:div>
+ </box>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/cookies.js b/comm/mail/components/preferences/cookies.js
new file mode 100644
index 0000000000..da06eb7e5a
--- /dev/null
+++ b/comm/mail/components/preferences/cookies.js
@@ -0,0 +1,993 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+
+var gCookiesWindow = {
+ _hosts: {},
+ _hostOrder: [],
+ _tree: null,
+ _bundle: null,
+
+ init() {
+ Services.obs.addObserver(this, "cookie-changed");
+ Services.obs.addObserver(this, "perm-changed");
+
+ this._bundle = document.getElementById("bundlePreferences");
+ this._tree = document.getElementById("cookiesList");
+
+ this._populateList(true);
+
+ document.getElementById("filter").focus();
+
+ if (!Services.prefs.getBoolPref("privacy.userContext.enabled")) {
+ document.getElementById("userContext").hidden = true;
+ document.getElementById("userContextLabel").hidden = true;
+ }
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "cookie-changed");
+ Services.obs.removeObserver(this, "perm-changed");
+ },
+
+ _populateList(aInitialLoad) {
+ this._loadCookies();
+ this._tree.view = this._view;
+ if (aInitialLoad) {
+ this.sort("rawHost");
+ }
+ if (this._view.rowCount > 0) {
+ this._tree.view.selection.select(0);
+ }
+
+ if (aInitialLoad) {
+ if (
+ "arguments" in window &&
+ window.arguments[2] &&
+ window.arguments[2].filterString
+ ) {
+ this.setFilter(window.arguments[2].filterString);
+ }
+ } else if (document.getElementById("filter").value != "") {
+ this.filter();
+ }
+
+ this._saveState();
+ },
+
+ _cookieEquals(aCookieA, aCookieB, aStrippedHost) {
+ return (
+ aCookieA.rawHost == aStrippedHost &&
+ aCookieA.name == aCookieB.name &&
+ aCookieA.path == aCookieB.path &&
+ ChromeUtils.isOriginAttributesEqual(
+ aCookieA.originAttributes,
+ aCookieB.originAttributes
+ )
+ );
+ },
+
+ observe(aCookie, aTopic, aData) {
+ if (aTopic != "cookie-changed") {
+ return;
+ }
+
+ if (aCookie instanceof Ci.nsICookie) {
+ var strippedHost = this._makeStrippedHost(aCookie.host);
+ if (aData == "changed") {
+ this._handleCookieChanged(aCookie, strippedHost);
+ } else if (aData == "added") {
+ this._handleCookieAdded(aCookie, strippedHost);
+ }
+ } else if (aData == "cleared") {
+ this._hosts = {};
+ this._hostOrder = [];
+
+ var oldRowCount = this._view._rowCount;
+ this._view._rowCount = 0;
+ this._tree.rowCountChanged(0, -oldRowCount);
+ this._view.selection.clearSelection();
+ } else if (aData == "reload") {
+ // first, clear any existing entries
+ this.observe(aCookie, aTopic, "cleared");
+
+ // then, reload the list
+ this._populateList(false);
+ }
+
+ // We don't yet handle aData == "deleted" - it's a less common case
+ // and is rather complicated as selection tracking is difficult
+ },
+
+ _handleCookieChanged(changedCookie, strippedHost) {
+ var rowIndex = 0;
+ var cookieItem = null;
+ if (!this._view._filtered) {
+ for (var i = 0; i < this._hostOrder.length; ++i) {
+ // (var host in this._hosts) {
+ ++rowIndex;
+ var hostItem = this._hosts[this._hostOrder[i]]; // var hostItem = this._hosts[host];
+ if (this._hostOrder[i] == strippedHost) {
+ // host == strippedHost) {
+ // Host matches, look for the cookie within this Host collection
+ // and update its data
+ for (var j = 0; j < hostItem.cookies.length; ++j) {
+ ++rowIndex;
+ var currCookie = hostItem.cookies[j];
+ if (this._cookieEquals(currCookie, changedCookie, strippedHost)) {
+ currCookie.value = changedCookie.value;
+ currCookie.isSecure = changedCookie.isSecure;
+ currCookie.isDomain = changedCookie.isDomain;
+ currCookie.expires = changedCookie.expires;
+ cookieItem = currCookie;
+ break;
+ }
+ }
+ } else if (hostItem.open) {
+ rowIndex += hostItem.cookies.length;
+ }
+ }
+ } else {
+ // Just walk the filter list to find the item. It doesn't matter that
+ // we don't update the main Host collection when we do this, because
+ // when the filter is reset the Host collection is rebuilt anyway.
+ for (rowIndex = 0; rowIndex < this._view._filterSet.length; ++rowIndex) {
+ currCookie = this._view._filterSet[rowIndex];
+ if (this._cookieEquals(currCookie, changedCookie, strippedHost)) {
+ currCookie.value = changedCookie.value;
+ currCookie.isSecure = changedCookie.isSecure;
+ currCookie.isDomain = changedCookie.isDomain;
+ currCookie.expires = changedCookie.expires;
+ cookieItem = currCookie;
+ break;
+ }
+ }
+ }
+
+ // Make sure the tree display is up to date...
+ this._tree.invalidateRow(rowIndex);
+ // ... and if the cookie is selected, update the displayed metadata too
+ if (cookieItem != null && this._view.selection.currentIndex == rowIndex) {
+ this._updateCookieData(cookieItem);
+ }
+ },
+
+ _handleCookieAdded(changedCookie, strippedHost) {
+ var rowCountImpact = 0;
+ var addedHost = { value: 0 };
+ this._addCookie(strippedHost, changedCookie, addedHost);
+ if (!this._view._filtered) {
+ // The Host collection for this cookie already exists, and it's not open,
+ // so don't increment the rowCountImpact because the user is not going to
+ // see the additional rows as they're hidden.
+ if (addedHost.value || this._hosts[strippedHost].open) {
+ ++rowCountImpact;
+ }
+ } else {
+ // We're in search mode, and the cookie being added matches
+ // the search condition, so add it to the list.
+ var c = this._makeCookieObject(strippedHost, changedCookie);
+ if (this._cookieMatchesFilter(c)) {
+ this._view._filterSet.push(
+ this._makeCookieObject(strippedHost, changedCookie)
+ );
+ ++rowCountImpact;
+ }
+ }
+ // Now update the tree display at the end (we could/should re run the sort
+ // if any to get the position correct.)
+ var oldRowCount = this._rowCount;
+ this._view._rowCount += rowCountImpact;
+ this._tree.rowCountChanged(oldRowCount - 1, rowCountImpact);
+
+ document.getElementById("removeAllCookies").disabled = this._view._filtered;
+ },
+
+ _view: {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+ _filtered: false,
+ _filterSet: [],
+ _filterValue: "",
+ _rowCount: 0,
+ _cacheValid: 0,
+ _cacheItems: [],
+ get rowCount() {
+ return this._rowCount;
+ },
+
+ _getItemAtIndex(aIndex) {
+ if (this._filtered) {
+ return this._filterSet[aIndex];
+ }
+
+ var start = 0;
+ var count = 0,
+ hostIndex = 0;
+
+ var cacheIndex = Math.min(this._cacheValid, aIndex);
+ if (cacheIndex > 0) {
+ var cacheItem = this._cacheItems[cacheIndex];
+ start = cacheItem.start;
+ count = hostIndex = cacheItem.count;
+ }
+
+ for (let i = start; i < gCookiesWindow._hostOrder.length; ++i) {
+ let currHost = gCookiesWindow._hosts[gCookiesWindow._hostOrder[i]];
+ if (!currHost) {
+ continue;
+ }
+ if (count == aIndex) {
+ return currHost;
+ }
+ hostIndex = count;
+
+ let cacheEntry = { start: i, count };
+ var cacheStart = count;
+
+ if (currHost.open) {
+ if (count < aIndex && aIndex <= count + currHost.cookies.length) {
+ // We are looking for an entry within this host's children,
+ // enumerate them looking for the index.
+ ++count;
+ for (let j = 0; j < currHost.cookies.length; ++j) {
+ if (count == aIndex) {
+ let cookie = currHost.cookies[j];
+ cookie.parentIndex = hostIndex;
+ return cookie;
+ }
+ ++count;
+ }
+ } else {
+ // A host entry was open, but we weren't looking for an index
+ // within that host entry's children, so skip forward over the
+ // entry's children. We need to add one to increment for the
+ // host value too.
+ count += currHost.cookies.length + 1;
+ }
+ } else {
+ ++count;
+ }
+
+ for (let j = cacheStart; j < count; j++) {
+ this._cacheItems[j] = cacheEntry;
+ }
+ this._cacheValid = count - 1;
+ }
+ return null;
+ },
+
+ _removeItemAtIndex(aIndex, aCount) {
+ var removeCount = aCount === undefined ? 1 : aCount;
+ if (this._filtered) {
+ // remove the cookies from the unfiltered set so that they
+ // don't reappear when the filter is changed. See bug 410863.
+ for (let i = aIndex; i < aIndex + removeCount; ++i) {
+ let item = this._filterSet[i];
+ let parent = gCookiesWindow._hosts[item.rawHost];
+ for (var j = 0; j < parent.cookies.length; ++j) {
+ if (item == parent.cookies[j]) {
+ parent.cookies.splice(j, 1);
+ break;
+ }
+ }
+ }
+ this._filterSet.splice(aIndex, removeCount);
+ return;
+ }
+
+ let item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return;
+ }
+ this._invalidateCache(aIndex - 1);
+ if (item.container) {
+ gCookiesWindow._hosts[item.rawHost] = null;
+ } else {
+ let parent = this._getItemAtIndex(item.parentIndex);
+ for (let i = 0; i < parent.cookies.length; ++i) {
+ var cookie = parent.cookies[i];
+ if (
+ item.rawHost == cookie.rawHost &&
+ item.name == cookie.name &&
+ item.path == cookie.path &&
+ ChromeUtils.isOriginAttributesEqual(
+ item.originAttributes,
+ cookie.originAttributes
+ )
+ ) {
+ parent.cookies.splice(i, removeCount);
+ }
+ }
+ }
+ },
+
+ _invalidateCache(aIndex) {
+ this._cacheValid = Math.min(this._cacheValid, aIndex);
+ },
+
+ getCellText(aIndex, aColumn) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return "";
+ }
+ if (aColumn.id == "domainCol") {
+ return item.rawHost;
+ }
+ if (aColumn.id == "nameCol") {
+ return "name" in item ? item.name : "";
+ }
+ } else if (aColumn.id == "domainCol") {
+ return this._filterSet[aIndex].rawHost;
+ } else if (aColumn.id == "nameCol") {
+ return "name" in this._filterSet[aIndex]
+ ? this._filterSet[aIndex].name
+ : "";
+ }
+ return "";
+ },
+
+ _selection: null,
+ get selection() {
+ return this._selection;
+ },
+ set selection(val) {
+ this._selection = val;
+ },
+ getRowProperties(aRow) {
+ return "";
+ },
+ getCellProperties(aRow, aColumn) {
+ return "";
+ },
+ getColumnProperties(aColumn) {
+ return "";
+ },
+ isContainer(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ return item.container;
+ }
+ return false;
+ },
+ isContainerOpen(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ return item.open;
+ }
+ return false;
+ },
+ isContainerEmpty(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ return item.cookies.length == 0;
+ }
+ return false;
+ },
+ isSeparator(aIndex) {
+ return false;
+ },
+ isSorted(aIndex) {
+ return false;
+ },
+ canDrop(aIndex, aOrientation) {
+ return false;
+ },
+ drop(aIndex, aOrientation) {},
+ getParentIndex(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ // If an item has no parent index (i.e. it is at the top level) this
+ // function MUST return -1 otherwise we will go into an infinite loop.
+ // Containers are always top level items in the cookies tree, so make
+ // sure to return the appropriate value here.
+ if (!item || item.container) {
+ return -1;
+ }
+ return item.parentIndex;
+ }
+ return -1;
+ },
+ hasNextSibling(aParentIndex, aIndex) {
+ if (!this._filtered) {
+ // |aParentIndex| appears to be bogus, but we can get the real
+ // parent index by getting the entry for |aIndex| and reading the
+ // parentIndex field.
+ // The index of the last item in this host collection is the
+ // index of the parent + the size of the host collection, and
+ // aIndex has a next sibling if it is less than this value.
+ var item = this._getItemAtIndex(aIndex);
+ if (item) {
+ if (item.container) {
+ for (var i = aIndex + 1; i < this.rowCount; ++i) {
+ var subsequent = this._getItemAtIndex(i);
+ if (subsequent.container) {
+ return true;
+ }
+ }
+ return false;
+ }
+ let parent = this._getItemAtIndex(item.parentIndex);
+ if (parent && parent.container) {
+ return aIndex < item.parentIndex + parent.cookies.length;
+ }
+ }
+ }
+ return aIndex < this.rowCount - 1;
+ },
+ hasPreviousSibling(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return false;
+ }
+ var parent = this._getItemAtIndex(item.parentIndex);
+ if (parent && parent.container) {
+ return aIndex > item.parentIndex + 1;
+ }
+ }
+ return aIndex > 0;
+ },
+ getLevel(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return 0;
+ }
+ return item.level;
+ }
+ return 0;
+ },
+ getImageSrc(aIndex, aColumn) {},
+ getProgressMode(aIndex, aColumn) {},
+ getCellValue(aIndex, aColumn) {},
+ setTree(aTree) {},
+ toggleOpenState(aIndex) {
+ if (!this._filtered) {
+ var item = this._getItemAtIndex(aIndex);
+ if (!item) {
+ return;
+ }
+ this._invalidateCache(aIndex);
+ var multiplier = item.open ? -1 : 1;
+ var delta = multiplier * item.cookies.length;
+ this._rowCount += delta;
+ item.open = !item.open;
+ gCookiesWindow._tree.rowCountChanged(aIndex + 1, delta);
+ gCookiesWindow._tree.invalidateRow(aIndex);
+ }
+ },
+ cycleHeader(aColumn) {},
+ selectionChanged() {},
+ cycleCell(aIndex, aColumn) {},
+ isEditable(aIndex, aColumn) {
+ return false;
+ },
+ setCellValue(aIndex, aColumn, aValue) {},
+ setCellText(aIndex, aColumn, aValue) {},
+ },
+
+ _makeStrippedHost(aHost) {
+ let formattedHost = aHost.startsWith(".")
+ ? aHost.substring(1, aHost.length)
+ : aHost;
+ return formattedHost.startsWith("www.")
+ ? formattedHost.substring(4, formattedHost.length)
+ : formattedHost;
+ },
+
+ _addCookie(aStrippedHost, aCookie, aHostCount) {
+ if (!(aStrippedHost in this._hosts) || !this._hosts[aStrippedHost]) {
+ this._hosts[aStrippedHost] = {
+ cookies: [],
+ rawHost: aStrippedHost,
+ level: 0,
+ open: false,
+ container: true,
+ };
+ this._hostOrder.push(aStrippedHost);
+ ++aHostCount.value;
+ }
+
+ var c = this._makeCookieObject(aStrippedHost, aCookie);
+ this._hosts[aStrippedHost].cookies.push(c);
+ },
+
+ _makeCookieObject(aStrippedHost, aCookie) {
+ let c = {
+ name: aCookie.name,
+ value: aCookie.value,
+ isDomain: aCookie.isDomain,
+ host: aCookie.host,
+ rawHost: aStrippedHost,
+ path: aCookie.path,
+ isSecure: aCookie.isSecure,
+ expires: aCookie.expires,
+ level: 1,
+ container: false,
+ originAttributes: aCookie.originAttributes,
+ };
+ return c;
+ },
+
+ _loadCookies() {
+ var hostCount = { value: 0 };
+ this._hosts = {};
+ this._hostOrder = [];
+ for (let cookie of Services.cookies.cookies) {
+ var strippedHost = this._makeStrippedHost(cookie.host);
+ this._addCookie(strippedHost, cookie, hostCount);
+ }
+ this._view._rowCount = hostCount.value;
+ },
+
+ formatExpiresString(aExpires) {
+ if (aExpires) {
+ var date = new Date(1000 * aExpires);
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "long",
+ timeStyle: "long",
+ });
+ return dateTimeFormatter.format(date);
+ }
+ return this._bundle.getString("expireAtEndOfSession");
+ },
+
+ _getUserContextString(aUserContextId) {
+ if (parseInt(aUserContextId, 10) == 0) {
+ return this._bundle.getString("defaultUserContextLabel");
+ }
+
+ return ContextualIdentityService.getUserContextLabel(aUserContextId);
+ },
+
+ _updateCookieData(aItem) {
+ var seln = this._view.selection;
+ var ids = [
+ "name",
+ "value",
+ "host",
+ "path",
+ "isSecure",
+ "expires",
+ "userContext",
+ ];
+ var properties;
+
+ if (aItem && !aItem.container && seln.count > 0) {
+ properties = {
+ name: aItem.name,
+ value: aItem.value,
+ host: aItem.host,
+ path: aItem.path,
+ expires: this.formatExpiresString(aItem.expires),
+ isDomain: aItem.isDomain
+ ? this._bundle.getString("domainColon")
+ : this._bundle.getString("hostColon"),
+ isSecure: aItem.isSecure
+ ? this._bundle.getString("forSecureOnly")
+ : this._bundle.getString("forAnyConnection"),
+ userContext: this._getUserContextString(
+ aItem.originAttributes.userContextId
+ ),
+ };
+ for (var i = 0; i < ids.length; ++i) {
+ document.getElementById(ids[i]).disabled = false;
+ }
+ } else {
+ var noneSelected = this._bundle.getString("noCookieSelected");
+ properties = {
+ name: noneSelected,
+ value: noneSelected,
+ host: noneSelected,
+ path: noneSelected,
+ expires: noneSelected,
+ isSecure: noneSelected,
+ userContext: noneSelected,
+ };
+ for (i = 0; i < ids.length; ++i) {
+ document.getElementById(ids[i]).disabled = true;
+ }
+ }
+ for (var property in properties) {
+ document.getElementById(property).value = properties[property];
+ }
+ },
+
+ onCookieSelected() {
+ var item;
+ var seln = this._tree.view.selection;
+ if (!this._view._filtered) {
+ item = this._view._getItemAtIndex(seln.currentIndex);
+ } else {
+ item = this._view._filterSet[seln.currentIndex];
+ }
+
+ this._updateCookieData(item);
+
+ var rangeCount = seln.getRangeCount();
+ var selectedCookieCount = 0;
+ for (var i = 0; i < rangeCount; ++i) {
+ var min = {};
+ var max = {};
+ seln.getRangeAt(i, min, max);
+ for (var j = min.value; j <= max.value; ++j) {
+ item = this._view._getItemAtIndex(j);
+ if (!item) {
+ continue;
+ }
+ if (item.container && !item.open) {
+ selectedCookieCount += item.cookies.length;
+ } else if (!item.container) {
+ ++selectedCookieCount;
+ }
+ }
+ }
+ item = this._view._getItemAtIndex(seln.currentIndex);
+ if (item && seln.count == 1 && item.container && item.open) {
+ selectedCookieCount += 2;
+ }
+
+ let buttonLabel = this._bundle.getString("removeSelectedCookies");
+ let removeSelectedCookies = document.getElementById(
+ "removeSelectedCookies"
+ );
+ removeSelectedCookies.label = PluralForm.get(
+ selectedCookieCount,
+ buttonLabel
+ ).replace("#1", selectedCookieCount);
+
+ removeSelectedCookies.disabled = !(seln.count > 0);
+ document.getElementById("removeAllCookies").disabled = this._view._filtered;
+ },
+
+ deleteCookie() {
+ // Selection Notes
+ // - Selection always moves to *NEXT* adjacent item unless item
+ // is last child at a given level in which case it moves to *PREVIOUS*
+ // item
+ //
+ // Selection Cases (Somewhat Complicated)
+ //
+ // 1) Single cookie selected, host has single child
+ // v cnn.com
+ // //// cnn.com ///////////// goksdjf@ ////
+ // > atwola.com
+ //
+ // Before SelectedIndex: 1 Before RowCount: 3
+ // After SelectedIndex: 0 After RowCount: 1
+ //
+ // 2) Host selected, host open
+ // v goats.com ////////////////////////////
+ // goats.com sldkkfjl
+ // goat.scom flksj133
+ // > atwola.com
+ //
+ // Before SelectedIndex: 0 Before RowCount: 4
+ // After SelectedIndex: 0 After RowCount: 1
+ //
+ // 3) Host selected, host closed
+ // > goats.com ////////////////////////////
+ // > atwola.com
+ //
+ // Before SelectedIndex: 0 Before RowCount: 2
+ // After SelectedIndex: 0 After RowCount: 1
+ //
+ // 4) Single cookie selected, host has many children
+ // v goats.com
+ // goats.com sldkkfjl
+ // //// goats.com /////////// flksjl33 ////
+ // > atwola.com
+ //
+ // Before SelectedIndex: 2 Before RowCount: 4
+ // After SelectedIndex: 1 After RowCount: 3
+ //
+ // 5) Single cookie selected, host has many children
+ // v goats.com
+ // //// goats.com /////////// flksjl33 ////
+ // goats.com sldkkfjl
+ // > atwola.com
+ //
+ // Before SelectedIndex: 1 Before RowCount: 4
+ // After SelectedIndex: 1 After RowCount: 3
+ var seln = this._view.selection;
+ var tbo = this._tree;
+
+ if (seln.count < 1) {
+ return;
+ }
+
+ var nextSelected = 0;
+ var rowCountImpact = 0;
+ var deleteItems = [];
+ if (!this._view._filtered) {
+ var ci = seln.currentIndex;
+ nextSelected = ci;
+ var invalidateRow = -1;
+ let item = this._view._getItemAtIndex(ci);
+ if (item.container) {
+ rowCountImpact -= (item.open ? item.cookies.length : 0) + 1;
+ deleteItems = deleteItems.concat(item.cookies);
+ if (!this._view.hasNextSibling(-1, ci)) {
+ --nextSelected;
+ }
+ this._view._removeItemAtIndex(ci);
+ } else {
+ var parent = this._view._getItemAtIndex(item.parentIndex);
+ --rowCountImpact;
+ if (parent.cookies.length == 1) {
+ --rowCountImpact;
+ deleteItems.push(item);
+ if (!this._view.hasNextSibling(-1, ci)) {
+ --nextSelected;
+ }
+ if (!this._view.hasNextSibling(-1, item.parentIndex)) {
+ --nextSelected;
+ }
+ this._view._removeItemAtIndex(item.parentIndex);
+ invalidateRow = item.parentIndex;
+ } else {
+ deleteItems.push(item);
+ if (!this._view.hasNextSibling(-1, ci)) {
+ --nextSelected;
+ }
+ this._view._removeItemAtIndex(ci);
+ }
+ }
+ this._view._rowCount += rowCountImpact;
+ tbo.rowCountChanged(ci, rowCountImpact);
+ if (invalidateRow != -1) {
+ tbo.invalidateRow(invalidateRow);
+ }
+ } else {
+ var rangeCount = seln.getRangeCount();
+ for (var i = 0; i < rangeCount; ++i) {
+ var min = {};
+ var max = {};
+ seln.getRangeAt(i, min, max);
+ nextSelected = min.value;
+ for (var j = min.value; j <= max.value; ++j) {
+ deleteItems.push(this._view._getItemAtIndex(j));
+ if (!this._view.hasNextSibling(-1, max.value)) {
+ --nextSelected;
+ }
+ }
+ var delta = max.value - min.value + 1;
+ this._view._removeItemAtIndex(min.value, delta);
+ rowCountImpact = -1 * delta;
+ this._view._rowCount += rowCountImpact;
+ tbo.rowCountChanged(min.value, rowCountImpact);
+ }
+ }
+
+ for (let item of deleteItems) {
+ Services.cookies.remove(
+ item.host,
+ item.name,
+ item.path,
+ item.originAttributes
+ );
+ }
+
+ if (nextSelected < 0) {
+ seln.clearSelection();
+ } else {
+ seln.select(nextSelected);
+ this._tree.focus();
+ }
+ },
+
+ deleteAllCookies() {
+ Services.cookies.removeAll();
+ this._tree.focus();
+ },
+
+ onCookieKeyPress(aEvent) {
+ if (aEvent.keyCode == 46) {
+ this.deleteCookie();
+ }
+ },
+
+ _lastSortProperty: "",
+ _lastSortAscending: false,
+ sort(aProperty) {
+ var ascending =
+ aProperty == this._lastSortProperty ? !this._lastSortAscending : true;
+
+ function sortByHost(a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ }
+
+ // Sort the Non-Filtered Host Collections
+ if (aProperty == "rawHost") {
+ this._hostOrder.sort(sortByHost);
+ if (!ascending) {
+ this._hostOrder.reverse();
+ }
+ }
+
+ function sortByProperty(a, b) {
+ return a[aProperty]
+ .toLowerCase()
+ .localeCompare(b[aProperty].toLowerCase());
+ }
+ for (var host in this._hosts) {
+ var cookies = this._hosts[host].cookies;
+ cookies.sort(sortByProperty);
+ if (!ascending) {
+ cookies.reverse();
+ }
+ }
+ // Sort the Filtered List, if in Filtered mode
+ if (this._view._filtered) {
+ this._view._filterSet.sort(sortByProperty);
+ if (!ascending) {
+ this._view._filterSet.reverse();
+ }
+ }
+
+ this._view._invalidateCache(0);
+ this._view.selection.clearSelection();
+ this._view.selection.select(0);
+ this._tree.invalidate();
+ this._tree.ensureRowIsVisible(0);
+
+ this._lastSortAscending = ascending;
+ this._lastSortProperty = aProperty;
+ },
+
+ clearFilter() {
+ // Revert to single-select in the tree
+ this._tree.setAttribute("seltype", "single");
+
+ // Clear the Tree Display
+ this._view._filtered = false;
+ this._view._rowCount = 0;
+ this._tree.rowCountChanged(0, -this._view._filterSet.length);
+ this._view._filterSet = [];
+
+ // Just reload the list to make sure deletions are respected
+ this._loadCookies();
+ this._tree.view = this._view;
+
+ // Restore sort order
+ var sortby = this._lastSortProperty;
+ if (sortby == "") {
+ this._lastSortAscending = false;
+ this.sort("rawHost");
+ } else {
+ this._lastSortAscending = !this._lastSortAscending;
+ this.sort(sortby);
+ }
+
+ // Restore open state
+ for (var i = 0; i < this._openIndices.length; ++i) {
+ this._view.toggleOpenState(this._openIndices[i]);
+ }
+ this._openIndices = [];
+
+ // Restore selection
+ this._view.selection.clearSelection();
+ for (i = 0; i < this._lastSelectedRanges.length; ++i) {
+ var range = this._lastSelectedRanges[i];
+ this._view.selection.rangedSelect(range.min, range.max, true);
+ }
+ this._lastSelectedRanges = [];
+
+ document.getElementById("cookiesIntro").value =
+ this._bundle.getString("cookiesAll");
+ },
+
+ _cookieMatchesFilter(aCookie) {
+ return (
+ aCookie.rawHost.includes(this._view._filterValue) ||
+ aCookie.name.includes(this._view._filterValue) ||
+ aCookie.value.includes(this._view._filterValue)
+ );
+ },
+
+ _filterCookies(aFilterValue) {
+ this._view._filterValue = aFilterValue;
+ var cookies = [];
+ for (let i = 0; i < gCookiesWindow._hostOrder.length; ++i) {
+ let currHost = gCookiesWindow._hosts[gCookiesWindow._hostOrder[i]];
+ if (!currHost) {
+ continue;
+ }
+ for (var j = 0; j < currHost.cookies.length; ++j) {
+ var cookie = currHost.cookies[j];
+ if (this._cookieMatchesFilter(cookie)) {
+ cookies.push(cookie);
+ }
+ }
+ }
+ return cookies;
+ },
+
+ _lastSelectedRanges: [],
+ _openIndices: [],
+ _saveState() {
+ // Save selection
+ var seln = this._view.selection;
+ this._lastSelectedRanges = [];
+ var rangeCount = seln.getRangeCount();
+ for (var i = 0; i < rangeCount; ++i) {
+ var min = {};
+ var max = {};
+ seln.getRangeAt(i, min, max);
+ this._lastSelectedRanges.push({ min: min.value, max: max.value });
+ }
+
+ // Save open states
+ this._openIndices = [];
+ for (i = 0; i < this._view.rowCount; ++i) {
+ var item = this._view._getItemAtIndex(i);
+ if (item && item.container && item.open) {
+ this._openIndices.push(i);
+ }
+ }
+ },
+
+ filter() {
+ var filter = document.getElementById("filter").value;
+ if (filter == "") {
+ gCookiesWindow.clearFilter();
+ return;
+ }
+ var view = gCookiesWindow._view;
+ view._filterSet = gCookiesWindow._filterCookies(filter);
+ if (!view._filtered) {
+ // Save Display Info for the Non-Filtered mode when we first
+ // enter Filtered mode.
+ gCookiesWindow._saveState();
+ view._filtered = true;
+ }
+ // Move to multi-select in the tree
+ gCookiesWindow._tree.setAttribute("seltype", "multiple");
+
+ // Clear the display
+ var oldCount = view._rowCount;
+ view._rowCount = 0;
+ gCookiesWindow._tree.rowCountChanged(0, -oldCount);
+ // Set up the filtered display
+ view._rowCount = view._filterSet.length;
+ gCookiesWindow._tree.rowCountChanged(0, view.rowCount);
+
+ // if the view is not empty then select the first item
+ if (view.rowCount > 0) {
+ view.selection.select(0);
+ }
+
+ document.getElementById("cookiesIntro").value =
+ gCookiesWindow._bundle.getString("cookiesFiltered");
+ },
+
+ setFilter(aFilterString) {
+ document.getElementById("filter").value = aFilterString;
+ this.filter();
+ },
+
+ focusFilterBox() {
+ var filter = document.getElementById("filter");
+ filter.focus();
+ filter.select();
+ },
+};
diff --git a/comm/mail/components/preferences/cookies.xhtml b/comm/mail/components/preferences/cookies.xhtml
new file mode 100644
index 0000000000..63b6ba5a64
--- /dev/null
+++ b/comm/mail/components/preferences/cookies.xhtml
@@ -0,0 +1,117 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE dialog>
+
+<window id="CookiesDialog"
+ class="windowDialog"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="cookies-window-dialog2"
+ onload="gCookiesWindow.init();"
+ onunload="gCookiesWindow.uninit();"
+ persist="width height">
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/preferences/cookies.js"/>
+
+ <stringbundle id="bundlePreferences"
+ src="chrome://messenger/locale/preferences/preferences.properties"/>
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/cookies.ftl"/>
+ </linkset>
+
+ <keyset>
+ <key data-l10n-id="window-close-key" data-l10n-attrs="key"
+ modifiers="accel" oncommand="window.close();"/>
+ <key data-l10n-id="window-focus-search-key" data-l10n-attrs="key"
+ modifiers="accel" oncommand="gCookiesWindow.focusFilterBox();"/>
+ <key data-l10n-id="window-focus-search-alt-key" data-l10n-attrs="key"
+ modifiers="accel" oncommand="gCookiesWindow.focusFilterBox();"/>
+ </keyset>
+
+ <vbox flex="1" class="contentPane largeDialogContainer">
+ <hbox align="center">
+ <label data-l10n-id="filter-search-label" control="filter"/>
+ <search-textbox id="filter"
+ flex="1"
+ aria-controls="cookiesList"
+ oncommand="gCookiesWindow.filter();"/>
+ </hbox>
+ <separator class="thin"/>
+ <label control="cookiesList" id="cookiesIntro" data-l10n-id="cookies-on-system-label"/>
+ <separator class="thin"/>
+ <tree id="cookiesList" flex="1" style="height: 10em;"
+ onkeypress="gCookiesWindow.onCookieKeyPress(event)"
+ onselect="gCookiesWindow.onCookieSelected();"
+ hidecolumnpicker="true" seltype="single">
+ <treecols>
+ <treecol id="domainCol" data-l10n-id="treecol-site-header"
+ primary="true"
+ persist="width"
+ onclick="gCookiesWindow.sort('rawHost');"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="nameCol" data-l10n-id="treecol-name-header"
+ persist="width"
+ onclick="gCookiesWindow.sort('name');"/>
+ </treecols>
+ <treechildren id="cookiesChildren"/>
+ </tree>
+ <hbox id="cookieInfoSettings" flex="1">
+ <vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="nameLabel" control="name" data-l10n-id="props-name-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="valueLabel" control="value" data-l10n-id="props-value-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="isDomain" control="host" data-l10n-id="props-domain-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="pathLabel" control="path" data-l10n-id="props-path-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="isSecureLabel" control="isSecure" data-l10n-id="props-secure-label"/>
+ </vbox>
+ <vbox flex="1" pack="center" align="end">
+ <label id="expiresLabel" control="expires" data-l10n-id="props-expires-label"/>
+ </vbox>
+ <vbox id="userContextLabel" flex="1" pack="center" align="end">
+ <label control="userContext" data-l10n-id="props-container-label"/>
+ </vbox>
+ </vbox>
+ <vbox flex="1">
+ <html:input id="name" type="text" readonly="readonly"/>
+ <html:input id="value" type="text" readonly="readonly"/>
+ <html:input id="host" type="text" readonly="readonly"/>
+ <html:input id="path" type="text" readonly="readonly"/>
+ <html:input id="isSecure" type="text" readonly="readonly"/>
+ <html:input id="expires" type="text" readonly="readonly"/>
+ <html:input id="userContext" type="text" readonly="readonly"/>
+ </vbox>
+ </hbox>
+ </vbox>
+ <hbox align="end">
+ <hbox class="actionButtons" flex="1">
+ <button id="removeSelectedCookies" disabled="true"
+ data-l10n-id="remove-cookie-button"
+ oncommand="gCookiesWindow.deleteCookie();"/>
+ <button id="removeAllCookies" disabled="true"
+ data-l10n-id="remove-all-cookies-button"
+ oncommand="gCookiesWindow.deleteAllCookies();"/>
+ <spacer flex="1"/>
+#ifndef XP_MACOSX
+ <button oncommand="window.close();"
+ data-l10n-id="cookie-close-button"/>
+#endif
+ </hbox>
+ </hbox>
+</window>
diff --git a/comm/mail/components/preferences/dockoptions.js b/comm/mail/components/preferences/dockoptions.js
new file mode 100644
index 0000000000..d518150551
--- /dev/null
+++ b/comm/mail/components/preferences/dockoptions.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "mail.biff.animate_dock_icon", type: "bool" },
+ { id: "mail.biff.show_badge", type: "bool" },
+ { id: "mail.biff.use_new_count_in_badge", type: "bool" },
+]);
diff --git a/comm/mail/components/preferences/dockoptions.xhtml b/comm/mail/components/preferences/dockoptions.xhtml
new file mode 100644
index 0000000000..978cbe1e2d
--- /dev/null
+++ b/comm/mail/components/preferences/dockoptions.xhtml
@@ -0,0 +1,59 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="dock-options-window-dialog2"
+ style="min-width: 33em;">
+ <dialog id="DockOptionsDialog"
+ dlgbuttons="accept,cancel">
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/dock-options.ftl"/>
+ </linkset>
+ <hbox orient="vertical">
+#ifdef XP_MACOSX
+ <checkbox id="newMailNotificationBounce"
+ data-l10n-id="bounce-system-dock-icon"
+ preference="mail.biff.animate_dock_icon"/>
+#endif
+#ifdef XP_WIN
+ <checkbox id="newMailBadge"
+ data-l10n-id="dock-options-show-badge"
+ preference="mail.biff.show_badge"/>
+#endif
+ <separator class="thin"/>
+ <html:div>
+ <html:fieldset>
+ <html:legend data-l10n-id="dock-icon-legend"></html:legend>
+ <vbox>
+ <separator class="thin"/>
+ <label data-l10n-id="dock-icon-show-label"/>
+ <radiogroup id="dockCount"
+ preference="mail.biff.use_new_count_in_badge"
+ class="indent" orient="vertical">
+ <radio id="dockCountAll" value="false"
+ data-l10n-id="count-unread-messages-radio"/>
+ <radio id="dockCountNew" value="true"
+ data-l10n-id="count-new-messages-radio"/>
+ </radiogroup>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+#ifdef XP_MACOSX
+ <separator/>
+ <description class="bold" data-l10n-id="notification-settings-info2"/>
+#endif
+ </hbox>
+
+ <script src="chrome://global/content/preferencesBindings.js"/>
+ <script src="chrome://messenger/content/preferences/dockoptions.js"/>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/downloads.js b/comm/mail/components/preferences/downloads.js
new file mode 100644
index 0000000000..ede1543492
--- /dev/null
+++ b/comm/mail/components/preferences/downloads.js
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from preferences.js */
+
+var { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+
+Preferences.addAll([
+ { id: "browser.download.useDownloadDir", type: "bool" },
+ { id: "browser.download.folderList", type: "int" },
+ { id: "browser.download.downloadDir", type: "file" },
+ { id: "browser.download.dir", type: "file" },
+ { id: "pref.downloads.disable_button.edit_actions", type: "bool" },
+]);
+
+var gDownloadDirSection = {
+ async chooseFolder() {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ var bundlePreferences = document.getElementById("bundlePreferences");
+ var title = bundlePreferences.getString("chooseAttachmentsFolderTitle");
+ fp.init(window, title, Ci.nsIFilePicker.modeGetFolder);
+
+ var customDirPref = Preferences.get("browser.download.dir");
+ if (customDirPref.value) {
+ fp.displayDirectory = customDirPref.value;
+ }
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ let rv = await new Promise(resolve => fp.open(resolve));
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+
+ let file = fp.file.QueryInterface(Ci.nsIFile);
+ let currentDirPref = Preferences.get("browser.download.downloadDir");
+ customDirPref.value = currentDirPref.value = file;
+ let folderListPref = Preferences.get("browser.download.folderList");
+ folderListPref.value = await this._fileToIndex(file);
+ },
+
+ onReadUseDownloadDir() {
+ this.readDownloadDirPref();
+ var downloadFolder = document.getElementById("downloadFolder");
+ var chooseFolder = document.getElementById("chooseFolder");
+ var preference = Preferences.get("browser.download.useDownloadDir");
+ var dirPreference = Preferences.get("browser.download.dir");
+ downloadFolder.disabled = !preference.value || dirPreference.locked;
+ chooseFolder.disabled = !preference.value || dirPreference.locked;
+ return undefined;
+ },
+
+ async _fileToIndex(aFile) {
+ if (!aFile || aFile.equals(await this._getDownloadsFolder("Desktop"))) {
+ return 0;
+ } else if (aFile.equals(await this._getDownloadsFolder("Downloads"))) {
+ return 1;
+ }
+ return 2;
+ },
+
+ async _indexToFile(aIndex) {
+ switch (aIndex) {
+ case 0:
+ return this._getDownloadsFolder("Desktop");
+ case 1:
+ return this._getDownloadsFolder("Downloads");
+ }
+ var customDirPref = Preferences.get("browser.download.dir");
+ return customDirPref.value;
+ },
+
+ async _getDownloadsFolder(aFolder) {
+ switch (aFolder) {
+ case "Desktop":
+ return Services.dirsvc.get("Desk", Ci.nsIFile);
+ case "Downloads":
+ let downloadsDir = await Downloads.getSystemDownloadsDirectory();
+ return new FileUtils.File(downloadsDir);
+ }
+ throw new Error(
+ "ASSERTION FAILED: folder type should be 'Desktop' or 'Downloads'"
+ );
+ },
+
+ async readDownloadDirPref() {
+ var folderListPref = Preferences.get("browser.download.folderList");
+ var bundlePreferences = document.getElementById("bundlePreferences");
+ var downloadFolder = document.getElementById("downloadFolder");
+
+ var customDirPref = Preferences.get("browser.download.dir");
+ var customIndex = customDirPref.value
+ ? await this._fileToIndex(customDirPref.value)
+ : 0;
+ if (customIndex == 0) {
+ downloadFolder.value = bundlePreferences.getString("desktopFolderName");
+ } else if (customIndex == 1) {
+ downloadFolder.value = bundlePreferences.getString(
+ "myDownloadsFolderName"
+ );
+ } else {
+ downloadFolder.value = customDirPref.value
+ ? customDirPref.value.path
+ : "";
+ }
+
+ var currentDirPref = Preferences.get("browser.download.downloadDir");
+ var downloadDir =
+ currentDirPref.value || (await this._indexToFile(folderListPref.value));
+ if (downloadDir) {
+ let urlSpec = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getURLSpecFromDir(downloadDir);
+
+ downloadFolder.style.backgroundImage =
+ "url(moz-icon://" + urlSpec + "?size=16)";
+ }
+
+ return undefined;
+ },
+};
+
+Preferences.get("browser.download.dir").on(
+ "change",
+ gDownloadDirSection.readDownloadDirPref.bind(gDownloadDirSection)
+);
diff --git a/comm/mail/components/preferences/extensionControlled.js b/comm/mail/components/preferences/extensionControlled.js
new file mode 100644
index 0000000000..5dccb348bc
--- /dev/null
+++ b/comm/mail/components/preferences/extensionControlled.js
@@ -0,0 +1,129 @@
+/* - This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from preferences.js */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+
+const API_PROXY_PREFS = [
+ "network.proxy.type",
+ "network.proxy.http",
+ "network.proxy.http_port",
+ "network.proxy.share_proxy_settings",
+ "network.proxy.ssl",
+ "network.proxy.ssl_port",
+ "network.proxy.socks",
+ "network.proxy.socks_port",
+ "network.proxy.socks_version",
+ "network.proxy.socks_remote_dns",
+ "network.proxy.no_proxies_on",
+ "network.proxy.autoconfig_url",
+ "signon.autologin.proxy",
+];
+
+/**
+ * Check if a pref is being managed by an extension.
+ *
+ * NOTE: We only currently handle proxy.settings.
+ */
+/**
+ * Get the addon extension that is controlling the proxy settings.
+ *
+ * @returns - The found addon, or undefined if none was found.
+ */
+async function getControllingProxyExtensionAddon() {
+ await ExtensionSettingsStore.initialize();
+ let id = ExtensionSettingsStore.getSetting("prefs", "proxy.settings")?.id;
+ if (id) {
+ return AddonManager.getAddonByID(id);
+ }
+ return undefined;
+}
+
+/**
+ * Show or hide the proxy extension message depending on whether or not the
+ * proxy settings are controlled by an extension.
+ *
+ * @returns {boolean} - Whether the proxy settings are controlled by an
+ * extension.
+ */
+async function handleControllingProxyExtension() {
+ let addon = await getControllingProxyExtensionAddon();
+ if (addon) {
+ showControllingProxyExtension(addon);
+ } else {
+ hideControllingProxyExtension();
+ }
+ return !!addon;
+}
+
+/**
+ * Show the proxy extension message.
+ *
+ * @param {object} addon - The addon extension that is currently controlling the
+ * proxy settings.
+ * @param {string} addon.name - The addon name.
+ * @param {string} [addon.iconUrl] - The addon icon source.
+ */
+function showControllingProxyExtension(addon) {
+ let description = document.getElementById("proxyExtensionDescription");
+ description
+ .querySelector("img")
+ .setAttribute(
+ "src",
+ addon.iconUrl || "chrome://mozapps/skin/extensions/extensionGeneric.svg"
+ );
+ document.l10n.setAttributes(
+ description,
+ "proxy-settings-controlled-by-extension",
+ { name: addon.name }
+ );
+
+ document.getElementById("proxyExtensionContent").hidden = false;
+}
+
+/**
+ * Hide the proxy extension message.
+ */
+function hideControllingProxyExtension() {
+ document.getElementById("proxyExtensionContent").hidden = true;
+}
+
+/**
+ * Disable the addon extension that is currently controlling the proxy settings.
+ */
+function disableControllingProxyExtension() {
+ getControllingProxyExtensionAddon().then(addon => addon?.disable());
+}
+
+/**
+ * Start listening to the proxy settings, and update the UI accordingly.
+ *
+ * @param {object} container - The proxy container.
+ * @param {Function} container.updateProxySettingsUI - A callback to call
+ * whenever the proxy settings change.
+ */
+function initializeProxyUI(container) {
+ let deferredUpdate = new DeferredTask(() => {
+ container.updateProxySettingsUI();
+ }, 10);
+ let proxyObserver = {
+ observe: (subject, topic, data) => {
+ if (API_PROXY_PREFS.includes(data)) {
+ deferredUpdate.arm();
+ }
+ },
+ };
+ Services.prefs.addObserver("", proxyObserver);
+ window.addEventListener("unload", () => {
+ Services.prefs.removeObserver("", proxyObserver);
+ });
+}
diff --git a/comm/mail/components/preferences/findInPage.js b/comm/mail/components/preferences/findInPage.js
new file mode 100644
index 0000000000..c69e8b50b6
--- /dev/null
+++ b/comm/mail/components/preferences/findInPage.js
@@ -0,0 +1,641 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from extensionControlled.js */
+/* import-globals-from preferences.js */
+
+// A tweak to the standard <button> CE to use textContent on the <label>
+// inside the button, which allows the text to be highlighted when the user
+// is searching.
+
+const MozButton = customElements.get("button");
+class HighlightableButton extends MozButton {
+ static get inheritedAttributes() {
+ return Object.assign({}, super.inheritedAttributes, {
+ ".button-text": "text=label,accesskey,crop",
+ });
+ }
+}
+customElements.define("highlightable-button", HighlightableButton, {
+ extends: "button",
+});
+
+var gSearchResultsPane = {
+ listSearchTooltips: new Set(),
+ listSearchMenuitemIndicators: new Set(),
+ searchInput: null,
+ // A map of DOM Elements to a string of keywords used in search.
+ // XXX: We should invalidate this cache on `intl:app-locales-changed`.
+ searchKeywords: new WeakMap(),
+ inited: false,
+
+ init() {
+ if (this.inited) {
+ return;
+ }
+ this.inited = true;
+ this.searchInput = document.getElementById("searchInput");
+ this.searchInput.hidden = !Services.prefs.getBoolPref(
+ "browser.preferences.search"
+ );
+ if (!this.searchInput.hidden) {
+ this.searchInput.addEventListener("input", this);
+ this.searchInput.addEventListener("command", this);
+ window.addEventListener("DOMContentLoaded", () => {
+ this.searchInput.focus();
+ });
+ // Initialize other panes in an idle callback.
+ window.requestIdleCallback(() => this.initializeCategories());
+ }
+ let helpUrl =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "preferences";
+ let helpContainer = document.getElementById("need-help");
+ helpContainer.querySelector("a").href = helpUrl;
+ },
+
+ async handleEvent(event) {
+ // Ensure categories are initialized if idle callback didn't run soon enough.
+ await this.initializeCategories();
+ this.searchFunction(event);
+ },
+
+ /**
+ * Check that the text content contains the query string.
+ *
+ * @param {string} content the text content to be searched.
+ * @param {string} query the query string.
+ *
+ * @returns {boolean} true when the text content contains the query string else false.
+ */
+ queryMatchesContent(content, query) {
+ if (!content || !query) {
+ return false;
+ }
+ return content.toLowerCase().includes(query.toLowerCase());
+ },
+
+ categoriesInitialized: false,
+
+ /**
+ * Will attempt to initialize all uninitialized categories.
+ */
+ async initializeCategories() {
+ // Initializing all the JS for all the tabs.
+ if (!this.categoriesInitialized) {
+ this.categoriesInitialized = true;
+ // Each element of gCategoryInits is a name.
+ for (let [name, category] of gCategoryInits) {
+ if (
+ (name != "paneCalendar" && !category.inited) ||
+ (calendarDeactivator.isCalendarActivated && !category.inited)
+ ) {
+ await category.init();
+ }
+ }
+ let lastSelected = Services.xulStore.getValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected"
+ );
+ search(lastSelected, "data-category");
+ }
+ },
+
+ /**
+ * Finds and returns text nodes within node and all descendants.
+ * Iterates through all the sibilings of the node object and adds the sibilings
+ * to an array if sibling is a TEXT_NODE else checks the text nodes with in current node.
+ * Source - http://stackoverflow.com/questions/10730309/find-all-text-nodes-in-html-page
+ *
+ * @param {Node} node DOM element.
+ *
+ * @returns {Node[]} array of text nodes.
+ */
+ textNodeDescendants(node) {
+ if (!node) {
+ return [];
+ }
+ let all = [];
+ for (node = node.firstChild; node; node = node.nextSibling) {
+ if (node.nodeType === node.TEXT_NODE) {
+ all.push(node);
+ } else {
+ all = all.concat(this.textNodeDescendants(node));
+ }
+ }
+ return all;
+ },
+
+ /**
+ * This function is used to find words contained within the text nodes.
+ * We pass in the textNodes because they contain the text to be highlighted.
+ * We pass in the nodeSizes to tell exactly where highlighting need be done.
+ * When creating the range for highlighting, if the nodes are section is split
+ * by an access key, it is important to have the size of each of the nodes summed.
+ *
+ * @param {Node[]} textNodes List of DOM elements.
+ * @param {Node[]} nodeSizes Running size of text nodes. This will contain the same
+ * number of elements as textNodes. The first element is the size of first textNode element.
+ * For any nodes after, they will contain the summation of the nodes thus far in the array.
+ * Example:
+ * textNodes = [[This is ], [a], [n example]]
+ * nodeSizes = [[8], [9], [18]]
+ * This is used to determine the offset when highlighting.
+ * @param {string} textSearch Concatenation of textNodes's text content.
+ * Example:
+ * textNodes = [[This is ], [a], [n example]]
+ * nodeSizes = "This is an example"
+ * This is used when executing the regular expression.
+ * @param {string} searchPhrase word or words to search for.
+ *
+ * @returns {boolean} Returns true when atleast one instance of search phrase is found, otherwise false.
+ */
+ highlightMatches(textNodes, nodeSizes, textSearch, searchPhrase) {
+ if (!searchPhrase) {
+ return false;
+ }
+
+ let indices = [];
+ let i = -1;
+ while ((i = textSearch.indexOf(searchPhrase, i + 1)) >= 0) {
+ indices.push(i);
+ }
+
+ // Looping through each spot the searchPhrase is found in the concatenated string.dom-mutation-list.
+ for (let startValue of indices) {
+ let endValue = startValue + searchPhrase.length;
+ let startNode = null;
+ let endNode = null;
+ let nodeStartIndex = null;
+
+ // Determining the start and end node to highlight from.
+ for (let index = 0; index < nodeSizes.length; index++) {
+ let lengthNodes = nodeSizes[index];
+ // Determining the start node.
+ if (!startNode && lengthNodes >= startValue) {
+ startNode = textNodes[index];
+ nodeStartIndex = index;
+ // Calculating the offset when found query is not in the first node.
+ if (index > 0) {
+ startValue -= nodeSizes[index - 1];
+ }
+ }
+ // Determining the end node.
+ if (!endNode && lengthNodes >= endValue) {
+ endNode = textNodes[index];
+ // Calculating the offset when endNode is different from startNode
+ // or when endNode is not the first node.
+ if (index != nodeStartIndex || index > 0) {
+ endValue -= nodeSizes[index - 1];
+ }
+ }
+ }
+ let range = document.createRange();
+ range.setStart(startNode, startValue);
+ range.setEnd(endNode, endValue);
+ this.getFindSelection(startNode.ownerGlobal).addRange(range);
+ }
+
+ return !!indices.length;
+ },
+
+ /**
+ * Get the selection instance from given window.
+ *
+ * @param {object} win The window object points to frame's window.
+ */
+ getFindSelection(win) {
+ // Yuck. See bug 138068.
+ let docShell = win.docShell;
+
+ let controller = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsISelectionDisplay)
+ .QueryInterface(Ci.nsISelectionController);
+
+ let selection = controller.getSelection(
+ Ci.nsISelectionController.SELECTION_FIND
+ );
+ selection.setColors("currentColor", "#ffe900", "currentColor", "#003eaa");
+
+ return selection;
+ },
+
+ /**
+ * Shows or hides content according to search input.
+ *
+ * @param {object} event to search for filted query in.
+ */
+ async searchFunction(event) {
+ let query = event.target.value.trim().toLowerCase();
+ if (this.query == query) {
+ return;
+ }
+
+ let subQuery = this.query && query.includes(this.query);
+ this.query = query;
+
+ this.getFindSelection(window).removeAllRanges();
+ this.removeAllSearchTooltips();
+ this.removeAllSearchMenuitemIndicators();
+
+ let srHeader = document.getElementById("header-searchResults");
+ let noResultsEl = document.getElementById("no-results-message");
+ if (this.query) {
+ // Showing the Search Results Tag.
+ await gotoPref("paneSearchResults");
+ srHeader.hidden = false;
+
+ let resultsFound = false;
+
+ // Building the range for highlighted areas.
+ let rootPreferencesChildren = [
+ ...document.querySelectorAll(
+ "#paneDeck > *:not([data-hidden-from-search],script,stringbundle,commandset,keyset,linkset)"
+ ),
+ ];
+
+ if (subQuery) {
+ // Since the previous query is a subset of the current query,
+ // there is no need to check elements that is hidden already.
+ rootPreferencesChildren = rootPreferencesChildren.filter(
+ el => !el.hidden
+ );
+ }
+
+ // Attach the bindings for all children if they were not already visible.
+ for (let child of rootPreferencesChildren) {
+ if (child.hidden) {
+ child.classList.add("visually-hidden");
+ child.hidden = false;
+ }
+ }
+
+ let ts = performance.now();
+ let FRAME_THRESHOLD = 10;
+
+ // Showing or Hiding specific section depending on if words in query are found.
+ for (let child of rootPreferencesChildren) {
+ if (performance.now() - ts > FRAME_THRESHOLD) {
+ // Creating tooltips for all the instances found.
+ for (let anchorNode of this.listSearchTooltips) {
+ this.createSearchTooltip(anchorNode, this.query);
+ }
+ ts = await new Promise(resolve =>
+ window.requestAnimationFrame(resolve)
+ );
+ if (query !== this.query) {
+ return;
+ }
+ }
+
+ if (
+ !child.classList.contains("header") &&
+ !child.classList.contains("subcategory") &&
+ (await this.searchWithinNode(child, this.query))
+ ) {
+ child.classList.remove("visually-hidden");
+
+ // Show the preceding search-header if one exists.
+ let groupbox = child.closest("groupbox");
+ let groupHeader =
+ groupbox && groupbox.querySelector(".search-header");
+ if (groupHeader) {
+ groupHeader.hidden = false;
+ }
+
+ resultsFound = true;
+ } else {
+ child.classList.add("visually-hidden");
+ }
+ }
+
+ noResultsEl.hidden = !!resultsFound;
+ noResultsEl.setAttribute("query", this.query);
+ // XXX: This is potentially racy in case where Fluent retranslates the
+ // message and ereases the query within.
+ // The feature is not yet supported, but we should fix for it before
+ // we enable it. See bug 1446389 for details.
+ let msgQueryElem = document.getElementById("sorry-message-query");
+ msgQueryElem.textContent = this.query;
+ if (resultsFound) {
+ // Creating tooltips for all the instances found.
+ for (let anchorNode of this.listSearchTooltips) {
+ this.createSearchTooltip(anchorNode, this.query);
+ }
+ }
+ } else {
+ noResultsEl.hidden = true;
+ document.getElementById("sorry-message-query").textContent = "";
+ // Going back to General when cleared.
+ await gotoPref("paneGeneral");
+ srHeader.hidden = true;
+
+ // Hide some special second level headers in normal view.
+ for (let element of document.querySelectorAll(".search-header")) {
+ element.hidden = true;
+ }
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("PreferencesSearchCompleted", { detail: query })
+ );
+ },
+
+ /**
+ * Finding leaf nodes and checking their content for words to search,
+ * It is a recursive function.
+ *
+ * @param {Node} nodeObject DOM Element.
+ * @param {string} searchPhrase
+ *
+ * @returns {boolean} Returns true when found in at least one childNode, false otherwise.
+ */
+ async searchWithinNode(nodeObject, searchPhrase) {
+ let matchesFound = false;
+ if (
+ nodeObject.childElementCount == 0 ||
+ nodeObject.tagName == "button" ||
+ nodeObject.tagName == "label" ||
+ nodeObject.tagName == "description" ||
+ nodeObject.tagName == "menulist" ||
+ nodeObject.tagName == "menuitem"
+ ) {
+ let simpleTextNodes = this.textNodeDescendants(nodeObject);
+ for (let node of simpleTextNodes) {
+ let result = this.highlightMatches(
+ [node],
+ [node.length],
+ node.textContent.toLowerCase(),
+ searchPhrase
+ );
+ matchesFound = matchesFound || result;
+ }
+
+ // Collecting data from anonymous content / label / description.
+ let nodeSizes = [];
+ let allNodeText = "";
+ let runningSize = 0;
+
+ let accessKeyTextNodes = [];
+
+ if (
+ nodeObject.tagName == "label" ||
+ nodeObject.tagName == "description"
+ ) {
+ accessKeyTextNodes.push(...simpleTextNodes);
+ }
+
+ for (let node of accessKeyTextNodes) {
+ runningSize += node.textContent.length;
+ allNodeText += node.textContent;
+ nodeSizes.push(runningSize);
+ }
+
+ // Access key are presented.
+ let complexTextNodesResult = this.highlightMatches(
+ accessKeyTextNodes,
+ nodeSizes,
+ allNodeText.toLowerCase(),
+ searchPhrase
+ );
+
+ // Searching some elements, such as xul:button, have a 'label' attribute
+ // that contains the user-visible text.
+ let labelResult = this.queryMatchesContent(
+ nodeObject.getAttribute("label"),
+ searchPhrase
+ );
+
+ // Searching some elements, such as xul:label, store their user-visible
+ // text in a "value" attribute. Value will be skipped for menuitem since
+ // value in menuitem could represent index number to distinct each item.
+ let valueResult =
+ nodeObject.tagName !== "menuitem" && nodeObject.tagName !== "radio"
+ ? this.queryMatchesContent(
+ nodeObject.getAttribute("value"),
+ searchPhrase
+ )
+ : false;
+
+ // Searching some elements, such as xul:button, buttons to open subdialogs
+ // using l10n ids.
+ let keywordsResult =
+ nodeObject.hasAttribute("search-l10n-ids") &&
+ (await this.matchesSearchL10nIDs(nodeObject, searchPhrase));
+
+ if (!keywordsResult) {
+ // Searching some elements, such as xul:button, buttons to open subdialogs
+ // using searchkeywords attribute.
+ keywordsResult =
+ !keywordsResult &&
+ nodeObject.hasAttribute("searchkeywords") &&
+ this.queryMatchesContent(
+ nodeObject.getAttribute("searchkeywords"),
+ searchPhrase
+ );
+ }
+
+ // Creating tooltips for buttons.
+ if (
+ keywordsResult &&
+ (nodeObject.tagName === "button" || nodeObject.tagName == "menulist")
+ ) {
+ this.listSearchTooltips.add(nodeObject);
+ }
+
+ if (keywordsResult && nodeObject.tagName === "menuitem") {
+ nodeObject.setAttribute("indicator", "true");
+ this.listSearchMenuitemIndicators.add(nodeObject);
+ let menulist = nodeObject.closest("menulist");
+
+ menulist.setAttribute("indicator", "true");
+ this.listSearchMenuitemIndicators.add(menulist);
+ }
+
+ if (
+ (nodeObject.tagName == "menulist" ||
+ nodeObject.tagName == "menuitem") &&
+ (labelResult || valueResult || keywordsResult)
+ ) {
+ nodeObject.setAttribute("highlightable", "true");
+ }
+
+ matchesFound =
+ matchesFound ||
+ complexTextNodesResult ||
+ labelResult ||
+ valueResult ||
+ keywordsResult;
+ }
+
+ for (let i = 0; i < nodeObject.childNodes.length; i++) {
+ let result = await this.searchChildNodeIfVisible(
+ nodeObject,
+ i,
+ searchPhrase
+ );
+ matchesFound = matchesFound || result;
+ }
+ return matchesFound;
+ },
+
+ /**
+ * Search for a phrase within a child node if it is visible.
+ *
+ * @param {Node} nodeObject The parent DOM Element.
+ * @param {number} index The index for the childNode.
+ * @param {string} searchPhrase
+ *
+ * @returns {boolean} Returns true when found the specific childNode, false otherwise
+ */
+ async searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
+ let result = false;
+ if (
+ !nodeObject.childNodes[index].hidden &&
+ nodeObject.getAttribute("data-hidden-from-search") !== "true"
+ ) {
+ result = await this.searchWithinNode(
+ nodeObject.childNodes[index],
+ searchPhrase
+ );
+ // Creating tooltips for menulist element.
+ if (result && nodeObject.tagName === "menulist") {
+ this.listSearchTooltips.add(nodeObject);
+ }
+ }
+ return result;
+ },
+
+ /**
+ * Search for a phrase in l10n messages associated with the element.
+ *
+ * @param {Node} nodeObject The parent DOM Element.
+ * @param {string} searchPhrase.
+ * @returns {boolean} true when the text content contains the query string else false.
+ */
+ async matchesSearchL10nIDs(nodeObject, searchPhrase) {
+ if (!this.searchKeywords.has(nodeObject)) {
+ // The `search-l10n-ids` attribute is a comma-separated list of
+ // l10n ids. It may also uses a dot notation to specify an attribute
+ // of the message to be used.
+ //
+ // Example: "containers-add-button.label, user-context-personal".
+ //
+ // The result is an array of arrays of l10n ids and optionally attribute names.
+ //
+ // Example: [["containers-add-button", "label"], ["user-context-personal"]]
+ const refs = nodeObject
+ .getAttribute("search-l10n-ids")
+ .split(",")
+ .map(s => s.trim().split("."))
+ .filter(s => !!s[0].length);
+
+ const messages = await document.l10n.formatMessages(
+ refs.map(ref => ({ id: ref[0] }))
+ );
+
+ // Map the localized messages taking value or a selected attribute and
+ // building a string of concatenated translated strings out of it.
+ let keywords = messages
+ .map((msg, i) => {
+ let [refId, refAttr] = refs[i];
+ if (!msg) {
+ console.error(`Missing search l10n id "${refId}"`);
+ return null;
+ }
+ if (refAttr) {
+ let attr =
+ msg.attributes && msg.attributes.find(a => a.name === refAttr);
+ if (!attr) {
+ console.error(`Missing search l10n id "${refId}.${refAttr}"`);
+ return null;
+ }
+ if (attr.value === "") {
+ console.error(
+ `Empty value added to search-l10n-ids "${refId}.${refAttr}"`
+ );
+ }
+ return attr.value;
+ }
+ if (msg.value === "") {
+ console.error(`Empty value added to search-l10n-ids "${refId}"`);
+ }
+ return msg.value;
+ })
+ .filter(keyword => keyword !== null)
+ .join(" ");
+
+ this.searchKeywords.set(nodeObject, keywords);
+ return this.queryMatchesContent(keywords, searchPhrase);
+ }
+
+ return this.queryMatchesContent(
+ this.searchKeywords.get(nodeObject),
+ searchPhrase
+ );
+ },
+
+ /**
+ * Inserting a div structure infront of the DOM element matched textContent.
+ * Then calculation the offsets to position the tooltip in the correct place.
+ *
+ * @param {Node} anchorNode DOM Element.
+ * @param {string} query Word or words that are being searched for.
+ */
+ createSearchTooltip(anchorNode, query) {
+ if (anchorNode.tooltipNode) {
+ return;
+ }
+ let searchTooltip = anchorNode.ownerDocument.createElement("span");
+ let searchTooltipText = anchorNode.ownerDocument.createElement("span");
+ searchTooltip.className = "search-tooltip";
+ searchTooltipText.textContent = query;
+ searchTooltip.appendChild(searchTooltipText);
+
+ // Set tooltipNode property to track corresponded tooltip node.
+ anchorNode.tooltipNode = searchTooltip;
+ anchorNode.parentElement.classList.add("search-tooltip-parent");
+ anchorNode.parentElement.appendChild(searchTooltip);
+
+ this.calculateTooltipPosition(anchorNode);
+ },
+
+ calculateTooltipPosition(anchorNode) {
+ let searchTooltip = anchorNode.tooltipNode;
+ // In order to get the up-to-date position of each of the nodes that we're
+ // putting tooltips on, we have to flush layout intentionally, and that
+ // this is the result of a XUL limitation (bug 1363730).
+ let tooltipRect = searchTooltip.getBoundingClientRect();
+ searchTooltip.style.setProperty(
+ "left",
+ `calc(50% - ${tooltipRect.width / 2}px)`
+ );
+ },
+
+ /**
+ * Remove all search tooltips.
+ */
+ removeAllSearchTooltips() {
+ for (let anchorNode of this.listSearchTooltips) {
+ anchorNode.parentElement.classList.remove("search-tooltip-parent");
+ if (anchorNode.tooltipNode) {
+ anchorNode.tooltipNode.remove();
+ }
+ anchorNode.tooltipNode = null;
+ }
+ this.listSearchTooltips.clear();
+ },
+
+ /**
+ * Remove all indicators on menuitem.
+ */
+ removeAllSearchMenuitemIndicators() {
+ for (let node of this.listSearchMenuitemIndicators) {
+ node.removeAttribute("indicator");
+ }
+ this.listSearchMenuitemIndicators.clear();
+ },
+};
diff --git a/comm/mail/components/preferences/fonts.js b/comm/mail/components/preferences/fonts.js
new file mode 100644
index 0000000000..d6a4c1308e
--- /dev/null
+++ b/comm/mail/components/preferences/fonts.js
@@ -0,0 +1,196 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// toolkit/content/preferencesBindings.js
+/* globals Preferences */
+// toolkit/mozapps/preferences/fontbuilder.js
+/* globals FontBuilder */
+
+var kDefaultFontType = "font.default.%LANG%";
+var kFontNameFmtSerif = "font.name.serif.%LANG%";
+var kFontNameFmtSansSerif = "font.name.sans-serif.%LANG%";
+var kFontNameFmtMonospace = "font.name.monospace.%LANG%";
+var kFontNameListFmtSerif = "font.name-list.serif.%LANG%";
+var kFontNameListFmtSansSerif = "font.name-list.sans-serif.%LANG%";
+var kFontNameListFmtMonospace = "font.name-list.monospace.%LANG%";
+var kFontSizeFmtVariable = "font.size.variable.%LANG%";
+var kFontSizeFmtFixed = "font.size.monospace.%LANG%";
+var kFontMinSizeFmt = "font.minimum-size.%LANG%";
+
+Preferences.addAll([
+ { id: "font.language.group", type: "wstring" },
+ { id: "browser.display.use_document_fonts", type: "int" },
+ { id: "mail.fixed_width_messages", type: "bool" },
+]);
+
+var gFontsDialog = {
+ _selectLanguageGroupPromise: Promise.resolve(),
+
+ init() {
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("selectLangs"),
+ () => gFontsDialog.readFontLanguageGroup()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("serif"),
+ element => FontBuilder.readFontSelection(element)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("sans-serif"),
+ element => FontBuilder.readFontSelection(element)
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("monospace"),
+ element => FontBuilder.readFontSelection(element)
+ );
+
+ let element = document.getElementById("useDocumentFonts");
+ Preferences.addSyncFromPrefListener(element, () =>
+ gFontsDialog.readUseDocumentFonts()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ gFontsDialog.writeUseDocumentFonts()
+ );
+
+ element = document.getElementById("mailFixedWidthMessages");
+ Preferences.addSyncFromPrefListener(element, () =>
+ gFontsDialog.readFixedWidthForPlainText()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ gFontsDialog.writeFixedWidthForPlainText()
+ );
+ },
+
+ _selectLanguageGroup(aLanguageGroup) {
+ this._selectLanguageGroupPromise = (async () => {
+ // Avoid overlapping language group selections by awaiting the resolution
+ // of the previous one. We do this because this function is re-entrant,
+ // as inserting <preference> elements into the DOM sometimes triggers a call
+ // back into this function. And since this function is also asynchronous,
+ // that call can enter this function before the previous run has completed,
+ // which would corrupt the font menulists. Awaiting the previous call's
+ // resolution avoids that fate.
+ await this._selectLanguageGroupPromise;
+
+ var prefs = [
+ {
+ format: kDefaultFontType,
+ type: "string",
+ element: "defaultFontType",
+ fonttype: null,
+ },
+ {
+ format: kFontNameFmtSerif,
+ type: "fontname",
+ element: "serif",
+ fonttype: "serif",
+ },
+ {
+ format: kFontNameFmtSansSerif,
+ type: "fontname",
+ element: "sans-serif",
+ fonttype: "sans-serif",
+ },
+ {
+ format: kFontNameFmtMonospace,
+ type: "fontname",
+ element: "monospace",
+ fonttype: "monospace",
+ },
+ {
+ format: kFontNameListFmtSerif,
+ type: "unichar",
+ element: null,
+ fonttype: "serif",
+ },
+ {
+ format: kFontNameListFmtSansSerif,
+ type: "unichar",
+ element: null,
+ fonttype: "sans-serif",
+ },
+ {
+ format: kFontNameListFmtMonospace,
+ type: "unichar",
+ element: null,
+ fonttype: "monospace",
+ },
+ {
+ format: kFontSizeFmtVariable,
+ type: "int",
+ element: "sizeVar",
+ fonttype: null,
+ },
+ {
+ format: kFontSizeFmtFixed,
+ type: "int",
+ element: "sizeMono",
+ fonttype: null,
+ },
+ {
+ format: kFontMinSizeFmt,
+ type: "int",
+ element: "minSize",
+ fonttype: null,
+ },
+ ];
+ for (var i = 0; i < prefs.length; ++i) {
+ var name = prefs[i].format.replace(/%LANG%/, aLanguageGroup);
+ var preference = Preferences.get(name);
+ if (!preference) {
+ preference = Preferences.add({ id: name, type: prefs[i].type });
+ }
+
+ if (!prefs[i].element) {
+ continue;
+ }
+
+ var element = document.getElementById(prefs[i].element);
+ if (element) {
+ element.setAttribute("preference", preference.id);
+
+ if (prefs[i].fonttype) {
+ await FontBuilder.buildFontList(
+ aLanguageGroup,
+ prefs[i].fonttype,
+ element
+ );
+ }
+ preference.setElementValue(element);
+ }
+ }
+ })().catch(console.error);
+ },
+
+ readFontLanguageGroup() {
+ var languagePref = Preferences.get("font.language.group");
+ this._selectLanguageGroup(languagePref.value);
+ return undefined;
+ },
+
+ readUseDocumentFonts() {
+ var preference = Preferences.get("browser.display.use_document_fonts");
+ return preference.value == 1;
+ },
+
+ writeUseDocumentFonts() {
+ var useDocumentFonts = document.getElementById("useDocumentFonts");
+ return useDocumentFonts.checked ? 1 : 0;
+ },
+
+ readFixedWidthForPlainText() {
+ var preference = Preferences.get("mail.fixed_width_messages");
+ return preference.value == 1;
+ },
+
+ writeFixedWidthForPlainText() {
+ var mailFixedWidthMessages = document.getElementById(
+ "mailFixedWidthMessages"
+ );
+ return mailFixedWidthMessages.checked;
+ },
+};
+
+window.addEventListener("load", () => gFontsDialog.init());
diff --git a/comm/mail/components/preferences/fonts.xhtml b/comm/mail/components/preferences/fonts.xhtml
new file mode 100644
index 0000000000..4bb793a04d
--- /dev/null
+++ b/comm/mail/components/preferences/fonts.xhtml
@@ -0,0 +1,337 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at https://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE html>
+<html
+ id="FontsDialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ type="child"
+ persist="lastSelected"
+ scrolling="false"
+ style="min-width: 60ch"
+>
+ <head>
+ <title data-l10n-id="fonts-dialog-title"></title>
+ <link rel="localization" href="messenger/preferences/fonts.ftl" />
+ <script
+ defer="defer"
+ src="chrome://global/content/preferencesBindings.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://mozapps/content/preferences/fontbuilder.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/menulist-charsetpicker.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/preferences/fonts.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept,cancel" style="min-height: 100vh">
+ <keyset>
+ <key
+ data-l10n-id="fonts-window-close"
+ modifiers="accel"
+ oncommand="Preferences.close(event)"
+ />
+ </keyset>
+
+ <html:fieldset>
+ <!-- title row -->
+ <hbox>
+ <hbox align="center" pack="end">
+ <label
+ id="fontsTitle"
+ control="selectLangs"
+ data-l10n-id="fonts-language-legend"
+ />
+ </hbox>
+ <menulist id="selectLangs" flex="1" preference="font.language.group">
+ <menupopup>
+ <menuitem value="ar" data-l10n-id="font-language-group-arabic" />
+ <menuitem
+ value="x-armn"
+ data-l10n-id="font-language-group-armenian"
+ />
+ <menuitem
+ value="x-beng"
+ data-l10n-id="font-language-group-bengali"
+ />
+ <menuitem
+ value="zh-CN"
+ data-l10n-id="font-language-group-simpl-chinese"
+ />
+ <menuitem
+ value="zh-HK"
+ data-l10n-id="font-language-group-trad-chinese-hk"
+ />
+ <menuitem
+ value="zh-TW"
+ data-l10n-id="font-language-group-trad-chinese"
+ />
+ <menuitem
+ value="x-cyrillic"
+ data-l10n-id="font-language-group-cyrillic"
+ />
+ <menuitem
+ value="x-devanagari"
+ data-l10n-id="font-language-group-devanagari"
+ />
+ <menuitem
+ value="x-ethi"
+ data-l10n-id="font-language-group-ethiopic"
+ />
+ <menuitem
+ value="x-geor"
+ data-l10n-id="font-language-group-georgian"
+ />
+ <menuitem value="el" data-l10n-id="font-language-group-el" />
+ <menuitem
+ value="x-gujr"
+ data-l10n-id="font-language-group-gujarati"
+ />
+ <menuitem
+ value="x-guru"
+ data-l10n-id="font-language-group-gurmukhi"
+ />
+ <menuitem value="he" data-l10n-id="font-language-group-hebrew" />
+ <menuitem
+ value="ja"
+ data-l10n-id="font-language-group-japanese"
+ />
+ <menuitem
+ value="x-knda"
+ data-l10n-id="font-language-group-kannada"
+ />
+ <menuitem
+ value="x-khmr"
+ data-l10n-id="font-language-group-khmer"
+ />
+ <menuitem value="ko" data-l10n-id="font-language-group-korean" />
+ <menuitem
+ value="x-western"
+ data-l10n-id="font-language-group-latin"
+ />
+ <menuitem
+ value="x-mlym"
+ data-l10n-id="font-language-group-malayalam"
+ />
+ <menuitem
+ value="x-math"
+ data-l10n-id="font-language-group-math"
+ />
+ <menuitem
+ value="x-orya"
+ data-l10n-id="font-language-group-odia"
+ />
+ <menuitem
+ value="x-sinh"
+ data-l10n-id="font-language-group-sinhala"
+ />
+ <menuitem
+ value="x-tamil"
+ data-l10n-id="font-language-group-tamil"
+ />
+ <menuitem
+ value="x-telu"
+ data-l10n-id="font-language-group-telugu"
+ />
+ <menuitem value="th" data-l10n-id="font-language-group-thai" />
+ <menuitem
+ value="x-tibt"
+ data-l10n-id="font-language-group-tibetan"
+ />
+ <menuitem
+ value="x-cans"
+ data-l10n-id="font-language-group-canadian"
+ />
+ <menuitem
+ value="x-unicode"
+ data-l10n-id="font-language-group-other"
+ />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <separator />
+ <box id="font-chooser-group">
+ <!-- proportional row -->
+ <hbox align="center" pack="end">
+ <label data-l10n-id="fonts-proportional-label" />
+ </hbox>
+ <menulist id="defaultFontType">
+ <menupopup>
+ <menuitem value="serif" data-l10n-id="default-font-serif" />
+ <menuitem
+ value="sans-serif"
+ data-l10n-id="default-font-sans-serif"
+ />
+ </menupopup>
+ </menulist>
+ <hbox align="center" pack="end">
+ <label
+ control="sizeVar"
+ data-l10n-id="font-size-proportional-label"
+ class="startSpacing"
+ />
+ </hbox>
+ <menulist id="sizeVar" delayprefsave="true">
+ <menupopup>
+ <menuitem value="9" label="9" />
+ <menuitem value="10" label="10" />
+ <menuitem value="11" label="11" />
+ <menuitem value="12" label="12" />
+ <menuitem value="13" label="13" />
+ <menuitem value="14" label="14" />
+ <menuitem value="15" label="15" />
+ <menuitem value="16" label="16" />
+ <menuitem value="17" label="17" />
+ <menuitem value="18" label="18" />
+ <menuitem value="20" label="20" />
+ <menuitem value="22" label="22" />
+ <menuitem value="24" label="24" />
+ <menuitem value="26" label="26" />
+ <menuitem value="28" label="28" />
+ <menuitem value="30" label="30" />
+ <menuitem value="32" label="32" />
+ <menuitem value="34" label="34" />
+ <menuitem value="36" label="36" />
+ <menuitem value="40" label="40" />
+ <menuitem value="44" label="44" />
+ <menuitem value="48" label="48" />
+ <menuitem value="56" label="56" />
+ <menuitem value="64" label="64" />
+ <menuitem value="72" label="72" />
+ </menupopup>
+ </menulist>
+
+ <!-- serif row -->
+ <hbox align="center" pack="end">
+ <label control="serif" data-l10n-id="font-serif-label" />
+ </hbox>
+ <menulist id="serif" delayprefsave="true" />
+ <spacer />
+ <spacer />
+
+ <!-- sans-serif row -->
+ <hbox align="center" pack="end">
+ <label control="sans-serif" data-l10n-id="font-sans-serif-label" />
+ </hbox>
+ <menulist id="sans-serif" delayprefsave="true" />
+ <spacer />
+ <spacer />
+
+ <!-- monospace row -->
+ <hbox align="center" pack="end">
+ <label control="monospace" data-l10n-id="font-monospace-label" />
+ </hbox>
+ <menulist id="monospace" crop="end" delayprefsave="true" />
+ <hbox align="center" pack="end">
+ <label
+ control="sizeMono"
+ data-l10n-id="font-size-monospace-label"
+ class="startSpacing"
+ />
+ </hbox>
+ <menulist id="sizeMono" delayprefsave="true">
+ <menupopup>
+ <menuitem value="9" label="9" />
+ <menuitem value="10" label="10" />
+ <menuitem value="11" label="11" />
+ <menuitem value="12" label="12" />
+ <menuitem value="13" label="13" />
+ <menuitem value="14" label="14" />
+ <menuitem value="15" label="15" />
+ <menuitem value="16" label="16" />
+ <menuitem value="17" label="17" />
+ <menuitem value="18" label="18" />
+ <menuitem value="20" label="20" />
+ <menuitem value="22" label="22" />
+ <menuitem value="24" label="24" />
+ <menuitem value="26" label="26" />
+ <menuitem value="28" label="28" />
+ <menuitem value="30" label="30" />
+ <menuitem value="32" label="32" />
+ <menuitem value="34" label="34" />
+ <menuitem value="36" label="36" />
+ <menuitem value="40" label="40" />
+ <menuitem value="44" label="44" />
+ <menuitem value="48" label="48" />
+ <menuitem value="56" label="56" />
+ <menuitem value="64" label="64" />
+ <menuitem value="72" label="72" />
+ </menupopup>
+ </menulist>
+ </box>
+
+ <separator class="thin" />
+
+ <hbox flex="1">
+ <spacer flex="1" />
+ <hbox align="center" pack="end">
+ <label data-l10n-id="font-min-size-label" control="minSize" />
+ <menulist id="minSize">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="min-size-none" />
+ <menuitem value="9" label="9" />
+ <menuitem value="10" label="10" />
+ <menuitem value="11" label="11" />
+ <menuitem value="12" label="12" />
+ <menuitem value="13" label="13" />
+ <menuitem value="14" label="14" />
+ <menuitem value="15" label="15" />
+ <menuitem value="16" label="16" />
+ <menuitem value="17" label="17" />
+ <menuitem value="18" label="18" />
+ <menuitem value="20" label="20" />
+ <menuitem value="22" label="22" />
+ <menuitem value="24" label="24" />
+ <menuitem value="26" label="26" />
+ <menuitem value="28" label="28" />
+ <menuitem value="30" label="30" />
+ <menuitem value="32" label="32" />
+ <menuitem value="34" label="34" />
+ <menuitem value="36" label="36" />
+ <menuitem value="40" label="40" />
+ <menuitem value="44" label="44" />
+ <menuitem value="48" label="48" />
+ <menuitem value="56" label="56" />
+ <menuitem value="64" label="64" />
+ <menuitem value="72" label="72" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+
+ <html:fieldset>
+ <html:legend data-l10n-id="font-control-legend"></html:legend>
+ <hbox>
+ <checkbox
+ id="useDocumentFonts"
+ data-l10n-id="use-document-fonts-checkbox"
+ preference="browser.display.use_document_fonts"
+ />
+ </hbox>
+ <hbox>
+ <checkbox
+ id="mailFixedWidthMessages"
+ data-l10n-id="use-fixed-width-plain-checkbox"
+ preference="mail.fixed_width_messages"
+ />
+ </hbox>
+ </html:fieldset>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/mail/components/preferences/general.inc.xhtml b/comm/mail/components/preferences/general.inc.xhtml
new file mode 100644
index 0000000000..438624649b
--- /dev/null
+++ b/comm/mail/components/preferences/general.inc.xhtml
@@ -0,0 +1,1096 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ <script src="chrome://communicator/content/utilityOverlay.js" />
+ <script src="chrome://messenger/content/preferences/general.js"/>
+ <script src="chrome://mozapps/content/preferences/fontbuilder.js"/>
+
+ <commandset id="appPaneCommandSet">
+ <command id="cmd_delete"
+ oncommand="gGeneralPane.onDelete();"/>
+ </commandset>
+
+ <keyset id="appPaneKeyset">
+ <key keycode="VK_BACK" modifiers="any" command="cmd_delete"/>
+ <key keycode="VK_DELETE" modifiers="any" command="cmd_delete"/>
+ </keyset>
+
+ <keyset>
+ <key data-l10n-id="focus-search-shortcut" modifiers="accel"
+ oncommand="gGeneralPane.focusFilterBox();"/>
+ <key data-l10n-id="focus-search-shortcut-alt" modifiers="accel"
+ oncommand="gGeneralPane.focusFilterBox();"/>
+ </keyset>
+
+ <stringbundle id="bundlePreferences" src="chrome://messenger/locale/preferences/preferences.properties"/>
+#ifdef HAVE_SHELL_SERVICE
+ <stringbundle id="bundleBrand" src="chrome://branding/locale/brand.properties"/>
+#endif
+ <html:template id="paneGeneral">
+ <hbox id="generalCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="pane-general-title"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="general-legend"></html:legend>
+ <vbox>
+ <hbox align="start">
+ <checkbox id="mailnewsStartPageEnabled"
+ preference="mailnews.start_page.enabled"
+ data-l10n-id="start-page-label"/>
+ </hbox>
+ <hbox align="center" class="input-container">
+ <label data-l10n-id="location-label" control="mailnewsStartPageUrl"/>
+ <html:input id="mailnewsStartPageUrl"
+ type="url"
+ preference="mailnews.start_page.url"/>
+ <button is="highlightable-button" id="browseForStartPageUrl"
+ data-l10n-id="restore-default-label"
+ oncommand="gGeneralPane.restoreDefaultStartPage();">
+ </button>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="default-search-engine"></html:legend>
+ <hbox align="center">
+ <hbox>
+ <menulist id="defaultWebSearch">
+ <menupopup id="defaultWebSearchPopup"/>
+ </menulist>
+ </hbox>
+ <button is="highlightable-button" id="addSearchEngine"
+ data-l10n-id="add-web-search-engine"
+ oncommand="gGeneralPane.addSearchEngine();"/>
+ <button is="highlightable-button" id="removeSearchEngine"
+ data-l10n-id="remove-search-engine"
+ oncommand="gGeneralPane.removeSearchEngine();"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+#ifdef HAVE_SHELL_SERVICE
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="systemDefaultsGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="system-integration-legend"></html:legend>
+ <vbox>
+ <hbox id="checkDefaultBox" align="center">
+ <checkbox id="alwaysCheckDefault"
+ preference="mail.shell.checkDefaultClient"
+ data-l10n-id="always-check-default"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="checkDefaultButton"
+ data-l10n-id="check-default-button"
+ oncommand="gGeneralPane.checkDefaultNow();"
+ preference="pref.general.disable_button.default_mail"
+ search-l10n-ids="
+ system-integration-title.title,
+ system-integration-dialog.buttonlabelaccept,
+ system-integration-dialog.buttonlabelcancel,
+ system-integration-dialog.buttonlabelcancel2,
+ default-client-intro,
+ unset-default-tooltip,
+ checkbox-email-label.label,
+ checkbox-newsgroups-label.label,
+ checkbox-feeds-label.label,
+ system-search-integration-label.label,
+ check-on-startup-label.label"/>
+ </hbox>
+ </hbox>
+#ifdef XP_WIN
+ <hbox align="start">
+ <checkbox data-l10n-id="minimize-to-tray-label"
+ preference="mail.minimizeToTray"/>
+ </hbox>
+#endif
+ <hbox id="searchIntegrationContainer">
+ <checkbox id="searchIntegration"
+ preference="searchintegration.enable"
+ data-l10n-id="search-integration-label"/>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+#endif
+
+ <hbox id="languageAndAppearanceCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-language-and-appearance-header"/>
+ </hbox>
+
+ <!-- Window layout -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="layoutGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="window-layout-legend"></html:legend>
+ <hbox>
+ <checkbox id="drawInTitlebar"
+ data-l10n-id="draw-in-titlebar-label"
+ preference="mail.tabs.drawInTitlebar"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <vbox>
+ <checkbox id="autoHideTabbar"
+ data-l10n-id="auto-hide-tabbar-label"
+ preference="mail.tabs.autoHide"/>
+ <description data-l10n-id="auto-hide-tabbar-description"
+ class="tip-caption indent"/>
+ </vbox>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Fonts and Colors -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="fontsGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="fonts-legend"></html:legend>
+
+ <hbox id="fontSettings" flex="1">
+ <vbox id="fontRow" flex="1">
+ <hbox align="center">
+ <label data-l10n-id="default-font-label" control="defaultFont"/>
+ <hbox flex="1">
+ <menulist id="defaultFont" flex="1" sizetopopup="pref" crop="center">
+ <menupopup crop="center"/>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="default-size-label" control="defaultFontSize"/>
+ <hbox flex="1">
+ <menulist id="defaultFontSize" flex="1">
+ <menupopup crop="center">
+ <menuitem value="9" label="9"/>
+ <menuitem value="10" label="10"/>
+ <menuitem value="11" label="11"/>
+ <menuitem value="12" label="12"/>
+ <menuitem value="13" label="13"/>
+ <menuitem value="14" label="14"/>
+ <menuitem value="15" label="15"/>
+ <menuitem value="16" label="16"/>
+ <menuitem value="17" label="17"/>
+ <menuitem value="18" label="18"/>
+ <menuitem value="20" label="20"/>
+ <menuitem value="22" label="22"/>
+ <menuitem value="24" label="24"/>
+ <menuitem value="26" label="26"/>
+ <menuitem value="28" label="28"/>
+ <menuitem value="30" label="30"/>
+ <menuitem value="32" label="32"/>
+ <menuitem value="34" label="34"/>
+ <menuitem value="36" label="36"/>
+ <menuitem value="40" label="40"/>
+ <menuitem value="44" label="44"/>
+ <menuitem value="48" label="48"/>
+ <menuitem value="56" label="56"/>
+ <menuitem value="64" label="64"/>
+ <menuitem value="72" label="72"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </vbox>
+ <vbox id="colorsRow">
+ <hbox flex="1">
+ <button is="highlightable-button" id="advancedFonts"
+ data-l10n-id="font-options-button"
+ oncommand="gGeneralPane.configureFonts();"
+ flex="1"
+ search-l10n-ids="
+ fonts-label-default-unnamed.label,
+ fonts-dialog-title,
+ fonts-language-legend.value,
+ fonts-proportional-label.value,
+ font-language-group-latin.label,
+ font-language-group-japanese.label,
+ font-language-group-trad-chinese.label,
+ font-language-group-simpl-chinese.label,
+ font-language-group-trad-chinese-hk.label,
+ font-language-group-korean.label,
+ font-language-group-cyrillic.label,
+ font-language-group-el.label,
+ font-language-group-other.label,
+ font-language-group-thai.label,
+ font-language-group-hebrew.label,
+ font-language-group-arabic.label,
+ font-language-group-devanagari.label,
+ font-language-group-tamil.label,
+ font-language-group-armenian.label,
+ font-language-group-bengali.label,
+ font-language-group-canadian.label,
+ font-language-group-ethiopic.label,
+ font-language-group-georgian.label,
+ font-language-group-gujarati.label,
+ font-language-group-gurmukhi.label,
+ font-language-group-khmer.label,
+ font-language-group-malayalam.label,
+ font-language-group-math.label,
+ font-language-group-odia.label,
+ font-language-group-telugu.label,
+ font-language-group-kannada.label,
+ font-language-group-sinhala.label,
+ font-language-group-tibetan.label,
+ default-font-serif.label,
+ default-font-sans-serif.label,
+ font-size-label.value,
+ font-size-monospace-label.value,
+ font-serif-label.value,
+ font-sans-serif-label.value,
+ font-monospace-label.value,
+ font-min-size-label.value,
+ min-size-none.label,
+ font-control-legend,
+ use-document-fonts-checkbox.label,
+ use-fixed-width-plain-checkbox.label,
+ text-encoding-legend,
+ text-encoding-description,
+ font-outgoing-email-label.value,
+ font-incoming-email-label.value,
+ default-font-reply-checkbox.label"/>
+ </hbox>
+ <hbox flex="1">
+ <button is="highlightable-button" id="colors"
+ data-l10n-id="color-options-button"
+ oncommand="gGeneralPane.configureColors();"
+ flex="1"
+ search-l10n-ids="
+ colors-dialog-window2.title,
+ colors-dialog-legend,
+ text-color-label.value,
+ background-color-label.value,
+ use-system-colors.label,
+ colors-link-legend,
+ link-color-label.value,
+ visited-link-color-label.value,
+ underline-link-checkbox.label,
+ override-color-label.value,
+ override-color-always.label,
+ override-color-auto.label,
+ override-color-never.label"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ <hbox>
+ <html:legend data-l10n-id="display-width-legend"></html:legend>
+ </hbox>
+ <hbox>
+ <checkbox id="displayGlyph"
+ preference="mail.display_glyph"
+ data-l10n-id="convert-emoticons-label"/>
+ <spacer flex="1"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <label control="displayText" data-l10n-id="display-text-label"/>
+ <hbox id="displayText" class="indent" align="center" role="group">
+ <label data-l10n-id="style-label" control="mailQuotedStyle"/>
+ <hbox>
+ <menulist id="mailQuotedStyle" preference="mail.quoted_style">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="regular-style-item"/>
+ <menuitem value="1" data-l10n-id="bold-style-item"/>
+ <menuitem value="2" data-l10n-id="italic-style-item"/>
+ <menuitem value="3" data-l10n-id="bold-italic-style-item"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="size-label" control="mailQuotedSize"/>
+ <hbox>
+ <menulist id="mailQuotedSize" preference="mail.quoted_size">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="regular-size-item"/>
+ <menuitem value="1" data-l10n-id="bigger-size-item"/>
+ <menuitem value="2" data-l10n-id="smaller-size-item"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="quoted-text-color" control="citationmenu"/>
+ <html:input type="color" id="citationmenu" preference="mail.citation_color"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Date and time formatting -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="datetime-formatting-legend"></html:legend>
+ <radiogroup id="formatLocale" align="start"
+ preference="intl.regional_prefs.use_os_locales"
+ orient="vertical">
+ <radio id="appLocale"
+ value="false"/>
+ <!-- label and accesskey will be set dynamically -->
+ <radio id="rsLocale"
+ value="true"/>
+ <!-- label and accesskey will be set dynamically -->
+ </radiogroup>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="messengerLanguagesBox" data-category="paneGeneral" hidden="hidden">
+ <html:legend data-l10n-id="language-selector-legend"></html:legend>
+ <vbox align="start">
+ <description flex="1"
+ controls="chooseMessengerLanguage"
+ data-l10n-id="choose-messenger-language-description"/>
+ <hbox>
+ <hbox>
+ <menulist id="primaryMessengerLocale"
+ oncommand="gGeneralPane.onPrimaryMessengerLanguageMenuChange(event)">
+ <menupopup/>
+ </menulist>
+ </hbox>
+ <hbox>
+ <button is="highlightable-button" id="manageMessengerLanguagesButton"
+ class="accessory-button"
+ data-l10n-id="manage-messenger-languages-button"
+ oncommand="gGeneralPane.showMessengerLanguagesSubDialog({search: false})"
+ search-l10n-ids="
+ languages-customize-moveup.label,
+ languages-customize-movedown.label,
+ languages-customize-remove.label,
+ languages-customize-select-language.placeholder,
+ languages-customize-add.label,
+ messenger-languages-window2.title,
+ messenger-languages-description,
+ messenger-languages-search,
+ messenger-languages-searching.label,
+ messenger-languages-downloading.label,
+ messenger-languages-select-language.label,
+ messenger-languages-installed-label,
+ messenger-languages-available-label,
+ messenger-languages-error"/>
+ </hbox>
+ </hbox>
+ </vbox>
+ <hbox id="confirmMessengerLanguage"
+ class="message-bar"
+ align="center"
+ hidden="true">
+ <html:img class="message-bar-icon"
+ src="chrome://global/skin/icons/info.svg" alt="" />
+ <vbox class="message-bar-content-container" align="stretch" flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Scrolling -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="scrollingGroup" data-category="paneGeneral">
+ <html:legend data-l10n-id="scrolling-legend"></html:legend>
+ <hbox>
+ <checkbox id="useAutoScroll"
+ data-l10n-id="autoscroll-label"
+ preference="general.autoScroll"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <checkbox id="useSmoothScrolling"
+ data-l10n-id="smooth-scrolling-label"
+ preference="general.smoothScroll"/>
+ <spacer flex="1"/>
+ </hbox>
+#ifdef MOZ_WIDGET_GTK
+ <hbox>
+ <checkbox id="useOverlayScrollbars"
+ data-l10n-id="browsing-gtk-use-non-overlay-scrollbars"
+ preference="widget.gtk.overlay-scrollbars.enabled"/>
+ <spacer flex="1"/>
+ </hbox>
+#endif
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="incomingMailCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-incoming-mail-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="new-message-arrival"></html:legend>
+#if defined(XP_MACOSX) || defined(XP_WIN)
+ <hbox align="center">
+ <description flex="1" data-l10n-id="change-dock-icon"/>
+ <hbox>
+ <button is="highlightable-button" id="dockOptions"
+ oncommand="gGeneralPane.configureDockOptions();"
+ data-l10n-id="app-icon-options"
+ search-l10n-ids="
+ dock-options-window-dialog2.title,
+ bounce-system-dock-icon.label,
+ dock-icon-legend,
+ dock-icon-show-label.value,
+ count-unread-messages-radio.label,
+ count-new-messages-radio.label,
+ notification-settings-info2"/>
+ </hbox>
+ </hbox>
+#endif
+#ifdef XP_MACOSX
+ <description class="bold" data-l10n-id="notification-settings2"/>
+#else
+ <hbox align="center">
+ <checkbox id="newMailNotificationAlert"
+ data-l10n-id="animated-alert-label"
+ preference="mail.biff.show_alert"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="customizeMailAlert"
+ oncommand="gGeneralPane.customizeMailAlert();"
+ data-l10n-id="customize-alert-label"
+ search-l10n-ids="
+ notifications-dialog-window.title,
+ customize-alert-description,
+ preview-text-checkbox.label,
+ subject-checkbox.label,
+ sender-checkbox.label,
+ open-time-label-before.value,
+ open-time-label-after.value"/>
+ </hbox>
+ </hbox>
+ <hbox align="center" class="indent">
+ <checkbox id="useSystemNotificationAlert"
+ data-l10n-id="biff-use-system-alert"
+ preference="mail.biff.use_system_alert"/>
+ </hbox>
+#ifdef XP_WIN
+ <vbox>
+ <checkbox id="newMailNotificationTrayIcon"
+ preference="mail.biff.show_tray_icon"
+ data-l10n-id="tray-icon-unread-label"/>
+ <description class="indent tip-caption"
+ flex="1"
+ data-l10n-id="tray-icon-unread-description"/>
+ </vbox>
+#endif
+#endif
+
+ <hbox align="center">
+ <checkbox id="newMailNotification"
+ preference="mail.biff.play_sound"
+ data-l10n-id="mail-play-sound-label"
+ oncommand="gGeneralPane.updatePlaySound();"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="playSound"
+ data-l10n-id="mail-play-button"
+ oncommand="gGeneralPane.previewSound();"/>
+ </hbox>
+
+#ifndef XP_MACOSX
+ <radiogroup id="soundType"
+ class="indent"
+ preference="mail.biff.play_sound.type"
+ orient="vertical"
+ oncommand="gGeneralPane.updatePlaySound();"
+ aria-labelledby="newMailNotification">
+ <hbox>
+ <radio id="system"
+ value="0"
+ data-l10n-id="mail-system-sound-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <radio id="custom"
+ value="1"
+ data-l10n-id="mail-custom-sound-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ </radiogroup>
+#endif
+ <hbox align="center" class="input-container">
+ <html:input id="soundUrlLocation"
+ type="text"
+ class="input-filefield indent"
+ readonly="readonly"
+ preference="mail.biff.play_sound.url"
+ preference-editable="true"
+ aria-labelledby="custom"/>
+ <button is="highlightable-button" id="browseForSound"
+ data-l10n-id="mail-browse-sound-button"
+ oncommand="gGeneralPane.browseForSoundFile();"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="filesAttachmentCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-files-and-attachment-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <search-textbox id="filter"
+ data-l10n-id="search-handler-table"
+ data-l10n-attrs="placeholder"
+ aria-controls="handlersTable"
+ oncommand="gGeneralPane._rebuildView();"/>
+ <separator class="thin"/>
+ <html:div id="handlersView"
+ preference="pref.downloads.disable_button.edit_actions">
+ <html:table id="handlersTable">
+ <html:thead>
+ <html:tr>
+ <html:th scope="col"
+ sort-type="type">
+ <html:button class="handlerHeaderButton">
+ <html:span class="handlerHeader"
+ data-l10n-id="type-column-header">
+ </html:span>
+ <html:img class="handlerSortHeaderIcon"
+ alt=""/>
+ </html:button>
+ </html:th>
+ <html:th scope="col"
+ sort-type="action">
+ <html:button class="handlerHeaderButton">
+ <html:span class="handlerHeader"
+ data-l10n-id="action-column-header">
+ </html:span>
+ <html:img class="handlerSortHeaderIcon"
+ alt=""/>
+ </html:button>
+ </html:th>
+ </html:tr>
+ </html:thead>
+ <html:tbody>
+ </html:tbody>
+ </html:table>
+ </html:div>
+
+ <separator class="thin"/>
+
+ <vbox align="start">
+ <radiogroup id="saveWhere" flex="1"
+ preference="browser.download.useDownloadDir">
+ <hbox id="saveToRow" align="center" class="input-container">
+ <radio id="saveTo" value="true"
+ data-l10n-id="save-to-label"
+ aria-labelledby="saveTo downloadFolder"/>
+ <html:input id="downloadFolder"
+ class="input-filefield"
+ type="text"
+ readonly="readonly"
+ aria-labelledby="saveTo"/>
+ <button is="highlightable-button" id="chooseFolder"
+ oncommand="gDownloadDirSection.chooseFolder();"
+ data-l10n-id="choose-folder-label"/>
+ </hbox>
+ <hbox>
+ <radio id="alwaysAsk"
+ value="false"
+ data-l10n-id="always-ask-label"/>
+ </hbox>
+ </radiogroup>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="tagsCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-tags-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <label control="tagList" data-l10n-id="display-tags-text"/>
+ <hbox>
+ <richlistbox id="tagList"
+ flex="1"
+ ondblclick="gGeneralPane.editTag();"
+ onselect="gGeneralPane.onSelectTag();"/>
+ <vbox id="tagButtons">
+ <hbox>
+ <button is="highlightable-button" id="newTagButton"
+ data-l10n-id="new-tag-button"
+ oncommand="gGeneralPane.addTag();"
+ search-l10n-ids="
+ tag-dialog-window.title,
+ tag-name-label.value"/>
+ </hbox>
+ <hbox>
+ <button is="highlightable-button" id="editTagButton"
+ disabled="true"
+ data-l10n-id="edit-tag-button"
+ oncommand="gGeneralPane.editTag();"
+ search-l10n-ids="
+ tag-dialog-window.title,
+ tag-name-label.value"/>
+ </hbox>
+ <button is="highlightable-button" id="removeTagButton"
+ disabled="true"
+ data-l10n-id="delete-tag-button"
+ oncommand="gGeneralPane.removeTag();"/>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="readingAndDisplayCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-reading-and-display-header"/>
+ </hbox>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <vbox>
+ <hbox>
+ <checkbox id="viewAttachmentsInline"
+ data-l10n-id="view-attachments-inline"
+ preference="mail.inline_attachments"/>
+ </hbox>
+
+ <hbox>
+ <checkbox id="automaticallyMarkAsRead"
+ preference="mailnews.mark_message_read.auto"
+ data-l10n-id="auto-mark-as-read"/>
+ </hbox>
+
+ <radiogroup id="markAsReadAutoPreferences" orient="vertical"
+ class="indent"
+ align="start"
+ preference="mailnews.mark_message_read.delay">
+ <radio id="mark_read_immediately"
+ data-l10n-id="mark-read-no-delay"
+ value="false"/>
+ <hbox align="center">
+ <radio id="markAsReadAfterDelay" value="true"
+ data-l10n-id="mark-read-delay"/>
+ <html:input id="markAsReadDelay" type="number" class="size3"
+ min="1" max="2147483"
+ preference="mailnews.mark_message_read.delay.interval"
+ aria-labelledby="markAsReadAfterDelay markAsReadDelay secondsLabel"/>
+ <label id="secondsLabel" data-l10n-id="seconds-label"/>
+ </hbox>
+ </radiogroup>
+ </vbox>
+
+ <separator/>
+
+ <vbox>
+ <hbox>
+ <label data-l10n-id="open-msg-label"
+ control="mailOpenMessageBehavior"/>
+ </hbox>
+ <hbox>
+ <radiogroup id="mailOpenMessageBehavior" class="indent"
+ preference="mail.openMessageBehavior"
+ orient="horizontal">
+ <radio id="newTab" value="2" data-l10n-id="open-msg-tab"/>
+ <radio id="newWindow" value="0" data-l10n-id="open-msg-window"/>
+ <radio id="existingWindow" value="1"
+ data-l10n-id="open-msg-ex-window"/>
+ </radiogroup>
+ </hbox>
+ <hbox>
+ <checkbox id="closeMsgOnMoveOrDelete"
+ data-l10n-id="close-move-delete"
+ preference="mail.close_message_window.on_delete"/>
+ </hbox>
+ </vbox>
+
+ <separator/>
+
+ <hbox>
+ <label data-l10n-id="display-name-label"/>
+ </hbox>
+ <hbox>
+ <checkbox id="showCondensedAddresses"
+ data-l10n-id="condensed-addresses-label"
+ preference="mail.showCondensedAddresses"/>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <description flex="1" data-l10n-id="return-receipts-description"/>
+ <hbox>
+ <button is="highlightable-button" id="showReturnReceipts"
+ data-l10n-id="return-receipts-button"
+ oncommand="gGeneralPane.showReturnReceipts();"
+ search-l10n-ids="
+ receipts-dialog-window.title,
+ return-receipt-checkbox-control.label,
+ receipt-arrive-label,
+ receipt-leave-radio-control.label,
+ receipt-move-radio-control.label,
+ receipt-request-label,
+ receipt-return-never-radio-control.label,
+ receipt-return-some-radio-control.label,
+ receipt-not-to-cc-label.value,
+ receipt-send-never-label.label,
+ receipt-send-always-label.label,
+ receipt-send-ask-label.label,
+ sender-outside-domain-label.value,
+ other-cases-text-label.value"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+#ifdef MOZ_UPDATER
+ <hbox id="updatesCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-updates-header"/>
+ </hbox>
+
+ <!-- Update -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset id="updateApp" data-category="paneGeneral">
+ <html:legend data-l10n-id="update-app-legend"></html:legend>
+ <hbox align="center">
+ <vbox>
+ <description>
+ <label id="version"/>
+ <label is="text-link" id="releasenotes" hidden="true" data-l10n-id="release-notes-link"></label>
+ </description>
+ <description id="distribution" class="text-blurb" hidden="true"/>
+ <description id="distributionId" class="text-blurb" hidden="true"/>
+ </vbox>
+ <spacer flex="1"/>
+ <vbox>
+ <hbox>
+ <button is="highlightable-button" id="showUpdateHistory"
+ data-l10n-id="update-history-button"
+ preference="app.update.disable_button.showUpdateHistory"
+ oncommand="gGeneralPane.showUpdates();"
+ search-l10n-ids="
+ history-title,
+ history-intro,
+ close-button-label.buttonlabelcancel,
+ close-button-label.title,
+ no-updates-label,
+ name-header,
+ date-header,
+ type-header,
+ state-header"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ <hbox id="updateBox">
+ <deck id="updateDeck" orient="vertical" flex="1">
+ <html:div id="checkForUpdates" class="update-deck-container">
+ <html:button id="checkForUpdatesButton"
+ data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="downloadAndInstall" class="update-deck-container">
+ <html:button id="downloadAndInstallButton"
+ onclick="gAppUpdater.startDownload();">
+ </html:button>
+ </html:div>
+ <html:div id="apply" class="update-deck-container">
+ <html:button id="updateButton"
+ data-l10n-id="update-update-button"
+ onclick="gAppUpdater.buttonRestartAfterDownload();">
+ </html:button>
+ </html:div>
+ <html:div id="checkingForUpdates" class="update-deck-container">
+ <html:img class="update-throbber" alt="" />
+ <html:span data-l10n-id="update-checking-for-updates"></html:span>
+ <!-- Button is only used for presentation in reference to the actual
+ - button that triggered this action, which would now be
+ - invisible -->
+ <html:button data-l10n-id="update-check-for-updates-button"
+ disabled="true">
+ </html:button>
+ </html:div>
+ <html:div id="downloading" class="update-deck-container"
+ data-l10n-id="update-downloading">
+ <html:img class="update-throbber" data-l10n-name="icon"></html:img>
+ <!-- Group within a single span to center align with icon. -->
+ <html:span id="downloadStatus" data-l10n-name="download-status"></html:span>
+ </html:div>
+ <html:div id="applying" class="update-deck-container">
+ <html:img class="update-throbber"/>
+ <html:span data-l10n-id="update-applying"></html:span>
+ </html:div>
+ <html:div id="downloadFailed" class="update-deck-container"
+ data-l10n-id="update-failed">
+ <html:a id="failedLink" class="text-link download-link"
+ data-l10n-name="failed-link"></html:a>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="policyDisabled" class="update-deck-container">
+ <html:span data-l10n-id="update-admin-disabled"></html:span>
+ </html:div>
+ <html:div id="noUpdatesFound" class="update-deck-container">
+ <html:span data-l10n-id="update-no-updates-found"></html:span>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="checkingFailed" class="update-deck-container">
+ <html:span data-l10n-id="aboutdialog-update-checking-failed"></html:span>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="otherInstanceHandlingUpdates"
+ class="update-deck-container">
+ <html:span data-l10n-id="update-other-instance-handling-updates"></html:span>
+ </html:div>
+ <html:div id="manualUpdate" class="update-deck-container"
+ data-l10n-id="update-manual">
+ <html:a id="manualLink" class="manualLink text-link download-link"
+ data-l10n-name="manual-link"></html:a>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ <html:div id="unsupportedSystem" class="update-deck-container" data-l10n-id="update-unsupported">
+ <html:a id="unsupportedLink" class="text-link download-link"
+ data-l10n-name="unsupported-link"></html:a>
+ </html:div>
+ <html:div id="restarting" class="update-deck-container">
+ <html:span class="update-throbber"></html:span>
+ <html:span data-l10n-id="update-restarting"></html:span>
+ </html:div>
+ <html:div id="internalError" class="update-deck-container"
+ data-l10n-id="update-internal-error">
+ <html:a id="internalErrorLink" class="manualLink text-link download-link"
+ data-l10n-name="manual-link"></html:a>
+ <html:button data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </html:button>
+ </html:div>
+ </deck>
+ </hbox>
+ <separator/>
+ <description id="updateAllowDescription" data-l10n-id="allow-description"/>
+ <vbox id="updateSettingsContainer">
+ <radiogroup id="updateRadioGroup"
+ align="start">
+ <radio id="autoDesktop"
+ value="true"
+ data-l10n-id="automatic-updates-label"/>
+ <radio id="manualDesktop"
+ value="false"
+ data-l10n-id="check-updates-label"/>
+ </radiogroup>
+ <description id="updateSettingCrossUserWarning"
+ data-l10n-id="cross-user-udpate-warning"
+ hidden="true"/>
+ </vbox>
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+ <separator class="thin"/>
+ <checkbox id="useService"
+ data-l10n-id="use-service"
+ preference="app.update.service.enabled"/>
+#endif
+ </html:fieldset>
+ </html:div>
+#endif
+
+ <hbox id="networkAndDiskspaceCategory"
+ class="subcategory"
+ data-category="paneGeneral">
+ <html:h1 data-l10n-id="general-network-and-diskspace-header"/>
+ </hbox>
+
+ <!-- Networking & Disk Space -->
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="networking-legend"></html:legend>
+ <hbox align="center">
+ <description control="catProxiesButton"
+ data-l10n-id="proxy-config-description"
+ flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="catProxiesButton"
+ data-l10n-id="network-settings-button"
+ oncommand="gGeneralPane.showConnections();"
+ search-l10n-ids="
+ connection-dns-over-https-url-resolver,
+ connection-dns-over-https-url-custom.label,
+ connection-dns-over-https-custom-label,
+ connection-proxy-legend,
+ proxy-type-no.label,
+ proxy-type-wpad.label,
+ proxy-type-system.label,
+ proxy-type-manual.label,
+ proxy-http-label.value,
+ http-port-label.value,
+ proxy-http-sharing.label,
+ proxy-https-label.value,
+ ssl-port-label.value,
+ proxy-socks-label.value,
+ socks-port-label.value,
+ proxy-socks4-label.label,
+ proxy-socks5-label.label,
+ proxy-type-auto.label,
+ proxy-reload-label.label,
+ no-proxy-label.value,
+ no-proxy-example,
+ connection-proxy-noproxy-localhost-desc-2,
+ proxy-password-prompt.label,
+ proxy-remote-dns.label,
+ proxy-enable-doh.label"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="offline-legend"></html:legend>
+ <hbox align="center">
+ <description data-l10n-id="offline-settings"
+ control="offlineSettingsButton"
+ flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="offlineSettingsButton"
+ data-l10n-id="offline-settings-button"
+ oncommand="gGeneralPane.showOffline();"
+ search-l10n-ids="
+ offline-dialog-window.title,
+ autodetect-online-label.label,
+ startup-label,
+ status-radio-remember.label,
+ status-radio-ask.label,
+ status-radio-always-online.label,
+ status-radio-always-offline.label,
+ going-online-label,
+ going-online-auto.label,
+ going-online-not.label,
+ going-online-ask.label,
+ going-offline-label,
+ going-offline-auto.label,
+ going-offline-not.label,
+ going-offline-ask.label"/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="diskspace-legend"></html:legend>
+ <hbox align="center">
+ <label id="actualDiskCacheSize" flex="1"/>
+ <button is="highlightable-button" id="clearCacheButton"
+ data-l10n-id="clear-cache-button"
+ oncommand="gGeneralPane.clearCache();"/>
+ </hbox>
+ <hbox>
+ <checkbox id="clearCacheOnShutdown"
+ preference="privacy.clearOnShutdown.cache"
+ data-l10n-id="clear-cache-shutdown-label"/>
+ </hbox>
+ <hbox>
+ <checkbox id="allowSmartSize"
+ preference="browser.cache.disk.smart_size.enabled"
+ data-l10n-id="smart-cache-label"/>
+ </hbox>
+ <hbox align="center" class="indent">
+ <label id="useCacheBefore" control="cacheSize"
+ data-l10n-id="use-cache-before"/>
+ <html:input id="cacheSize" type="number" class="size4" max="1024"
+ preference="browser.cache.disk.capacity"
+ aria-labelledby="useCacheBefore cacheSize useCacheAfter"/>
+ <label id="useCacheAfter" data-l10n-id="use-cache-after" flex="1"/>
+ </hbox>
+ <hbox align="center">
+ <checkbox id="offlineCompactFolder"
+ data-l10n-id="offline-compact-folder"
+ aria-labelledby="offlineCompactFolder offlineCompactFolderMin compactFolderMB"
+ preference="mail.prompt_purge_threshhold"
+ oncommand="gGeneralPane.updateCompactOptions();"/>
+ <html:input id="offlineCompactFolderMin" type="number" class="size4"
+ min="1" max="2048" value="200"
+ preference="mail.purge_threshhold_mb"
+ aria-labelledby="offlineCompactFolder offlineCompactFolderMin compactFolderMB"/>
+ <label id="compactFolderMB" data-l10n-id="compact-folder-size" value=""/>
+ </hbox>
+ <hbox align="center" class="indent">
+ <checkbox id="offlineCompactFolderAutomatically"
+ data-l10n-id="offline-compact-folder-automatically"
+ preference="mail.purge.ask"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <html:legend data-l10n-id="general-indexing-label"></html:legend>
+ <vbox>
+ <hbox>
+ <checkbox id="enableGloda"
+ preference="mailnews.database.global.indexer.enabled"
+ data-l10n-id="enable-gloda-search-label"/>
+ </hbox>
+ <hbox align="center">
+ <label control="storeTypeMenulist" data-l10n-id="store-type-label"/>
+ <hbox>
+ <menulist id="storeTypeMenulist"
+ oncommand="gGeneralPane.updateDefaultStore(this.selectedItem.value)">
+ <menupopup id="storeTypeMenupopup">
+ <menuitem id="mboxStore"
+ data-l10n-id="mbox-store-label"
+ value="@mozilla.org/msgstore/berkeleystore;1"/>
+ <menuitem id="maildirStore"
+ data-l10n-id="maildir-store-label"
+ value="@mozilla.org/msgstore/maildirstore;1"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <hbox>
+ <checkbox id="allowHWAccel"
+ preference="layers.acceleration.disabled"
+ data-l10n-id="allow-hw-accel"/>
+ </hbox>
+ </vbox>
+ <vbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" data-category="paneGeneral"/>
+
+ <html:div data-category="paneGeneral">
+ <html:fieldset data-category="paneGeneral">
+ <hbox pack="end">
+ <hbox>
+ <button is="highlightable-button" id="configEditor"
+ data-l10n-id="config-editor-button"
+ oncommand="gGeneralPane.showConfigEdit();"
+ searchkeywords="about:config"
+ search-l10n-ids="
+ about-config-page-title,
+ about-config-search-input1.placeholder,
+ about-config-show-all,
+ about-config-pref-add-button.title,
+ about-config-pref-toggle-button.title,
+ about-config-pref-edit-button.title,
+ about-config-pref-save-button.title,
+ about-config-pref-reset-button.title,
+ about-config-pref-delete-button.title,
+ about-config-pref-add-type-boolean,
+ about-config-pref-add-type-number,
+ about-config-pref-add-type-string
+ "/>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ </html:template>
diff --git a/comm/mail/components/preferences/general.js b/comm/mail/components/preferences/general.js
new file mode 100644
index 0000000000..96e8aaad2d
--- /dev/null
+++ b/comm/mail/components/preferences/general.js
@@ -0,0 +1,2962 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../../base/content/aboutDialog-appUpdater.js */
+/* import-globals-from ../../../../toolkit/mozapps/preferences/fontbuilder.js */
+/* import-globals-from preferences.js */
+
+// ------------------------------
+// Constants & Enumeration Values
+
+var { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ gHandlerService: [
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService",
+ ],
+ gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
+});
+
+XPCOMUtils.defineLazyGetter(this, "gIsPackagedApp", () => {
+ return Services.sysinfo.getProperty("isPackagedApp");
+});
+
+const TYPE_PDF = "application/pdf";
+
+const PREF_PDFJS_DISABLED = "pdfjs.disabled";
+
+const AUTO_UPDATE_CHANGED_TOPIC = "auto-update-config-change";
+
+Preferences.addAll([
+ { id: "mail.pane_config.dynamic", type: "int" },
+ { id: "mailnews.reuse_message_window", type: "bool" },
+ { id: "mailnews.start_page.enabled", type: "bool" },
+ { id: "mailnews.start_page.url", type: "string" },
+ { id: "mail.biff.show_tray_icon", type: "bool" },
+ { id: "mail.biff.play_sound", type: "bool" },
+ { id: "mail.biff.play_sound.type", type: "int" },
+ { id: "mail.biff.play_sound.url", type: "string" },
+ { id: "mail.biff.use_system_alert", type: "bool" },
+ { id: "general.autoScroll", type: "bool" },
+ { id: "general.smoothScroll", type: "bool" },
+ { id: "widget.gtk.overlay-scrollbars.enabled", type: "bool", inverted: true },
+ { id: "mail.fixed_width_messages", type: "bool" },
+ { id: "mail.inline_attachments", type: "bool" },
+ { id: "mail.quoted_style", type: "int" },
+ { id: "mail.quoted_size", type: "int" },
+ { id: "mail.citation_color", type: "string" },
+ { id: "mail.display_glyph", type: "bool" },
+ { id: "font.language.group", type: "wstring" },
+ { id: "intl.regional_prefs.use_os_locales", type: "bool" },
+ { id: "mailnews.database.global.indexer.enabled", type: "bool" },
+ { id: "mailnews.labels.description.1", type: "wstring" },
+ { id: "mailnews.labels.color.1", type: "string" },
+ { id: "mailnews.labels.description.2", type: "wstring" },
+ { id: "mailnews.labels.color.2", type: "string" },
+ { id: "mailnews.labels.description.3", type: "wstring" },
+ { id: "mailnews.labels.color.3", type: "string" },
+ { id: "mailnews.labels.description.4", type: "wstring" },
+ { id: "mailnews.labels.color.4", type: "string" },
+ { id: "mailnews.labels.description.5", type: "wstring" },
+ { id: "mailnews.labels.color.5", type: "string" },
+ { id: "mail.showCondensedAddresses", type: "bool" },
+ { id: "mailnews.mark_message_read.auto", type: "bool" },
+ { id: "mailnews.mark_message_read.delay", type: "bool" },
+ { id: "mailnews.mark_message_read.delay.interval", type: "int" },
+ { id: "mail.openMessageBehavior", type: "int" },
+ { id: "mail.close_message_window.on_delete", type: "bool" },
+ { id: "mail.prompt_purge_threshhold", type: "bool" },
+ { id: "mail.purge.ask", type: "bool" },
+ { id: "mail.purge_threshhold_mb", type: "int" },
+ { id: "browser.cache.disk.capacity", type: "int" },
+ { id: "browser.cache.disk.smart_size.enabled", inverted: true, type: "bool" },
+ { id: "privacy.clearOnShutdown.cache", type: "bool" },
+ { id: "layers.acceleration.disabled", type: "bool", inverted: true },
+ { id: "searchintegration.enable", type: "bool" },
+ { id: "mail.tabs.drawInTitlebar", type: "bool" },
+ { id: "mail.tabs.autoHide", type: "bool" },
+]);
+if (AppConstants.platform == "win") {
+ Preferences.add({ id: "mail.minimizeToTray", type: "bool" });
+}
+if (AppConstants.platform != "macosx") {
+ Preferences.add({ id: "mail.biff.show_alert", type: "bool" });
+}
+
+var ICON_URL_APP = "";
+
+if (AppConstants.MOZ_WIDGET_GTK) {
+ ICON_URL_APP = "moz-icon://dummy.exe?size=16";
+} else {
+ ICON_URL_APP = "chrome://messenger/skin/preferences/application.png";
+}
+
+if (AppConstants.HAVE_SHELL_SERVICE) {
+ Preferences.addAll([
+ { id: "mail.shell.checkDefaultClient", type: "bool" },
+ { id: "pref.general.disable_button.default_mail", type: "bool" },
+ ]);
+}
+
+if (AppConstants.MOZ_UPDATER) {
+ Preferences.add({
+ id: "app.update.disable_button.showUpdateHistory",
+ type: "bool",
+ });
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ Preferences.add({ id: "app.update.service.enabled", type: "bool" });
+ }
+}
+
+var gGeneralPane = {
+ // The set of types the app knows how to handle. A map of HandlerInfoWrapper
+ // objects, indexed by type.
+ _handledTypes: new Map(),
+ // Map from a handlerInfoWrapper to the corresponding table HandlerRow.
+ _handlerRows: new Map(),
+ _handlerMenuId: 0,
+
+ // The list of types we can show, sorted by the sort column/direction.
+ // An array of HandlerInfoWrapper objects. We build this list when we first
+ // load the data and then rebuild it when users change a pref that affects
+ // what types we can show or change the sort column/direction.
+ // Note: this isn't necessarily the list of types we *will* show; if the user
+ // provides a filter string, we'll only show the subset of types in this list
+ // that match that string.
+ _visibleTypes: [],
+
+ // Map whose keys are string descriptions and values are references to the
+ // first visible HandlerInfoWrapper that has this description. We use this
+ // to determine whether or not to annotate descriptions with their types to
+ // distinguish duplicate descriptions from each other.
+ _visibleDescriptions: new Map(),
+
+ // -----------------------------------
+ // Convenience & Performance Shortcuts
+
+ // These get defined by init().
+ _brandShortName: null,
+ _handlerTbody: null,
+ _filter: null,
+ _prefsBundle: null,
+ mPane: null,
+ mStartPageUrl: "",
+ mShellServiceWorking: false,
+ mTagListBox: null,
+ requestingLocales: null,
+
+ async init() {
+ function setEventListener(aId, aEventType, aCallback) {
+ document
+ .getElementById(aId)
+ .addEventListener(aEventType, aCallback.bind(gGeneralPane));
+ }
+
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("saveWhere"),
+ () => gDownloadDirSection.onReadUseDownloadDir()
+ );
+
+ this.mPane = document.getElementById("paneGeneral");
+ this._prefsBundle = document.getElementById("bundlePreferences");
+ this._brandShortName = document
+ .getElementById("bundleBrand")
+ .getString("brandShortName");
+ this._handlerTbody = document.querySelector("#handlersTable > tbody");
+ this._filter = document.getElementById("filter");
+
+ this._handlerSort = { type: "type", descending: false };
+ this._handlerSortHeaders = document.querySelectorAll(
+ "#handlersTable > thead th[sort-type]"
+ );
+ for (let header of this._handlerSortHeaders) {
+ let button = header.querySelector("button");
+ button.addEventListener(
+ "click",
+ this.sort.bind(this, header.getAttribute("sort-type"))
+ );
+ }
+
+ this.updateStartPage();
+ this.updatePlaySound(
+ !Preferences.get("mail.biff.play_sound").value,
+ Preferences.get("mail.biff.play_sound.url").value,
+ Preferences.get("mail.biff.play_sound.type").value
+ );
+ if (AppConstants.platform != "macosx") {
+ this.updateShowAlert();
+ }
+ this.updateWebSearch();
+
+ // Search integration -- check whether we should hide or disable integration
+ let hideSearchUI = false;
+ let disableSearchUI = false;
+ const { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+ );
+ if (SearchIntegration) {
+ if (SearchIntegration.osVersionTooLow) {
+ hideSearchUI = true;
+ } else if (SearchIntegration.osComponentsNotRunning) {
+ disableSearchUI = true;
+ }
+ } else {
+ hideSearchUI = true;
+ }
+
+ if (hideSearchUI) {
+ document.getElementById("searchIntegrationContainer").hidden = true;
+ } else if (disableSearchUI) {
+ let searchCheckbox = document.getElementById("searchIntegration");
+ searchCheckbox.checked = false;
+ Preferences.get("searchintegration.enable").disabled = true;
+ }
+
+ // If the shell service is not working, disable the "Check now" button
+ // and "perform check at startup" checkbox.
+ try {
+ Cc["@mozilla.org/mail/shell-service;1"].getService(Ci.nsIShellService);
+ this.mShellServiceWorking = true;
+ } catch (ex) {
+ // The elements may not exist if HAVE_SHELL_SERVICE is off.
+ if (document.getElementById("alwaysCheckDefault")) {
+ document.getElementById("alwaysCheckDefault").disabled = true;
+ document.getElementById("alwaysCheckDefault").checked = false;
+ }
+ if (document.getElementById("checkDefaultButton")) {
+ document.getElementById("checkDefaultButton").disabled = true;
+ }
+ this.mShellServiceWorking = false;
+ }
+ this._rebuildFonts();
+
+ var menulist = document.getElementById("defaultFont");
+ if (menulist.selectedIndex == -1) {
+ // Prepend menuitem with empty name and value.
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", "");
+ item.setAttribute("value", "");
+ menulist.menupopup.insertBefore(
+ item,
+ menulist.menupopup.firstElementChild
+ );
+ menulist.selectedIndex = 0;
+ }
+
+ this.formatLocaleSetLabels();
+
+ if (Services.prefs.getBoolPref("intl.multilingual.enabled")) {
+ this.initPrimaryMessengerLanguageUI();
+ }
+
+ this.mTagListBox = document.getElementById("tagList");
+ this.buildTagList();
+ this.updateMarkAsReadOptions();
+
+ document.getElementById("citationmenu").value = Preferences.get(
+ "mail.citation_color"
+ ).value;
+
+ // By doing this in a timeout, we let the preferences dialog resize itself
+ // to an appropriate size before we add a bunch of items to the list.
+ // Otherwise, if there are many items, and the Applications prefpane
+ // is the one that gets displayed when the user first opens the dialog,
+ // the dialog might stretch too much in an attempt to fit them all in.
+ // XXX Shouldn't we perhaps just set a max-height on the richlistbox?
+ var _delayedPaneLoad = function (self) {
+ self._loadAppHandlerData();
+ self._rebuildVisibleTypes();
+ self._sortVisibleTypes();
+ self._rebuildView();
+
+ // Notify observers that the UI is now ready
+ Services.obs.notifyObservers(window, "app-handler-pane-loaded");
+ };
+ this.updateActualCacheSize();
+ this.updateCompactOptions();
+
+ // Default store type initialization.
+ let storeTypeElement = document.getElementById("storeTypeMenulist");
+ // set the menuitem to match the account
+ let defaultStoreID = Services.prefs.getCharPref(
+ "mail.serverDefaultStoreContractID"
+ );
+ let targetItem = storeTypeElement.getElementsByAttribute(
+ "value",
+ defaultStoreID
+ );
+ storeTypeElement.selectedItem = targetItem[0];
+ setTimeout(_delayedPaneLoad, 0, this);
+
+ if (AppConstants.MOZ_UPDATER) {
+ this.updateReadPrefs();
+ gAppUpdater = new appUpdater(); // eslint-disable-line no-global-assign
+ let updateDisabled =
+ Services.policies && !Services.policies.isAllowed("appUpdate");
+
+ if (gIsPackagedApp) {
+ // When we're running inside an app package, there's no point in
+ // displaying any update content here, and it would get confusing if we
+ // did, because our updater is not enabled.
+ // We can't rely on the hidden attribute for the toplevel elements,
+ // because of the pane hiding/showing code interfering.
+ document
+ .getElementById("updatesCategory")
+ .setAttribute("style", "display: none !important");
+ document
+ .getElementById("updateApp")
+ .setAttribute("style", "display: none !important");
+ } else if (updateDisabled || UpdateUtils.appUpdateAutoSettingIsLocked()) {
+ document.getElementById("updateAllowDescription").hidden = true;
+ document.getElementById("updateSettingsContainer").hidden = true;
+ if (updateDisabled && AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ document.getElementById("useService").hidden = true;
+ }
+ } else {
+ // Start with no option selected since we are still reading the value
+ document.getElementById("autoDesktop").removeAttribute("selected");
+ document.getElementById("manualDesktop").removeAttribute("selected");
+ // Start reading the correct value from the disk
+ this.updateReadPrefs();
+ setEventListener(
+ "updateRadioGroup",
+ "command",
+ gGeneralPane.updateWritePrefs
+ );
+ }
+
+ let defaults = Services.prefs.getDefaultBranch(null);
+ let distroId = defaults.getCharPref("distribution.id", "");
+ if (distroId) {
+ let distroVersion = defaults.getCharPref("distribution.version", "");
+
+ let distroIdField = document.getElementById("distributionId");
+ distroIdField.value = distroId + " - " + distroVersion;
+ distroIdField.style.display = "block";
+
+ let distroAbout = defaults.getStringPref("distribution.about", "");
+ if (distroAbout) {
+ let distroField = document.getElementById("distribution");
+ distroField.value = distroAbout;
+ distroField.style.display = "block";
+ }
+ }
+
+ if (AppConstants.platform == "win") {
+ // On Windows, the Application Update setting is an installation-
+ // specific preference, not a profile-specific one. Show a warning to
+ // inform users of this.
+ let updateContainer = document.getElementById(
+ "updateSettingsContainer"
+ );
+ updateContainer.classList.add("updateSettingCrossUserWarningContainer");
+ document.getElementById("updateSettingCrossUserWarning").hidden = false;
+ }
+
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ // Check to see if the maintenance service is installed.
+ // If it isn't installed, don't show the preference at all.
+ let installed;
+ try {
+ let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ wrk.open(
+ wrk.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\MaintenanceService",
+ wrk.ACCESS_READ | wrk.WOW64_64
+ );
+ installed = wrk.readIntValue("Installed");
+ wrk.close();
+ } catch (e) {}
+ if (installed != 1) {
+ document.getElementById("useService").hidden = true;
+ }
+ }
+
+ let version = AppConstants.MOZ_APP_VERSION_DISPLAY;
+
+ // Include the build ID and display warning if this is an "a#" (nightly) build
+ if (/a\d+$/.test(version)) {
+ let buildID = Services.appinfo.appBuildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ version += ` (${year}-${month}-${day})`;
+ }
+
+ // Append "(32-bit)" or "(64-bit)" build architecture to the version number:
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let archResource = Services.appinfo.is64Bit
+ ? "aboutDialog.architecture.sixtyFourBit"
+ : "aboutDialog.architecture.thirtyTwoBit";
+ let arch = bundle.GetStringFromName(archResource);
+ version += ` (${arch})`;
+
+ document.l10n.setAttributes(
+ document.getElementById("version"),
+ "update-app-version",
+ { version }
+ );
+
+ if (!AppConstants.NIGHTLY_BUILD) {
+ // Show a release notes link if we have a URL.
+ let relNotesLink = document.getElementById("releasenotes");
+ let relNotesPrefType = Services.prefs.getPrefType(
+ "app.releaseNotesURL"
+ );
+ if (relNotesPrefType != Services.prefs.PREF_INVALID) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL"
+ );
+ if (relNotesURL != "about:blank") {
+ relNotesLink.href = relNotesURL;
+ relNotesLink.hidden = false;
+ }
+ }
+ }
+ // Initialize Application section.
+
+ // Listen for window unload so we can remove our preference observers.
+ window.addEventListener("unload", this);
+
+ Services.obs.addObserver(this, AUTO_UPDATE_CHANGED_TOPIC);
+ Services.prefs.addObserver("mailnews.tags.", this);
+ }
+
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("allowSmartSize"),
+ () => this.readSmartSizeEnabled()
+ );
+
+ let element = document.getElementById("cacheSize");
+ Preferences.addSyncFromPrefListener(element, () => this.readCacheSize());
+ Preferences.addSyncToPrefListener(element, () => this.writeCacheSize());
+ Preferences.addSyncFromPrefListener(menulist, () =>
+ this.readFontSelection()
+ );
+ Preferences.addSyncFromPrefListener(
+ document.getElementById("soundUrlLocation"),
+ () => this.readSoundLocation()
+ );
+
+ if (!Services.policies.isAllowed("about:config")) {
+ document.getElementById("configEditor").disabled = true;
+ }
+ },
+
+ /**
+ * Restores the default start page as the user's start page
+ */
+ restoreDefaultStartPage() {
+ var startPage = Preferences.get("mailnews.start_page.url");
+ startPage.value = startPage.defaultValue;
+ },
+
+ /**
+ * Returns a formatted url corresponding to the value of mailnews.start_page.url
+ * Stores the original value of mailnews.start_page.url
+ */
+ readStartPageUrl() {
+ var pref = Preferences.get("mailnews.start_page.url");
+ this.mStartPageUrl = pref.value;
+ return Services.urlFormatter.formatURL(this.mStartPageUrl);
+ },
+
+ /**
+ * Returns the value of the mailnews start page url represented by the UI.
+ * If the url matches the formatted version of our stored value, then
+ * return the unformatted url.
+ */
+ writeStartPageUrl() {
+ var startPage = document.getElementById("mailnewsStartPageUrl");
+ return Services.urlFormatter.formatURL(this.mStartPageUrl) ==
+ startPage.value
+ ? this.mStartPageUrl
+ : startPage.value;
+ },
+
+ customizeMailAlert() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/notifications.xhtml",
+ { features: "resizable=no" }
+ );
+ },
+
+ configureDockOptions() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/dockoptions.xhtml",
+ { features: "resizable=no" }
+ );
+ },
+
+ convertURLToLocalFile(aFileURL) {
+ // convert the file url into a nsIFile
+ if (aFileURL) {
+ return Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(aFileURL);
+ }
+ return null;
+ },
+
+ readSoundLocation() {
+ var soundUrlLocation = document.getElementById("soundUrlLocation");
+ soundUrlLocation.value = Preferences.get("mail.biff.play_sound.url").value;
+ if (soundUrlLocation.value) {
+ soundUrlLocation.label = this.convertURLToLocalFile(
+ soundUrlLocation.value
+ ).leafName;
+ soundUrlLocation.style.backgroundImage =
+ "url(moz-icon://" + soundUrlLocation.label + "?size=16)";
+ }
+ },
+
+ previewSound() {
+ let sound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound);
+
+ let soundLocation;
+ // soundType radio-group isn't used for macOS so it is not in the XUL file
+ // for the platform.
+ soundLocation =
+ AppConstants.platform == "macosx" ||
+ document.getElementById("soundType").value == 1
+ ? document.getElementById("soundUrlLocation").value
+ : "";
+
+ if (!soundLocation.includes("file://")) {
+ // User has not set any custom sound file to be played
+ sound.playEventSound(Ci.nsISound.EVENT_NEW_MAIL_RECEIVED);
+ } else {
+ // User has set a custom audio file to be played along the alert.
+ sound.play(Services.io.newURI(soundLocation));
+ }
+ },
+
+ browseForSoundFile() {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ // if we already have a sound file, then use the path for that sound file
+ // as the initial path in the dialog.
+ var localFile = this.convertURLToLocalFile(
+ document.getElementById("soundUrlLocation").value
+ );
+ if (localFile) {
+ fp.displayDirectory = localFile.parent;
+ }
+
+ // XXX todo, persist the last sound directory and pass it in
+ fp.init(
+ window,
+ document
+ .getElementById("bundlePreferences")
+ .getString("soundFilePickerTitle"),
+ Ci.nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterAudio);
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+ // convert the nsIFile into a nsIFile url
+ Preferences.get("mail.biff.play_sound.url").value = fp.fileURL.spec;
+ this.readSoundLocation(); // XXX We shouldn't have to be doing this by hand
+ this.updatePlaySound();
+ });
+ },
+
+ updatePlaySound(soundsDisabled, soundUrlLocation, soundType) {
+ // Update the sound type radio buttons based on the state of the
+ // play sound checkbox.
+ if (soundsDisabled === undefined) {
+ soundsDisabled = !document.getElementById("newMailNotification").checked;
+ soundUrlLocation = document.getElementById("soundUrlLocation").value;
+ }
+
+ // The UI is different on OS X as the user can only choose between letting
+ // the system play a default sound or setting a custom one. Therefore,
+ // "soundTypeEl" does not exist on OS X.
+ if (AppConstants.platform != "macosx") {
+ var soundTypeEl = document.getElementById("soundType");
+ if (soundType === undefined) {
+ soundType = soundTypeEl.value;
+ }
+
+ soundTypeEl.disabled = soundsDisabled;
+ document.getElementById("soundUrlLocation").disabled =
+ soundsDisabled || soundType != 1;
+ document.getElementById("browseForSound").disabled =
+ soundsDisabled || soundType != 1;
+ document.getElementById("playSound").disabled =
+ soundsDisabled || (!soundUrlLocation && soundType != 0);
+ } else {
+ // On OS X, if there is no selected custom sound then default one will
+ // be played. We keep consistency by disabling the "Play sound" checkbox
+ // if the user hasn't selected a custom sound file yet.
+ document.getElementById("newMailNotification").disabled =
+ !soundUrlLocation;
+ document.getElementById("playSound").disabled = !soundUrlLocation;
+ // The sound type radiogroup is hidden, but we have to keep the
+ // play_sound.type pref set appropriately.
+ Preferences.get("mail.biff.play_sound.type").value =
+ !soundsDisabled && soundUrlLocation ? 1 : 0;
+ }
+ },
+
+ updateStartPage() {
+ document.getElementById("mailnewsStartPageUrl").disabled = !Preferences.get(
+ "mailnews.start_page.enabled"
+ ).value;
+ document.getElementById("browseForStartPageUrl").disabled =
+ !Preferences.get("mailnews.start_page.enabled").value;
+ },
+
+ updateShowAlert() {
+ // The button does not exist on all platforms.
+ let customizeAlertButton = document.getElementById("customizeMailAlert");
+ if (customizeAlertButton) {
+ customizeAlertButton.disabled = !Preferences.get("mail.biff.show_alert")
+ .value;
+ }
+ // The checkmark does not exist on all platforms.
+ let systemNotification = document.getElementById(
+ "useSystemNotificationAlert"
+ );
+ if (systemNotification) {
+ systemNotification.disabled = !Preferences.get("mail.biff.show_alert")
+ .value;
+ }
+ },
+
+ updateWebSearch() {
+ let self = this;
+ Services.search.init().then(async () => {
+ let defaultEngine = await Services.search.getDefault();
+ let engineList = document.getElementById("defaultWebSearch");
+ for (let engine of await Services.search.getVisibleEngines()) {
+ let item = engineList.appendItem(engine.name);
+ item.engine = engine;
+ item.className = "menuitem-iconic";
+ item.setAttribute(
+ "image",
+ engine.iconURI
+ ? engine.iconURI.spec
+ : "resource://gre-resources/broken-image.png"
+ );
+ if (engine == defaultEngine) {
+ engineList.selectedItem = item;
+ }
+ }
+ self.defaultEngines = await Services.search.getAppProvidedEngines();
+ self.updateRemoveButton();
+
+ engineList.addEventListener("command", async () => {
+ await Services.search.setDefault(
+ engineList.selectedItem.engine,
+ Ci.nsISearchService.CHANGE_REASON_USER
+ );
+ self.updateRemoveButton();
+ });
+ });
+ },
+
+ // Caches the default engines so we only retrieve them once.
+ defaultEngines: null,
+
+ async updateRemoveButton() {
+ let engineList = document.getElementById("defaultWebSearch");
+ let removeButton = document.getElementById("removeSearchEngine");
+ if (this.defaultEngines.includes(await Services.search.getDefault())) {
+ // Don't allow deletion of a default engine (saves us having a 'restore' button).
+ removeButton.disabled = true;
+ } else {
+ // Don't allow removal of last engine. This shouldn't happen since there should
+ // always be default engines.
+ removeButton.disabled = engineList.itemCount <= 1;
+ }
+ },
+
+ /**
+ * Look up OpenSearch Description URL.
+ *
+ * @param url - the url to use as basis for discovery
+ */
+ async lookupOpenSearch(url) {
+ let response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Bad response for url=${url}`);
+ }
+ let contentType = response.headers.get("Content-Type")?.toLowerCase();
+ if (
+ contentType == "application/opensearchdescription+xml" ||
+ contentType == "application/xml" ||
+ contentType == "text/xml"
+ ) {
+ return url;
+ }
+ let doc = new DOMParser().parseFromString(
+ await response.text(),
+ "text/html"
+ );
+ let auto = doc.querySelector(
+ "link[rel='search'][type='application/opensearchdescription+xml']"
+ );
+ if (!auto) {
+ throw new Error(`No provider discovered for url=${url}`);
+ }
+ return /^https?:/.test(auto.href)
+ ? auto.href
+ : new URL(url).origin + auto.href;
+ },
+
+ async addSearchEngine() {
+ let input = { value: "https://" };
+ let [title, text] = await document.l10n.formatValues([
+ "add-opensearch-provider-title",
+ "add-opensearch-provider-text",
+ ]);
+ let result = Services.prompt.prompt(window, title, text, input, null, {
+ value: false,
+ });
+ input.value = input.value.trim();
+ if (!result || !input.value || input.value == "https://") {
+ return;
+ }
+ let url = input.value;
+ let engine;
+ try {
+ url = await this.lookupOpenSearch(url);
+ engine = await Services.search.addOpenSearchEngine(url, null);
+ } catch (reason) {
+ let [title, text] = await document.l10n.formatValues([
+ { id: "adding-opensearch-provider-failed-title" },
+ { id: "adding-opensearch-provider-failed-text", args: { url } },
+ ]);
+ Services.prompt.alert(window, title, text);
+ return;
+ }
+ // Wait a bit, so the engine iconURI has time to be fetched.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+
+ // Add new engine to the list, make the added engine the default.
+ let engineList = document.getElementById("defaultWebSearch");
+ let item = engineList.appendItem(engine.name);
+ item.engine = engine;
+ item.className = "menuitem-iconic";
+ item.setAttribute(
+ "image",
+ engine.iconURI
+ ? engine.iconURI.spec
+ : "resource://gre-resources/broken-image.png"
+ );
+ engineList.selectedIndex =
+ engineList.firstElementChild.childElementCount - 1;
+ await Services.search.setDefault(
+ engineList.selectedItem.engine,
+ Ci.nsISearchService.CHANGE_REASON_USER
+ );
+ this.updateRemoveButton();
+ },
+
+ async removeSearchEngine() {
+ // Deletes the current engine. Firefox does a better job since it
+ // shows all the engines in the list. But better than nothing.
+ let defaultEngine = await Services.search.getDefault();
+ let engineList = document.getElementById("defaultWebSearch");
+ for (let i = 0; i < engineList.itemCount; i++) {
+ let item = engineList.getItemAtIndex(i);
+ if (item.engine == defaultEngine) {
+ await Services.search.removeEngine(item.engine);
+ item.remove();
+ engineList.selectedIndex = 0;
+ await Services.search.setDefault(
+ engineList.selectedItem.engine,
+ Ci.nsISearchService.CHANGE_REASON_USER
+ );
+ this.updateRemoveButton();
+ break;
+ }
+ }
+ },
+
+ /**
+ * Checks whether Thunderbird is currently registered with the operating
+ * system as the default app for mail, rss and news. If Thunderbird is not
+ * currently the default app, the user is given the option of making it the
+ * default for each type; otherwise, the user is informed that Thunderbird is
+ * already the default.
+ */
+ checkDefaultNow(aAppType) {
+ if (!this.mShellServiceWorking) {
+ return;
+ }
+
+ // otherwise, bring up the default client dialog
+ gSubDialog.open(
+ "chrome://messenger/content/systemIntegrationDialog.xhtml",
+ { features: "resizable=no" },
+ "calledFromPrefs"
+ );
+ },
+
+ // FONTS
+
+ /**
+ * Populates the default font list in UI.
+ */
+ _rebuildFonts() {
+ var langGroupPref = Preferences.get("font.language.group");
+ var isSerif =
+ gGeneralPane._readDefaultFontTypeForLanguage(langGroupPref.value) ==
+ "serif";
+ gGeneralPane._selectDefaultLanguageGroup(langGroupPref.value, isSerif);
+ },
+
+ /**
+ * Select the default language group.
+ */
+ _selectDefaultLanguageGroupPromise: Promise.resolve(),
+
+ _selectDefaultLanguageGroup(aLanguageGroup, aIsSerif) {
+ this._selectDefaultLanguageGroupPromise = (async () => {
+ // Avoid overlapping language group selections by awaiting the resolution
+ // of the previous one. We do this because this function is re-entrant,
+ // as inserting <preference> elements into the DOM sometimes triggers a call
+ // back into this function. And since this function is also asynchronous,
+ // that call can enter this function before the previous run has completed,
+ // which would corrupt the font menulists. Awaiting the previous call's
+ // resolution avoids that fate.
+ await this._selectDefaultLanguageGroupPromise;
+
+ const kFontNameFmtSerif = "font.name.serif.%LANG%";
+ const kFontNameFmtSansSerif = "font.name.sans-serif.%LANG%";
+ const kFontNameListFmtSerif = "font.name-list.serif.%LANG%";
+ const kFontNameListFmtSansSerif = "font.name-list.sans-serif.%LANG%";
+ const kFontSizeFmtVariable = "font.size.variable.%LANG%";
+
+ // Make sure font.name-list is created before font.name so that it's
+ // available at the time readFontSelection below is called.
+ var prefs = [
+ {
+ format: aIsSerif ? kFontNameListFmtSerif : kFontNameListFmtSansSerif,
+ type: "unichar",
+ element: null,
+ fonttype: aIsSerif ? "serif" : "sans-serif",
+ },
+ {
+ format: aIsSerif ? kFontNameFmtSerif : kFontNameFmtSansSerif,
+ type: "fontname",
+ element: "defaultFont",
+ fonttype: aIsSerif ? "serif" : "sans-serif",
+ },
+ {
+ format: kFontSizeFmtVariable,
+ type: "int",
+ element: "defaultFontSize",
+ fonttype: null,
+ },
+ ];
+
+ for (var i = 0; i < prefs.length; ++i) {
+ var preference = Preferences.get(
+ prefs[i].format.replace(/%LANG%/, aLanguageGroup)
+ );
+ if (!preference) {
+ preference = Preferences.add({
+ id: prefs[i].format.replace(/%LANG%/, aLanguageGroup),
+ type: prefs[i].type,
+ });
+ }
+
+ if (!prefs[i].element) {
+ continue;
+ }
+
+ var element = document.getElementById(prefs[i].element);
+ if (element) {
+ if (prefs[i].fonttype) {
+ await FontBuilder.buildFontList(
+ aLanguageGroup,
+ prefs[i].fonttype,
+ element
+ );
+ }
+
+ element.setAttribute("preference", preference.id);
+
+ preference.setElementValue(element);
+ }
+ }
+ })().catch(console.error);
+ },
+
+ /**
+ * Displays the fonts dialog, where web page font names and sizes can be
+ * configured.
+ */
+ configureFonts() {
+ gSubDialog.open("chrome://messenger/content/preferences/fonts.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /**
+ * Displays the colors dialog, where default web page/link/etc. colors can be
+ * configured.
+ */
+ configureColors() {
+ gSubDialog.open("chrome://messenger/content/preferences/colors.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /**
+ * Returns the type of the current default font for the language denoted by
+ * aLanguageGroup.
+ */
+ _readDefaultFontTypeForLanguage(aLanguageGroup) {
+ const kDefaultFontType = "font.default.%LANG%";
+ var defaultFontTypePref = kDefaultFontType.replace(
+ /%LANG%/,
+ aLanguageGroup
+ );
+ var preference = Preferences.get(defaultFontTypePref);
+ if (!preference) {
+ Preferences.add({
+ id: defaultFontTypePref,
+ type: "string",
+ name: defaultFontTypePref,
+ }).on("change", gGeneralPane._rebuildFonts);
+ }
+
+ // We should return preference.value here, but we can't wait for the binding to load,
+ // or things get really messy. Fortunately this will give the same answer.
+ return Services.prefs.getCharPref(defaultFontTypePref);
+ },
+
+ /**
+ * Determine the appropriate value to select for defaultFont, for the
+ * following cases:
+ * - there is no setting
+ * - the font selected by the user is no longer present (e.g. deleted from
+ * fonts folder)
+ */
+ readFontSelection() {
+ let element = document.getElementById("defaultFont");
+ let preference = Preferences.get(element.getAttribute("preference"));
+ if (preference.value) {
+ let fontItem = element.querySelector(
+ '[value="' + preference.value + '"]'
+ );
+
+ // There is a setting that actually is in the list. Respect it.
+ if (fontItem) {
+ return undefined;
+ }
+ }
+
+ let defaultValue =
+ element.firstElementChild.firstElementChild.getAttribute("value");
+ let languagePref = Preferences.get("font.language.group");
+ let defaultType = this._readDefaultFontTypeForLanguage(languagePref.value);
+ let listPref = Preferences.get(
+ "font.name-list." + defaultType + "." + languagePref.value
+ );
+ if (!listPref) {
+ return defaultValue;
+ }
+
+ let fontNames = listPref.value.split(",");
+
+ for (let fontName of fontNames) {
+ let fontItem = element.querySelector('[value="' + fontName.trim() + '"]');
+ if (fontItem) {
+ return fontItem.getAttribute("value");
+ }
+ }
+ return defaultValue;
+ },
+
+ async formatLocaleSetLabels() {
+ // HACK: calling getLocaleDisplayNames may fail the first time due to
+ // synchronous loading of the .ftl files. If we load the files and wait
+ // for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+
+ const osprefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(
+ Ci.mozIOSPreferences
+ );
+ let appLocale = Services.locale.appLocalesAsBCP47[0];
+ let rsLocale = osprefs.regionalPrefsLocales[0];
+ let names = Services.intl.getLocaleDisplayNames(undefined, [
+ appLocale,
+ rsLocale,
+ ]);
+ let appLocaleRadio = document.getElementById("appLocale");
+ let rsLocaleRadio = document.getElementById("rsLocale");
+ let appLocaleLabel = this._prefsBundle.getFormattedString(
+ "appLocale.label",
+ [names[0]]
+ );
+ let rsLocaleLabel = this._prefsBundle.getFormattedString("rsLocale.label", [
+ names[1],
+ ]);
+ appLocaleRadio.setAttribute("label", appLocaleLabel);
+ rsLocaleRadio.setAttribute("label", rsLocaleLabel);
+ appLocaleRadio.accessKey = this._prefsBundle.getString(
+ "appLocale.accesskey"
+ );
+ rsLocaleRadio.accessKey = this._prefsBundle.getString("rsLocale.accesskey");
+ },
+
+ // Load the preferences string bundle for other locales with fallbacks.
+ getBundleForLocales(newLocales) {
+ let locales = Array.from(
+ new Set([
+ ...newLocales,
+ ...Services.locale.requestedLocales,
+ Services.locale.lastFallbackLocale,
+ ])
+ );
+ return new Localization(
+ ["messenger/preferences/preferences.ftl", "branding/brand.ftl"],
+ false,
+ undefined,
+ locales
+ );
+ },
+
+ initPrimaryMessengerLanguageUI() {
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.requestedLocale
+ );
+ },
+
+ /**
+ * Update the available list of locales and select the locale that the user
+ * is "selecting". This could be the currently requested locale or a locale
+ * that the user would like to switch to after confirmation.
+ *
+ * @param {string} selected - The selected BCP 47 locale.
+ */
+ async updatePrimaryMessengerLanguageUI(selected) {
+ // HACK: calling getLocaleDisplayNames may fail the first time due to
+ // synchronous loading of the .ftl files. If we load the files and wait
+ // for a known value asynchronously, no such failure will happen.
+ await new Localization([
+ "toolkit/intl/languageNames.ftl",
+ "toolkit/intl/regionNames.ftl",
+ ]).formatValue("language-name-en");
+
+ let available = await getAvailableLocales();
+ let localeNames = Services.intl.getLocaleDisplayNames(
+ undefined,
+ available,
+ { preferNative: true }
+ );
+ let locales = available.map((code, i) => ({ code, name: localeNames[i] }));
+ locales.sort((a, b) => a.name > b.name);
+
+ let fragment = document.createDocumentFragment();
+ for (let { code, name } of locales) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("value", code);
+ menuitem.setAttribute("label", name);
+ fragment.appendChild(menuitem);
+ }
+
+ // Add an option to search for more languages if downloading is supported.
+ if (Services.prefs.getBoolPref("intl.multilingual.downloadEnabled")) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.id = "primaryMessengerLocaleSearch";
+ menuitem.setAttribute(
+ "label",
+ await document.l10n.formatValue("messenger-languages-search")
+ );
+ menuitem.setAttribute("value", "search");
+ menuitem.addEventListener("command", () => {
+ gGeneralPane.showMessengerLanguagesSubDialog({ search: true });
+ });
+ fragment.appendChild(menuitem);
+ }
+
+ let menulist = document.getElementById("primaryMessengerLocale");
+ let menupopup = menulist.querySelector("menupopup");
+ menupopup.textContent = "";
+ menupopup.appendChild(fragment);
+ menulist.value = selected;
+
+ document.getElementById("messengerLanguagesBox").hidden = false;
+ },
+
+ /**
+ * Open the messenger languages sub dialog in either the normal mode, or search mode.
+ * The search mode is only available from the menu to change the primary browser
+ * language.
+ *
+ * @param {{ search: boolean }}
+ */
+ showMessengerLanguagesSubDialog({ search }) {
+ let opts = {
+ selectedLocalesForRestart: gGeneralPane.selectedLocalesForRestart,
+ search,
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/messengerLanguages.xhtml",
+ { closingCallback: this.messengerLanguagesClosed },
+ opts
+ );
+ },
+
+ /**
+ * Returns the assumed script directionality for known Firefox locales. This is
+ * somewhat crude, but should work until Bug 1750781 lands.
+ *
+ * TODO (Bug 1750781) - This should use Intl.LocaleInfo once it is standardized (see
+ * Bug 1693576), rather than maintaining a hardcoded list of RTL locales.
+ *
+ * @param {string} locale
+ * @returns {"ltr" | "rtl"}
+ */
+ getLocaleDirection(locale) {
+ if (
+ locale == "ar" ||
+ locale == "ckb" ||
+ locale == "fa" ||
+ locale == "he" ||
+ locale == "ur"
+ ) {
+ return "rtl";
+ }
+ return "ltr";
+ },
+
+ /**
+ * Determine the transition strategy for switching the locale based on prefs
+ * and the switched locales.
+ *
+ * @param {Array<string>} newLocales - List of BCP 47 locale identifiers.
+ * @returns {"locales-match" | "requires-restart" | "live-reload"}
+ */
+ getLanguageSwitchTransitionType(newLocales) {
+ const { appLocalesAsBCP47 } = Services.locale;
+ if (appLocalesAsBCP47.join(",") === newLocales.join(",")) {
+ // The selected locales match, the order matters.
+ return "locales-match";
+ }
+
+ if (Services.prefs.getBoolPref("intl.multilingual.liveReload")) {
+ if (
+ gGeneralPane.getLocaleDirection(newLocales[0]) !==
+ gGeneralPane.getLocaleDirection(appLocalesAsBCP47[0]) &&
+ !Services.prefs.getBoolPref("intl.multilingual.liveReloadBidirectional")
+ ) {
+ // Bug 1750852: The directionality of the text changed, which requires a restart
+ // until the quality of the switch can be improved.
+ return "requires-restart";
+ }
+
+ return "live-reload";
+ }
+
+ return "requires-restart";
+ },
+
+ /* Show or hide the confirm change message bar based on the updated ordering. */
+ messengerLanguagesClosed() {
+ // When the subdialog is closed, settings are stored on gMessengerLanguagesDialog.
+ // The next time the dialog is opened, a new gMessengerLanguagesDialog is created.
+ let { selected } = this.gMessengerLanguagesDialog;
+
+ if (!selected) {
+ // No locales were selected. Cancel the operation.
+ return;
+ }
+
+ switch (gGeneralPane.getLanguageSwitchTransitionType(selected)) {
+ case "requires-restart":
+ gGeneralPane.showConfirmLanguageChangeMessageBar(selected);
+ gGeneralPane.updatePrimaryMessengerLanguageUI(selected[0]);
+ break;
+ case "live-reload":
+ Services.locale.requestedLocales = selected;
+
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ case "locales-match":
+ // They matched, so we can reset the UI.
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ default:
+ throw new Error("Unhandled transition type.");
+ }
+ },
+
+ /* Show the confirmation message bar to allow a restart into the new locales. */
+ async showConfirmLanguageChangeMessageBar(locales) {
+ let messageBar = document.getElementById("confirmMessengerLanguage");
+
+ // Get the bundle for the new locale.
+ let newBundle = this.getBundleForLocales(locales);
+
+ // Find the messages and labels.
+ let messages = await Promise.all(
+ [newBundle, document.l10n].map(async bundle =>
+ bundle.formatValue("confirm-messenger-language-change-description")
+ )
+ );
+ let buttonLabels = await Promise.all(
+ [newBundle, document.l10n].map(async bundle =>
+ bundle.formatValue("confirm-messenger-language-change-button")
+ )
+ );
+
+ // If both the message and label are the same, just include one row.
+ if (messages[0] == messages[1] && buttonLabels[0] == buttonLabels[1]) {
+ messages.pop();
+ buttonLabels.pop();
+ }
+
+ let contentContainer = messageBar.querySelector(
+ ".message-bar-content-container"
+ );
+ contentContainer.textContent = "";
+
+ for (let i = 0; i < messages.length; i++) {
+ let messageContainer = document.createXULElement("hbox");
+ messageContainer.classList.add("message-bar-content");
+ messageContainer.setAttribute("flex", "1");
+ messageContainer.setAttribute("align", "center");
+
+ let description = document.createXULElement("description");
+ description.classList.add("message-bar-description");
+
+ if (i == 0 && gGeneralPane.getLocaleDirection(locales[0]) === "rtl") {
+ description.classList.add("rtl-locale");
+ }
+
+ description.setAttribute("flex", "1");
+ description.textContent = messages[i];
+ messageContainer.appendChild(description);
+
+ let button = document.createXULElement("button");
+ button.addEventListener("command", gGeneralPane.confirmLanguageChange);
+ button.classList.add("message-bar-button");
+ button.setAttribute("locales", locales.join(","));
+ button.setAttribute("label", buttonLabels[i]);
+ messageContainer.appendChild(button);
+
+ contentContainer.appendChild(messageContainer);
+ }
+
+ messageBar.hidden = false;
+ this.selectedLocalesForRestart = locales;
+ },
+
+ hideConfirmLanguageChangeMessageBar() {
+ let messageBar = document.getElementById("confirmMessengerLanguage");
+ messageBar.hidden = true;
+ let contentContainer = messageBar.querySelector(
+ ".message-bar-content-container"
+ );
+ contentContainer.textContent = "";
+ this.requestingLocales = null;
+ },
+
+ /* Confirm the locale change and restart the Thunderbird in the new locale. */
+ confirmLanguageChange(event) {
+ let localesString = (event.target.getAttribute("locales") || "").trim();
+ if (!localesString || localesString.length == 0) {
+ return;
+ }
+ let locales = localesString.split(",");
+ Services.locale.requestedLocales = locales;
+
+ // Restart with the new locale.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+ if (!cancelQuit.data) {
+ Services.startup.quit(
+ Services.startup.eAttemptQuit | Services.startup.eRestart
+ );
+ }
+ },
+
+ /* Show or hide the confirm change message bar based on the new locale. */
+ onPrimaryMessengerLanguageMenuChange(event) {
+ let locale = event.target.value;
+
+ if (locale == "search") {
+ return;
+ } else if (locale == Services.locale.appLocaleAsBCP47) {
+ this.hideConfirmLanguageChangeMessageBar();
+ return;
+ }
+
+ let newLocales = Array.from(
+ new Set([locale, ...Services.locale.requestedLocales]).values()
+ );
+
+ switch (gGeneralPane.getLanguageSwitchTransitionType(newLocales)) {
+ case "requires-restart":
+ // Prepare to change the locales, as they were different.
+ gGeneralPane.showConfirmLanguageChangeMessageBar(newLocales);
+ gGeneralPane.updatePrimaryMessengerLanguageUI(newLocales[0]);
+ break;
+ case "live-reload":
+ Services.locale.requestedLocales = newLocales;
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ case "locales-match":
+ // They matched, so we can reset the UI.
+ gGeneralPane.updatePrimaryMessengerLanguageUI(
+ Services.locale.appLocaleAsBCP47
+ );
+ gGeneralPane.hideConfirmLanguageChangeMessageBar();
+ break;
+ default:
+ throw new Error("Unhandled transition type.");
+ }
+ },
+
+ // appends the tag to the tag list box
+ appendTagItem(aTagName, aKey, aColor) {
+ let item = this.mTagListBox.appendItem(aTagName, aKey);
+ item.style.color = aColor;
+ return item;
+ },
+
+ buildTagList() {
+ let tagArray = MailServices.tags.getAllTags();
+ for (let i = 0; i < tagArray.length; ++i) {
+ let taginfo = tagArray[i];
+ this.appendTagItem(taginfo.tag, taginfo.key, taginfo.color);
+ }
+ },
+
+ removeTag() {
+ var index = this.mTagListBox.selectedIndex;
+ if (index >= 0) {
+ var itemToRemove = this.mTagListBox.getItemAtIndex(index);
+ MailServices.tags.deleteKey(itemToRemove.getAttribute("value"));
+ }
+ },
+
+ /**
+ * Open the edit tag dialog
+ */
+ editTag() {
+ var index = this.mTagListBox.selectedIndex;
+ if (index >= 0) {
+ var tagElToEdit = this.mTagListBox.getItemAtIndex(index);
+ var args = {
+ result: "",
+ keyToEdit: tagElToEdit.getAttribute("value"),
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ { features: "resizable=no" },
+ args
+ );
+ }
+ },
+
+ addTag() {
+ var args = { result: "", okCallback: addTagCallback };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ { features: "resizable=no" },
+ args
+ );
+ },
+
+ onSelectTag() {
+ let btnEdit = document.getElementById("editTagButton");
+ let listBox = document.getElementById("tagList");
+
+ if (listBox.selectedCount > 0) {
+ btnEdit.disabled = false;
+ } else {
+ btnEdit.disabled = true;
+ }
+
+ document.getElementById("removeTagButton").disabled = btnEdit.disabled;
+ },
+
+ /**
+ * Enable/disable the options of automatic marking as read depending on the
+ * state of the automatic marking feature.
+ */
+ updateMarkAsReadOptions() {
+ let enableRadioGroup = Preferences.get(
+ "mailnews.mark_message_read.auto"
+ ).value;
+ let autoMarkAsPref = Preferences.get("mailnews.mark_message_read.delay");
+ let autoMarkDisabled = !enableRadioGroup || autoMarkAsPref.locked;
+ document.getElementById("markAsReadAutoPreferences").disabled =
+ autoMarkDisabled;
+ document.getElementById("secondsLabel").disabled = autoMarkDisabled;
+ gGeneralPane.updateMarkAsReadTextbox();
+ },
+
+ /**
+ * Automatically enable/disable delay textbox depending on state of the
+ * Mark As Read On Delay feature.
+ */
+ updateMarkAsReadTextbox() {
+ let radioGroupEnabled = Preferences.get(
+ "mailnews.mark_message_read.auto"
+ ).value;
+ let textBoxEnabled = Preferences.get(
+ "mailnews.mark_message_read.delay"
+ ).value;
+ let intervalPref = Preferences.get(
+ "mailnews.mark_message_read.delay.interval"
+ );
+
+ let delayTextbox = document.getElementById("markAsReadDelay");
+ delayTextbox.disabled =
+ !radioGroupEnabled || !textBoxEnabled || intervalPref.locked;
+ if (document.activeElement.id == "markAsReadAutoPreferences") {
+ delayTextbox.focus();
+ }
+ },
+
+ /**
+ * Display the return receipts configuration dialog.
+ */
+ showReturnReceipts() {
+ gSubDialog.open("chrome://messenger/content/preferences/receipts.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /**
+ * Show the about:config page in a tab.
+ */
+ showConfigEdit() {
+ // If the about:config tab is already open, switch to the tab.
+ let mainWin = Services.wm.getMostRecentWindow("mail:3pane");
+ let tabmail = mainWin.document.getElementById("tabmail");
+ for (let tabInfo of tabmail.tabInfo) {
+ let tab = tabmail.getTabForBrowser(tabInfo.browser);
+ if (tab?.urlbar?.value == "about:config") {
+ tabmail.switchToTab(tabInfo);
+ return;
+ }
+ }
+ // Wasn't open already. Open in a new tab.
+ tabmail.openTab("contentTab", { url: "about:config" });
+ },
+
+ /**
+ * Display the the connection settings dialog.
+ */
+ showConnections() {
+ gSubDialog.open("chrome://messenger/content/preferences/connection.xhtml");
+ },
+
+ /**
+ * Display the the offline settings dialog.
+ */
+ showOffline() {
+ gSubDialog.open("chrome://messenger/content/preferences/offline.xhtml", {
+ features: "resizable=no",
+ });
+ },
+
+ /*
+ * browser.cache.disk.capacity
+ * - the size of the browser cache in KB
+ */
+
+ // Retrieves the amount of space currently used by disk cache
+ updateActualCacheSize() {
+ let actualSizeLabel = document.getElementById("actualDiskCacheSize");
+ let prefStrBundle = document.getElementById("bundlePreferences");
+
+ // Needs to root the observer since cache service keeps only a weak reference.
+ this.observer = {
+ onNetworkCacheDiskConsumption(consumption) {
+ let size = DownloadUtils.convertByteUnits(consumption);
+ // The XBL binding for the string bundle may have been destroyed if
+ // the page was closed before this callback was executed.
+ if (!prefStrBundle.getFormattedString) {
+ return;
+ }
+ actualSizeLabel.value = prefStrBundle.getFormattedString(
+ "actualDiskCacheSize",
+ size
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICacheStorageConsumptionObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ actualSizeLabel.value = prefStrBundle.getString(
+ "actualDiskCacheSizeCalculated"
+ );
+
+ try {
+ Services.cache2.asyncGetDiskConsumption(this.observer);
+ } catch (e) {}
+ },
+
+ updateCacheSizeUI(smartSizeEnabled) {
+ document.getElementById("useCacheBefore").disabled = smartSizeEnabled;
+ document.getElementById("cacheSize").disabled = smartSizeEnabled;
+ document.getElementById("useCacheAfter").disabled = smartSizeEnabled;
+ },
+
+ readSmartSizeEnabled() {
+ // The smart_size.enabled preference element is inverted="true", so its
+ // value is the opposite of the actual pref value
+ var disabled = Preferences.get(
+ "browser.cache.disk.smart_size.enabled"
+ ).value;
+ this.updateCacheSizeUI(!disabled);
+ },
+
+ /**
+ * Converts the cache size from units of KB to units of MB and returns that
+ * value.
+ */
+ readCacheSize() {
+ var preference = Preferences.get("browser.cache.disk.capacity");
+ return preference.value / 1024;
+ },
+
+ /**
+ * Converts the cache size as specified in UI (in MB) to KB and returns that
+ * value.
+ */
+ writeCacheSize() {
+ var cacheSize = document.getElementById("cacheSize");
+ var intValue = parseInt(cacheSize.value, 10);
+ return isNaN(intValue) ? 0 : intValue * 1024;
+ },
+
+ /**
+ * Clears the cache.
+ */
+ clearCache() {
+ try {
+ Services.cache2.clear();
+ } catch (ex) {}
+ this.updateActualCacheSize();
+ },
+
+ updateCompactOptions() {
+ let disabled =
+ !Preferences.get("mail.prompt_purge_threshhold").value ||
+ Preferences.get("mail.purge_threshhold_mb").locked;
+
+ document.getElementById("offlineCompactFolderMin").disabled = disabled;
+ document.getElementById("offlineCompactFolderAutomatically").disabled =
+ disabled;
+ },
+
+ /**
+ * Set the default store contract ID.
+ */
+ updateDefaultStore(storeID) {
+ Services.prefs.setCharPref("mail.serverDefaultStoreContractID", storeID);
+ },
+
+ /**
+ * When the user toggles the layers.acceleration.disabled pref,
+ * sync its new value to the gfx.direct2d.disabled pref too.
+ * Note that layers.acceleration.disabled is inverted.
+ */
+ updateHardwareAcceleration() {
+ if (AppConstants.platform == "win") {
+ let preference = Preferences.get("layers.acceleration.disabled");
+ Services.prefs.setBoolPref("gfx.direct2d.disabled", !preference.value);
+ }
+ },
+
+ /**
+ * Selects the correct item in the update radio group
+ */
+ async updateReadPrefs() {
+ if (
+ AppConstants.MOZ_UPDATER &&
+ (!Services.policies || Services.policies.isAllowed("appUpdate")) &&
+ !gIsPackagedApp
+ ) {
+ let radiogroup = document.getElementById("updateRadioGroup");
+ radiogroup.disabled = true;
+ try {
+ let enabled = await UpdateUtils.getAppUpdateAutoEnabled();
+ radiogroup.value = enabled;
+ radiogroup.disabled = false;
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ },
+
+ /**
+ * Writes the value of the update radio group to the disk
+ */
+ async updateWritePrefs() {
+ if (
+ AppConstants.MOZ_UPDATER &&
+ (!Services.policies || Services.policies.isAllowed("appUpdate")) &&
+ !gIsPackagedApp
+ ) {
+ let radiogroup = document.getElementById("updateRadioGroup");
+ let updateAutoValue = radiogroup.value == "true";
+ radiogroup.disabled = true;
+ try {
+ await UpdateUtils.setAppUpdateAutoEnabled(updateAutoValue);
+ radiogroup.disabled = false;
+ } catch (error) {
+ console.error(error);
+ await this.updateReadPrefs();
+ await this.reportUpdatePrefWriteError();
+ return;
+ }
+
+ // If the value was changed to false the user should be given the option
+ // to discard an update if there is one.
+ if (!updateAutoValue) {
+ await this.checkUpdateInProgress();
+ }
+ }
+ },
+
+ async reportUpdatePrefWriteError() {
+ let [title, message] = await document.l10n.formatValues([
+ { id: "update-setting-write-failure-title" },
+ {
+ id: "update-setting-write-failure-message",
+ args: { path: UpdateUtils.configFilePath },
+ },
+ ]);
+
+ // Set up the Ok Button
+ let buttonFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_OK;
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ buttonFlags,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ },
+
+ async checkUpdateInProgress() {
+ let um = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ );
+ if (!um.readyUpdate && !um.downloadingUpdate) {
+ return;
+ }
+
+ let [title, message, okButton, cancelButton] =
+ await document.l10n.formatValues([
+ { id: "update-in-progress-title" },
+ { id: "update-in-progress-message" },
+ { id: "update-in-progress-ok-button" },
+ { id: "update-in-progress-cancel-button" },
+ ]);
+
+ // Continue is the cancel button which is BUTTON_POS_1 and is set as the
+ // default so pressing escape or using a platform standard method of closing
+ // the UI will not discard the update.
+ let buttonFlags =
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_POS_1_DEFAULT;
+
+ let rv = Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ buttonFlags,
+ okButton,
+ cancelButton,
+ null,
+ null,
+ {}
+ );
+ if (rv != 1) {
+ let aus = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+ );
+ aus.stopDownload();
+ um.cleanupReadyUpdate();
+ um.cleanupDownloadingUpdate();
+ }
+ },
+
+ showUpdates() {
+ gSubDialog.open("chrome://mozapps/content/update/history.xhtml");
+ },
+
+ _loadAppHandlerData() {
+ this._loadInternalHandlers();
+ this._loadApplicationHandlers();
+ },
+
+ _loadInternalHandlers() {
+ const internalHandlers = [new PDFHandlerInfoWrapper()];
+ for (const internalHandler of internalHandlers) {
+ if (internalHandler.enabled) {
+ this._handledTypes.set(internalHandler.type, internalHandler);
+ }
+ }
+ },
+
+ /**
+ * Load the set of handlers defined by the application datastore.
+ */
+ _loadApplicationHandlers() {
+ for (let wrappedHandlerInfo of gHandlerService.enumerate()) {
+ let type = wrappedHandlerInfo.type;
+
+ let handlerInfoWrapper;
+ if (this._handledTypes.has(type)) {
+ handlerInfoWrapper = this._handledTypes.get(type);
+ } else {
+ handlerInfoWrapper = new HandlerInfoWrapper(type, wrappedHandlerInfo);
+ this._handledTypes.set(type, handlerInfoWrapper);
+ }
+ }
+ },
+
+ // -----------------
+ // View Construction
+
+ _rebuildVisibleTypes() {
+ // Reset the list of visible types and the visible type description.
+ this._visibleTypes.length = 0;
+ this._visibleDescriptions.clear();
+
+ for (let handlerInfo of this._handledTypes.values()) {
+ // We couldn't find any reason to exclude the type, so include it.
+ this._visibleTypes.push(handlerInfo);
+
+ let otherHandlerInfo = this._visibleDescriptions.get(
+ handlerInfo.description
+ );
+ if (!otherHandlerInfo) {
+ // This is the first type with this description that we encountered
+ // while rebuilding the _visibleTypes array this time. Make sure the
+ // flag is reset so we won't add the type to the description.
+ handlerInfo.disambiguateDescription = false;
+ this._visibleDescriptions.set(handlerInfo.description, handlerInfo);
+ } else {
+ // There is at least another type with this description. Make sure we
+ // add the type to the description on both HandlerInfoWrapper objects.
+ handlerInfo.disambiguateDescription = true;
+ otherHandlerInfo.disambiguateDescription = true;
+ }
+ }
+ },
+
+ _rebuildView() {
+ // Clear the list of entries.
+ let tbody = this._handlerTbody;
+ while (tbody.hasChildNodes()) {
+ // Rows kept alive by the _handlerRows map.
+ tbody.removeChild(tbody.lastChild);
+ }
+
+ let sort = this._handlerSort;
+ for (let header of this._handlerSortHeaders) {
+ let icon = header.querySelector("img");
+ if (sort.type === header.getAttribute("sort-type")) {
+ icon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/nav-down-sm.svg"
+ );
+ if (sort.descending) {
+ /* Rotates the src image to point up. */
+ icon.setAttribute("descending", "");
+ header.setAttribute("aria-sort", "descending");
+ } else {
+ icon.removeAttribute("descending");
+ header.setAttribute("aria-sort", "ascending");
+ }
+ } else {
+ icon.removeAttribute("src");
+ header.setAttribute("aria-sort", "none");
+ }
+ }
+
+ let visibleTypes = this._visibleTypes;
+
+ // If the user is filtering the list, then only show matching types.
+ if (this._filter.value) {
+ visibleTypes = visibleTypes.filter(this._matchesFilter, this);
+ }
+
+ for (let handlerInfo of visibleTypes) {
+ let row = this._handlerRows.get(handlerInfo);
+ if (row) {
+ tbody.appendChild(row.node);
+ } else {
+ row = new HandlerRow(handlerInfo, this.onDelete.bind(this));
+ row.constructNodeAndAppend(tbody, this._handlerMenuId);
+ this._handlerMenuId++;
+ this._handlerRows.set(handlerInfo, row);
+ }
+ }
+ },
+
+ _matchesFilter(aType) {
+ var filterValue = this._filter.value.toLowerCase();
+ return (
+ aType.typeDescription.toLowerCase().includes(filterValue) ||
+ aType.actionDescription.toLowerCase().includes(filterValue)
+ );
+ },
+
+ /**
+ * Get the details for the type represented by the given handler info
+ * object.
+ *
+ * @param aHandlerInfo {nsIHandlerInfo} the type to get the extensions for.
+ * @returns {string} the extensions for the type
+ */
+ _typeDetails(aHandlerInfo) {
+ let exts = [];
+ if (aHandlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) {
+ for (let extName of aHandlerInfo.wrappedHandlerInfo.getFileExtensions()) {
+ let ext = "." + extName;
+ if (!exts.includes(ext)) {
+ exts.push(ext);
+ }
+ }
+ }
+ exts.sort();
+ exts = exts.join(", ");
+ if (this._visibleDescriptions.has(aHandlerInfo.description)) {
+ if (exts) {
+ return this._prefsBundle.getFormattedString(
+ "typeDetailsWithTypeAndExt",
+ [aHandlerInfo.type, exts]
+ );
+ }
+ return this._prefsBundle.getFormattedString("typeDetailsWithTypeOrExt", [
+ aHandlerInfo.type,
+ ]);
+ }
+ if (exts) {
+ return this._prefsBundle.getFormattedString("typeDetailsWithTypeOrExt", [
+ exts,
+ ]);
+ }
+ return exts;
+ },
+
+ /**
+ * Whether or not the given handler app is valid.
+ *
+ * @param aHandlerApp {nsIHandlerApp} the handler app in question
+ * @returns {boolean} whether or not it's valid
+ */
+ isValidHandlerApp(aHandlerApp) {
+ if (!aHandlerApp) {
+ return false;
+ }
+
+ if (aHandlerApp instanceof Ci.nsILocalHandlerApp) {
+ return this._isValidHandlerExecutable(aHandlerApp.executable);
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebHandlerApp) {
+ return aHandlerApp.uriTemplate;
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebContentHandlerInfo) {
+ return aHandlerApp.uri;
+ }
+
+ return false;
+ },
+
+ _isValidHandlerExecutable(aExecutable) {
+ let isExecutable =
+ aExecutable && aExecutable.exists() && aExecutable.isExecutable();
+ // XXXben - we need to compare this with the running instance executable
+ // just don't know how to do that via script...
+ // XXXmano TBD: can probably add this to nsIShellService
+ if (AppConstants.platform == "win") {
+ return (
+ isExecutable &&
+ aExecutable.leafName != AppConstants.MOZ_APP_NAME + ".exe"
+ );
+ }
+
+ if (AppConstants.platform == "macosx") {
+ return (
+ isExecutable && aExecutable.leafName != AppConstants.MOZ_MACBUNDLE_NAME
+ );
+ }
+
+ return (
+ isExecutable && aExecutable.leafName != AppConstants.MOZ_APP_NAME + "-bin"
+ );
+ },
+
+ // -------------------
+ // Sorting & Filtering
+
+ /**
+ * Sort the list when the user clicks on a column header. If sortType is
+ * different than the last sort, the sort direction is toggled. Otherwise, the
+ * sort is changed to the new sortType with ascending direction.
+ *
+ * @param {string} sortType - The sort type associated with the column header.
+ */
+ sort(sortType) {
+ let sort = this._handlerSort;
+ if (sort.type === sortType) {
+ sort.descending = !sort.descending;
+ } else {
+ sort.type = sortType;
+ sort.descending = false;
+ }
+ this._sortVisibleTypes();
+ this._rebuildView();
+ },
+
+ /**
+ * Sort the list of visible types by the current sort column/direction.
+ */
+ _sortVisibleTypes() {
+ function sortByType(a, b) {
+ return a.typeDescription
+ .toLowerCase()
+ .localeCompare(b.typeDescription.toLowerCase());
+ }
+
+ function sortByAction(a, b) {
+ return a.actionDescription
+ .toLowerCase()
+ .localeCompare(b.actionDescription.toLowerCase());
+ }
+
+ let sort = this._handlerSort;
+ if (sort.type === "action") {
+ this._visibleTypes.sort(sortByAction);
+ } else {
+ this._visibleTypes.sort(sortByType);
+ }
+ if (sort.descending) {
+ this._visibleTypes.reverse();
+ }
+ },
+
+ focusFilterBox() {
+ this._filter.focus();
+ this._filter.select();
+ },
+
+ onDelete(handlerRow) {
+ let handlerInfo = handlerRow.handlerInfoWrapper;
+ let index = this._visibleTypes.indexOf(handlerInfo);
+ if (index != -1) {
+ this._visibleTypes.splice(index, 1);
+ }
+
+ let tbody = this._handlerTbody;
+ if (handlerRow.node.parentNode === tbody) {
+ tbody.removeChild(handlerRow.node);
+ }
+
+ this._handledTypes.delete(handlerInfo.type);
+ this._handlerRows.delete(handlerInfo);
+
+ handlerInfo.remove();
+ },
+
+ _getIconURLForHandlerApp(aHandlerApp) {
+ if (aHandlerApp instanceof Ci.nsILocalHandlerApp) {
+ return this._getIconURLForFile(aHandlerApp.executable);
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebHandlerApp) {
+ return this._getIconURLForWebApp(aHandlerApp.uriTemplate);
+ }
+
+ if (aHandlerApp instanceof Ci.nsIWebContentHandlerInfo) {
+ return this._getIconURLForWebApp(aHandlerApp.uri);
+ }
+
+ // We know nothing about other kinds of handler apps.
+ return "";
+ },
+
+ _getIconURLForFile(aFile) {
+ let urlSpec = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .getURLSpecFromActualFile(aFile);
+
+ return "moz-icon://" + urlSpec + "?size=16";
+ },
+
+ _getIconURLForWebApp(aWebAppURITemplate) {
+ var uri = Services.io.newURI(aWebAppURITemplate);
+
+ // Unfortunately we can't use the favicon service to get the favicon,
+ // because the service looks in the annotations table for a record with
+ // the exact URL we give it, and users won't have such records for URLs
+ // they don't visit, and users won't visit the web app's URL template,
+ // they'll only visit URLs derived from that template (i.e. with %s
+ // in the template replaced by the URL of the content being handled).
+
+ if (/^https?/.test(uri.scheme)) {
+ return uri.prePath + "/favicon.ico";
+ }
+
+ return /^https?/.test(uri.scheme) ? uri.resolve("/favicon.ico") : "";
+ },
+
+ destroy() {
+ window.removeEventListener("unload", this);
+
+ Services.obs.removeObserver(this, AUTO_UPDATE_CHANGED_TOPIC);
+ Services.prefs.removeObserver("mailnews.tags.", this);
+ },
+
+ // nsISupports
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ // nsIObserver
+
+ async observe(subject, topic, data) {
+ if (topic == AUTO_UPDATE_CHANGED_TOPIC) {
+ if (data != "true" && data != "false") {
+ throw new Error(`Invalid value for app.update.auto ${data}`);
+ }
+ document.getElementById("updateRadioGroup").value = data;
+ } else if (topic == "nsPref:changed" && data.startsWith("mailnews.tags.")) {
+ let selIndex = this.mTagListBox.selectedIndex;
+ this.mTagListBox.replaceChildren();
+ this.buildTagList();
+ let numItemsInListBox = this.mTagListBox.getRowCount();
+ this.mTagListBox.selectedIndex =
+ selIndex < numItemsInListBox ? selIndex : numItemsInListBox - 1;
+ if (data.endsWith(".color") && Services.prefs.prefHasUserValue(data)) {
+ let key = data.replace(/^mailnews\.tags\./, "").replace(/\.color$/, "");
+ let color = Services.prefs.getCharPref(`mailnews.tags.${key}.color`);
+ // Add to style sheet. We simply add the new color, the rule is added
+ // at the end and will overrule the previous rule.
+ TagUtils.addTagToAllDocumentSheets(key, color);
+ }
+ }
+ },
+
+ // EventListener
+
+ handleEvent(aEvent) {
+ if (aEvent.type == "unload") {
+ this.destroy();
+ if (AppConstants.MOZ_UPDATER) {
+ onUnload();
+ }
+ }
+ },
+};
+
+function getDisplayNameForFile(aFile) {
+ if (AppConstants.platform == "win") {
+ if (aFile instanceof Ci.nsILocalFileWin) {
+ try {
+ return aFile.getVersionInfoField("FileDescription");
+ } catch (ex) {
+ // fall through to the file name
+ }
+ }
+ } else if (AppConstants.platform == "macosx") {
+ if (aFile instanceof Ci.nsILocalFileMac) {
+ try {
+ return aFile.bundleDisplayName;
+ } catch (ex) {
+ // fall through to the file name
+ }
+ }
+ }
+
+ return aFile.leafName;
+}
+
+function getLocalHandlerApp(aFile) {
+ var localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.name = getDisplayNameForFile(aFile);
+ localHandlerApp.executable = aFile;
+
+ return localHandlerApp;
+}
+
+// eslint-disable-next-line no-undef
+let gHandlerRowFragment = MozXULElement.parseXULToFragment(`
+ <html:tr>
+ <html:td class="typeCell">
+ <html:div class="typeLabel">
+ <html:img class="typeIcon" alt=""/>
+ <label class="typeDescription" crop="end"/>
+ </html:div>
+ </html:td>
+ <html:td class="actionCell">
+ <menulist class="actionsMenu" crop="end" selectedIndex="1">
+ <menupopup/>
+ </menulist>
+ </html:td>
+ </html:tr>
+`);
+
+/**
+ * This is associated to rows in the handlers table.
+ */
+class HandlerRow {
+ constructor(handlerInfoWrapper, onDeleteCallback) {
+ this.handlerInfoWrapper = handlerInfoWrapper;
+ this.previousSelectedItem = null;
+ this.deleteCallback = onDeleteCallback;
+ }
+
+ constructNodeAndAppend(tbody, id) {
+ tbody.appendChild(document.importNode(gHandlerRowFragment, true));
+ this.node = tbody.lastChild;
+
+ this.menu = this.node.querySelector(".actionsMenu");
+ id = `action-menu-${id}`;
+ this.menu.setAttribute("id", id);
+ this.menu.addEventListener("command", event =>
+ this.onSelectAction(event.originalTarget)
+ );
+
+ let typeDescription = this.node.querySelector(".typeDescription");
+ typeDescription.setAttribute(
+ "value",
+ this.handlerInfoWrapper.typeDescription
+ );
+ // NOTE: Control only works for a XUL <label>. Using a HTML <label> and the
+ // corresponding "for" attribute would not currently work with the XUL
+ // <menulist> because a XUL <menulist> is technically not a labelable
+ // element, as required for the html:label "for" attribute.
+ typeDescription.setAttribute("control", id);
+ // Spoof the HTML label "for" attribute focus behaviour on the whole cell.
+ this.node
+ .querySelector(".typeCell")
+ .addEventListener("click", () => this.menu.focus());
+
+ this.node
+ .querySelector(".typeIcon")
+ .setAttribute("src", this.handlerInfoWrapper.smallIcon);
+
+ this.rebuildActionsMenu();
+ }
+
+ rebuildActionsMenu() {
+ let menu = this.menu;
+ let menuPopup = menu.menupopup;
+ let handlerInfo = this.handlerInfoWrapper;
+
+ // Clear out existing items.
+ while (menuPopup.hasChildNodes()) {
+ menuPopup.removeChild(menuPopup.lastChild);
+ }
+
+ let internalMenuItem;
+ // Add the "Preview in Thunderbird" option for optional internal handlers.
+ if (handlerInfo instanceof InternalHandlerInfoWrapper) {
+ internalMenuItem = document.createXULElement("menuitem");
+ internalMenuItem.setAttribute(
+ "action",
+ Ci.nsIHandlerInfo.handleInternally
+ );
+ let label = gGeneralPane._prefsBundle.getFormattedString("previewInApp", [
+ gGeneralPane._brandShortName,
+ ]);
+ internalMenuItem.setAttribute("label", label);
+ internalMenuItem.setAttribute("tooltiptext", label);
+ internalMenuItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/preferences/alwaysAsk.png"
+ );
+ menuPopup.appendChild(internalMenuItem);
+ }
+
+ let askMenuItem = document.createXULElement("menuitem");
+ askMenuItem.setAttribute("alwaysAsk", "true");
+ {
+ let label = gGeneralPane._prefsBundle.getString("alwaysAsk");
+ askMenuItem.setAttribute("label", label);
+ askMenuItem.setAttribute("tooltiptext", label);
+ askMenuItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/preferences/alwaysAsk.png"
+ );
+ menuPopup.appendChild(askMenuItem);
+ }
+
+ // Create a menu item for saving to disk.
+ // Note: this option isn't available to protocol types, since we don't know
+ // what it means to save a URL having a certain scheme to disk.
+ let saveMenuItem;
+ if (handlerInfo.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) {
+ saveMenuItem = document.createXULElement("menuitem");
+ saveMenuItem.setAttribute("action", Ci.nsIHandlerInfo.saveToDisk);
+ let label = gGeneralPane._prefsBundle.getString("saveFile");
+ saveMenuItem.setAttribute("label", label);
+ saveMenuItem.setAttribute("tooltiptext", label);
+ saveMenuItem.setAttribute(
+ "image",
+ "chrome://messenger/skin/preferences/saveFile.png"
+ );
+ menuPopup.appendChild(saveMenuItem);
+ }
+
+ // Add a separator to distinguish these items from the helper app items
+ // that follow them.
+ let menuItem = document.createXULElement("menuseparator");
+ menuPopup.appendChild(menuItem);
+
+ // Create a menu item for the OS default application, if any.
+ let defaultMenuItem;
+ if (handlerInfo.hasDefaultHandler) {
+ defaultMenuItem = document.createXULElement("menuitem");
+ defaultMenuItem.setAttribute(
+ "action",
+ Ci.nsIHandlerInfo.useSystemDefault
+ );
+ let label = gGeneralPane._prefsBundle.getFormattedString("useDefault", [
+ handlerInfo.defaultDescription,
+ ]);
+ defaultMenuItem.setAttribute("label", label);
+ defaultMenuItem.setAttribute(
+ "tooltiptext",
+ handlerInfo.defaultDescription
+ );
+ defaultMenuItem.setAttribute(
+ "image",
+ handlerInfo.iconURLForSystemDefault
+ );
+
+ menuPopup.appendChild(defaultMenuItem);
+ }
+
+ // Create menu items for possible handlers.
+ let preferredApp = handlerInfo.preferredApplicationHandler;
+ let possibleAppMenuItems = [];
+ for (let possibleApp of handlerInfo.possibleApplicationHandlers.enumerate()) {
+ if (!gGeneralPane.isValidHandlerApp(possibleApp)) {
+ continue;
+ }
+
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.setAttribute("action", Ci.nsIHandlerInfo.useHelperApp);
+ let label;
+ if (possibleApp instanceof Ci.nsILocalHandlerApp) {
+ label = getDisplayNameForFile(possibleApp.executable);
+ } else {
+ label = possibleApp.name;
+ }
+ label = gGeneralPane._prefsBundle.getFormattedString("useApp", [label]);
+ menuItem.setAttribute("label", label);
+ menuItem.setAttribute("tooltiptext", label);
+ menuItem.setAttribute(
+ "image",
+ gGeneralPane._getIconURLForHandlerApp(possibleApp)
+ );
+
+ // Attach the handler app object to the menu item so we can use it
+ // to make changes to the datastore when the user selects the item.
+ menuItem.handlerApp = possibleApp;
+
+ menuPopup.appendChild(menuItem);
+ possibleAppMenuItems.push(menuItem);
+ }
+
+ // Create a menu item for selecting a local application.
+ let createItem = true;
+ if (AppConstants.platform == "win") {
+ // On Windows, selecting an application to open another application
+ // would be meaningless so we special case executables.
+ let executableType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromExtension("exe");
+ if (handlerInfo.type == executableType) {
+ createItem = false;
+ }
+ }
+
+ if (createItem) {
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.addEventListener("command", this.chooseApp.bind(this));
+ let label = gGeneralPane._prefsBundle.getString("useOtherApp");
+ menuItem.setAttribute("label", label);
+ menuItem.setAttribute("tooltiptext", label);
+ menuPopup.appendChild(menuItem);
+ }
+
+ // Create a menu item for managing applications.
+ if (possibleAppMenuItems.length) {
+ let menuItem = document.createXULElement("menuseparator");
+ menuPopup.appendChild(menuItem);
+ menuItem = document.createXULElement("menuitem");
+ menuItem.addEventListener("command", this.manageApp.bind(this));
+ menuItem.setAttribute(
+ "label",
+ gGeneralPane._prefsBundle.getString("manageApp")
+ );
+ menuPopup.appendChild(menuItem);
+ }
+
+ menuItem = document.createXULElement("menuseparator");
+ menuPopup.appendChild(menuItem);
+ menuItem = document.createXULElement("menuitem");
+ menuItem.addEventListener("command", this.confirmDelete.bind(this));
+ menuItem.setAttribute(
+ "label",
+ gGeneralPane._prefsBundle.getString("delete")
+ );
+ menuPopup.appendChild(menuItem);
+
+ // Select the item corresponding to the preferred action. If the always
+ // ask flag is set, it overrides the preferred action. Otherwise we pick
+ // the item identified by the preferred action (when the preferred action
+ // is to use a helper app, we have to pick the specific helper app item).
+ if (handlerInfo.alwaysAskBeforeHandling) {
+ menu.selectedItem = askMenuItem;
+ } else {
+ switch (handlerInfo.preferredAction) {
+ case Ci.nsIHandlerInfo.handleInternally:
+ if (internalMenuItem) {
+ menu.selectedItem = internalMenuItem;
+ } else {
+ console.error("No menu item defined to set!");
+ }
+ break;
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ menu.selectedItem = defaultMenuItem;
+ break;
+ case Ci.nsIHandlerInfo.useHelperApp:
+ if (preferredApp) {
+ menu.selectedItem = possibleAppMenuItems.filter(v =>
+ v.handlerApp.equals(preferredApp)
+ )[0];
+ }
+ break;
+ case Ci.nsIHandlerInfo.saveToDisk:
+ menu.selectedItem = saveMenuItem;
+ break;
+ }
+ }
+ // menu.selectedItem may be null if the preferredAction is
+ // useSystemDefault, but handlerInfo.hasDefaultHandler returns false.
+ // For now, we'll just use the askMenuItem to avoid ugly exceptions.
+ this.previousSelectedItem = this.menu.selectedItem || askMenuItem;
+ }
+
+ manageApp(aEvent) {
+ // Don't let the normal "on select action" handler get this event,
+ // as we handle it specially ourselves.
+ aEvent.stopPropagation();
+
+ var handlerInfo = this.handlerInfoWrapper;
+
+ let onComplete = () => {
+ // Rebuild the actions menu so that we revert to the previous selection,
+ // or "Always ask" if the previous default application has been removed.
+ this.rebuildActionsMenu();
+ };
+
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/applicationManager.xhtml",
+ { features: "resizable=no", closingCallback: onComplete },
+ handlerInfo
+ );
+ }
+
+ chooseApp(aEvent) {
+ // Don't let the normal "on select action" handler get this event,
+ // as we handle it specially ourselves.
+ aEvent.stopPropagation();
+
+ var handlerApp;
+ let onSelectionDone = function () {
+ // Rebuild the actions menu whether the user picked an app or canceled.
+ // If they picked an app, we want to add the app to the menu and select it.
+ // If they canceled, we want to go back to their previous selection.
+ this.rebuildActionsMenu();
+
+ // If the user picked a new app from the menu, select it.
+ if (handlerApp) {
+ let menuItems = this.menu.menupopup.children;
+ for (let i = 0; i < menuItems.length; i++) {
+ let menuItem = menuItems[i];
+ if (menuItem.handlerApp && menuItem.handlerApp.equals(handlerApp)) {
+ this.menu.selectedIndex = i;
+ this.onSelectAction(menuItem);
+ break;
+ }
+ }
+ }
+ }.bind(this);
+
+ if (AppConstants.platform == "win") {
+ let params = {};
+ let handlerInfo = this.handlerInfoWrapper;
+
+ params.mimeInfo = handlerInfo.wrappedHandlerInfo;
+
+ params.title = gGeneralPane._prefsBundle.getString("fpTitleChooseApp");
+ params.description = handlerInfo.description;
+ params.filename = null;
+ params.handlerApp = null;
+
+ let onAppSelected = () => {
+ if (gGeneralPane.isValidHandlerApp(params.handlerApp)) {
+ handlerApp = params.handlerApp;
+
+ // Add the app to the type's list of possible handlers.
+ handlerInfo.addPossibleApplicationHandler(handlerApp);
+ }
+ onSelectionDone();
+ };
+
+ gSubDialog.open(
+ "chrome://global/content/appPicker.xhtml",
+ { features: "resizable=no", closingCallback: onAppSelected },
+ params
+ );
+ } else {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let winTitle = gGeneralPane._prefsBundle.getString("fpTitleChooseApp");
+ fp.init(window, winTitle, Ci.nsIFilePicker.modeOpen);
+ fp.appendFilters(Ci.nsIFilePicker.filterApps);
+
+ // Prompt the user to pick an app. If they pick one, and it's a valid
+ // selection, then add it to the list of possible handlers.
+
+ fp.open(rv => {
+ if (
+ rv == Ci.nsIFilePicker.returnOK &&
+ fp.file &&
+ gGeneralPane._isValidHandlerExecutable(fp.file)
+ ) {
+ handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.name = getDisplayNameForFile(fp.file);
+ handlerApp.executable = fp.file;
+
+ // Add the app to the type's list of possible handlers.
+ let handlerInfo = this.handlerInfoWrapper;
+ handlerInfo.addPossibleApplicationHandler(handlerApp);
+ }
+ onSelectionDone();
+ });
+ }
+ }
+
+ confirmDelete(aEvent) {
+ aEvent.stopPropagation();
+ if (
+ Services.prompt.confirm(
+ null,
+ gGeneralPane._prefsBundle.getString("confirmDeleteTitle"),
+ gGeneralPane._prefsBundle.getString("confirmDeleteText")
+ )
+ ) {
+ // Deletes self.
+ this.deleteCallback(this);
+ } else {
+ // They hit cancel, so return them to the previously selected item.
+ this.menu.selectedItem = this.previousSelectedItem;
+ }
+ }
+
+ onSelectAction(aActionItem) {
+ this.previousSelectedItem = aActionItem;
+ this._storeAction(aActionItem);
+ }
+
+ _storeAction(aActionItem) {
+ var handlerInfo = this.handlerInfoWrapper;
+
+ if (aActionItem.hasAttribute("alwaysAsk")) {
+ handlerInfo.alwaysAskBeforeHandling = true;
+ } else if (aActionItem.hasAttribute("action")) {
+ let action = parseInt(aActionItem.getAttribute("action"));
+
+ // Set the preferred application handler.
+ // We leave the existing preferred app in the list when we set
+ // the preferred action to something other than useHelperApp so that
+ // legacy datastores that don't have the preferred app in the list
+ // of possible apps still include the preferred app in the list of apps
+ // the user can choose to handle the type.
+ if (action == Ci.nsIHandlerInfo.useHelperApp) {
+ handlerInfo.preferredApplicationHandler = aActionItem.handlerApp;
+ }
+
+ // Set the "always ask" flag.
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ // Set the preferred action.
+ handlerInfo.preferredAction = action;
+ }
+
+ handlerInfo.store();
+ }
+}
+
+/**
+ * This object wraps nsIHandlerInfo with some additional functionality
+ * the Applications prefpane needs to display and allow modification of
+ * the list of handled types.
+ *
+ * We create an instance of this wrapper for each entry we might display
+ * in the prefpane, and we compose the instances from various sources,
+ * including the handler service.
+ *
+ * We don't implement all the original nsIHandlerInfo functionality,
+ * just the stuff that the prefpane needs.
+ */
+class HandlerInfoWrapper {
+ constructor(type, handlerInfo) {
+ this.type = type;
+ this.wrappedHandlerInfo = handlerInfo;
+ this.disambiguateDescription = false;
+ }
+
+ get description() {
+ if (this.wrappedHandlerInfo.description) {
+ return this.wrappedHandlerInfo.description;
+ }
+
+ if (this.primaryExtension) {
+ var extension = this.primaryExtension.toUpperCase();
+ return document
+ .getElementById("bundlePreferences")
+ .getFormattedString("fileEnding", [extension]);
+ }
+ return this.type;
+ }
+
+ /**
+ * Describe, in a human-readable fashion, the type represented by the given
+ * handler info object. Normally this is just the description, but if more
+ * than one object presents the same description, "disambiguateDescription"
+ * is set and we annotate the duplicate descriptions with the type itself
+ * to help users distinguish between those types.
+ */
+ get typeDescription() {
+ if (this.disambiguateDescription) {
+ return gGeneralPane._prefsBundle.getFormattedString(
+ "typeDetailsWithTypeAndExt",
+ [this.description, this.type]
+ );
+ }
+
+ return this.description;
+ }
+
+ /**
+ * Describe, in a human-readable fashion, the preferred action to take on
+ * the type represented by the given handler info object.
+ */
+ get actionDescription() {
+ // alwaysAskBeforeHandling overrides the preferred action, so if that flag
+ // is set, then describe that behavior instead. For most types, this is
+ // the "alwaysAsk" string, but for the feed type we show something special.
+ if (this.alwaysAskBeforeHandling) {
+ return gGeneralPane._prefsBundle.getString("alwaysAsk");
+ }
+
+ switch (this.preferredAction) {
+ case Ci.nsIHandlerInfo.saveToDisk:
+ return gGeneralPane._prefsBundle.getString("saveFile");
+
+ case Ci.nsIHandlerInfo.useHelperApp:
+ var preferredApp = this.preferredApplicationHandler;
+ var name;
+ if (preferredApp instanceof Ci.nsILocalHandlerApp) {
+ name = getDisplayNameForFile(preferredApp.executable);
+ } else {
+ name = preferredApp.name;
+ }
+ return gGeneralPane._prefsBundle.getFormattedString("useApp", [name]);
+
+ case Ci.nsIHandlerInfo.handleInternally:
+ if (this instanceof InternalHandlerInfoWrapper) {
+ return gGeneralPane._prefsBundle.getFormattedString("previewInApp", [
+ gGeneralPane._brandShortName,
+ ]);
+ }
+
+ // For other types, handleInternally looks like either useHelperApp
+ // or useSystemDefault depending on whether or not there's a preferred
+ // handler app.
+ if (gGeneralPane.isValidHandlerApp(this.preferredApplicationHandler)) {
+ return this.preferredApplicationHandler.name;
+ }
+
+ return this.defaultDescription;
+
+ // XXX Why don't we say the app will handle the type internally?
+ // Is it because the app can't actually do that? But if that's true,
+ // then why would a preferredAction ever get set to this value
+ // in the first place?
+
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ return gGeneralPane._prefsBundle.getFormattedString("useDefault", [
+ this.defaultDescription,
+ ]);
+
+ default:
+ throw new Error(`Unexpected preferredAction: ${this.preferredAction}`);
+ }
+ }
+
+ get actionIconClass() {
+ if (this.alwaysAskBeforeHandling) {
+ return "ask";
+ }
+
+ switch (this.preferredAction) {
+ case Ci.nsIHandlerInfo.saveToDisk:
+ return "save";
+
+ case Ci.nsIHandlerInfo.handleInternally:
+ if (this instanceof InternalHandlerInfoWrapper) {
+ return "ask";
+ }
+ }
+
+ return "";
+ }
+
+ get actionIcon() {
+ switch (this.preferredAction) {
+ case Ci.nsIHandlerInfo.useSystemDefault:
+ return this.iconURLForSystemDefault;
+
+ case Ci.nsIHandlerInfo.useHelperApp:
+ let preferredApp = this.preferredApplicationHandler;
+ if (gGeneralPane.isValidHandlerApp(preferredApp)) {
+ return gGeneralPane._getIconURLForHandlerApp(preferredApp);
+ }
+ // This should never happen, but if preferredAction is set to some weird
+ // value, then fall back to the generic application icon.
+
+ // Explicit fall-through
+ default:
+ return ICON_URL_APP;
+ }
+ }
+
+ get iconURLForSystemDefault() {
+ // Handler info objects for MIME types on some OSes implement a property bag
+ // interface from which we can get an icon for the default app, so if we're
+ // dealing with a MIME type on one of those OSes, then try to get the icon.
+ if (
+ this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo &&
+ this.wrappedHandlerInfo instanceof Ci.nsIPropertyBag
+ ) {
+ try {
+ let url = this.wrappedHandlerInfo.getProperty(
+ "defaultApplicationIconURL"
+ );
+ if (url) {
+ return url + "?size=16";
+ }
+ } catch (ex) {}
+ }
+
+ // If this isn't a MIME type object on an OS that supports retrieving
+ // the icon, or if we couldn't retrieve the icon for some other reason,
+ // then use a generic icon.
+ return ICON_URL_APP;
+ }
+
+ get preferredApplicationHandler() {
+ return this.wrappedHandlerInfo.preferredApplicationHandler;
+ }
+
+ set preferredApplicationHandler(aNewValue) {
+ this.wrappedHandlerInfo.preferredApplicationHandler = aNewValue;
+
+ // Make sure the preferred handler is in the set of possible handlers.
+ if (aNewValue) {
+ this.addPossibleApplicationHandler(aNewValue);
+ }
+ }
+
+ get possibleApplicationHandlers() {
+ return this.wrappedHandlerInfo.possibleApplicationHandlers;
+ }
+
+ addPossibleApplicationHandler(aNewHandler) {
+ for (let possibleApp of this.possibleApplicationHandlers.enumerate()) {
+ if (possibleApp.equals(aNewHandler)) {
+ return;
+ }
+ }
+ this.possibleApplicationHandlers.appendElement(aNewHandler);
+ }
+
+ removePossibleApplicationHandler(aHandler) {
+ var defaultApp = this.preferredApplicationHandler;
+ if (defaultApp && aHandler.equals(defaultApp)) {
+ // If the app we remove was the default app, we must make sure
+ // it won't be used anymore
+ this.alwaysAskBeforeHandling = true;
+ this.preferredApplicationHandler = null;
+ }
+
+ var handlers = this.possibleApplicationHandlers;
+ for (var i = 0; i < handlers.length; ++i) {
+ var handler = handlers.queryElementAt(i, Ci.nsIHandlerApp);
+ if (handler.equals(aHandler)) {
+ handlers.removeElementAt(i);
+ break;
+ }
+ }
+ }
+
+ get hasDefaultHandler() {
+ return this.wrappedHandlerInfo.hasDefaultHandler;
+ }
+
+ get defaultDescription() {
+ return this.wrappedHandlerInfo.defaultDescription;
+ }
+
+ // What to do with content of this type.
+ get preferredAction() {
+ // If the action is to use a helper app, but we don't have a preferred
+ // handler app, then switch to using the system default, if any; otherwise
+ // fall back to saving to disk, which is the default action in nsMIMEInfo.
+ // Note: "save to disk" is an invalid value for protocol info objects,
+ // but the alwaysAskBeforeHandling getter will detect that situation
+ // and always return true in that case to override this invalid value.
+ if (
+ this.wrappedHandlerInfo.preferredAction ==
+ Ci.nsIHandlerInfo.useHelperApp &&
+ !gGeneralPane.isValidHandlerApp(this.preferredApplicationHandler)
+ ) {
+ if (this.wrappedHandlerInfo.hasDefaultHandler) {
+ return Ci.nsIHandlerInfo.useSystemDefault;
+ }
+ return Ci.nsIHandlerInfo.saveToDisk;
+ }
+
+ return this.wrappedHandlerInfo.preferredAction;
+ }
+
+ set preferredAction(aNewValue) {
+ this.wrappedHandlerInfo.preferredAction = aNewValue;
+ }
+
+ get alwaysAskBeforeHandling() {
+ // If this is a protocol type and the preferred action is "save to disk",
+ // which is invalid for such types, then return true here to override that
+ // action. This could happen when the preferred action is to use a helper
+ // app, but the preferredApplicationHandler is invalid, and there isn't
+ // a default handler, so the preferredAction getter returns save to disk
+ // instead.
+ if (
+ !(this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) &&
+ this.preferredAction == Ci.nsIHandlerInfo.saveToDisk
+ ) {
+ return true;
+ }
+
+ return this.wrappedHandlerInfo.alwaysAskBeforeHandling;
+ }
+
+ set alwaysAskBeforeHandling(aNewValue) {
+ this.wrappedHandlerInfo.alwaysAskBeforeHandling = aNewValue;
+ }
+
+ // The primary file extension associated with this type, if any.
+ get primaryExtension() {
+ try {
+ if (
+ this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo &&
+ this.wrappedHandlerInfo.primaryExtension
+ ) {
+ return this.wrappedHandlerInfo.primaryExtension;
+ }
+ } catch (ex) {}
+
+ return null;
+ }
+
+ // -------
+ // Storage
+
+ store() {
+ gHandlerService.store(this.wrappedHandlerInfo);
+ }
+
+ remove() {
+ gHandlerService.remove(this.wrappedHandlerInfo);
+ }
+
+ // -----
+ // Icons
+
+ get smallIcon() {
+ return this._getIcon(16);
+ }
+
+ get largeIcon() {
+ return this._getIcon(32);
+ }
+
+ _getIcon(aSize) {
+ if (this.primaryExtension) {
+ return "moz-icon://goat." + this.primaryExtension + "?size=" + aSize;
+ }
+
+ if (this.wrappedHandlerInfo instanceof Ci.nsIMIMEInfo) {
+ return "moz-icon://goat?size=" + aSize + "&contentType=" + this.type;
+ }
+
+ // FIXME: consider returning some generic icon when we can't get a URL for
+ // one (for example in the case of protocol schemes). Filed as bug 395141.
+ return null;
+ }
+}
+
+/**
+ * InternalHandlerInfoWrapper provides a basic mechanism to create an internal
+ * mime type handler that can be enabled/disabled in the applications preference
+ * menu.
+ */
+class InternalHandlerInfoWrapper extends HandlerInfoWrapper {
+ constructor(mimeType) {
+ super(mimeType, gMIMEService.getFromTypeAndExtension(mimeType, null));
+ }
+
+ // Override store so we so we can notify any code listening for registration
+ // or unregistration of this handler.
+ store() {
+ super.store();
+ }
+
+ get enabled() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ get description() {
+ return gGeneralPane._prefsBundle.getString(this._appPrefLabel);
+ }
+}
+
+class PDFHandlerInfoWrapper extends InternalHandlerInfoWrapper {
+ constructor() {
+ super(TYPE_PDF);
+ }
+
+ get _appPrefLabel() {
+ return "applications-type-pdf";
+ }
+
+ get enabled() {
+ return !Services.prefs.getBoolPref(PREF_PDFJS_DISABLED);
+ }
+}
+
+function addTagCallback(aName, aColor) {
+ MailServices.tags.addTag(aName, aColor, "");
+
+ // Add to style sheet.
+ let key = MailServices.tags.getKeyForTag(aName);
+ let tagListBox = document.getElementById("tagList");
+ let item = tagListBox.querySelector(`richlistitem[value=${key}]`);
+ tagListBox.ensureElementIsVisible(item);
+ tagListBox.selectItem(item);
+ tagListBox.focus();
+ return true;
+}
+
+Preferences.get("mailnews.start_page.enabled").on(
+ "change",
+ gGeneralPane.updateStartPage
+);
+Preferences.get("font.language.group").on("change", gGeneralPane._rebuildFonts);
+Preferences.get("mailnews.mark_message_read.auto").on(
+ "change",
+ gGeneralPane.updateMarkAsReadOptions
+);
+Preferences.get("mailnews.mark_message_read.delay").on(
+ "change",
+ gGeneralPane.updateMarkAsReadTextbox
+);
+Preferences.get("mail.prompt_purge_threshhold").on(
+ "change",
+ gGeneralPane.updateCompactOptions
+);
+Preferences.get("layers.acceleration.disabled").on(
+ "change",
+ gGeneralPane.updateHardwareAcceleration
+);
+if (AppConstants.platform != "macosx") {
+ Preferences.get("mail.biff.show_alert").on(
+ "change",
+ gGeneralPane.updateShowAlert
+ );
+}
diff --git a/comm/mail/components/preferences/jar.mn b/comm/mail/components/preferences/jar.mn
new file mode 100644
index 0000000000..fc9b56184e
--- /dev/null
+++ b/comm/mail/components/preferences/jar.mn
@@ -0,0 +1,55 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+* content/messenger/preferences/preferences.xhtml
+ content/messenger/preferences/preferences.js
+ content/messenger/preferences/preferencesTab.js
+ content/messenger/preferences/general.js
+#if defined(XP_MACOSX) || defined(XP_WIN)
+ content/messenger/preferences/dockoptions.js
+* content/messenger/preferences/dockoptions.xhtml
+#endif
+ content/messenger/preferences/chat.js
+#ifdef NIGHTLY_BUILD
+ content/messenger/preferences/sync.js
+#endif
+ content/messenger/preferences/messagestyle.js
+ content/messenger/preferences/messengerLanguages.js
+ content/messenger/preferences/messengerLanguages.xhtml
+ content/messenger/preferences/colors.js
+* content/messenger/preferences/colors.xhtml
+ content/messenger/preferences/compose.js
+ content/messenger/preferences/extensionControlled.js
+ content/messenger/preferences/privacy.js
+ content/messenger/preferences/receipts.js
+ content/messenger/preferences/receipts.xhtml
+ content/messenger/preferences/connection.js
+ content/messenger/preferences/connection.xhtml
+ content/messenger/preferences/downloads.js
+ content/messenger/preferences/attachmentReminder.js
+ content/messenger/preferences/attachmentReminder.xhtml
+ content/messenger/preferences/applicationManager.xhtml
+ content/messenger/preferences/applicationManager.js
+ content/messenger/preferences/actionsshared.js
+ content/messenger/preferences/findInPage.js
+ content/messenger/preferences/fonts.js
+ content/messenger/preferences/fonts.xhtml
+#ifndef XP_MACOSX
+ content/messenger/preferences/notifications.js
+ content/messenger/preferences/notifications.xhtml
+#endif
+ content/messenger/preferences/offline.js
+ content/messenger/preferences/offline.xhtml
+ content/messenger/preferences/cookies.js
+* content/messenger/preferences/cookies.xhtml
+ content/messenger/preferences/passwordManager.js
+ content/messenger/preferences/passwordManager.xhtml
+ content/messenger/preferences/permissions.js
+ content/messenger/preferences/permissions.xhtml
+#ifdef NIGHTLY_BUILD
+ content/messenger/preferences/syncDialog.js
+ content/messenger/preferences/syncDialog.xhtml
+#endif
+* content/messenger/preferences/tagDialog.xhtml
diff --git a/comm/mail/components/preferences/messagestyle.js b/comm/mail/components/preferences/messagestyle.js
new file mode 100644
index 0000000000..7d10553296
--- /dev/null
+++ b/comm/mail/components/preferences/messagestyle.js
@@ -0,0 +1,259 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from preferences.js */
+
+var { GenericConvIMPrototype, GenericMessagePrototype } =
+ ChromeUtils.importESModule("resource:///modules/jsProtoHelper.sys.mjs");
+var { getThemeByName, getThemeVariants } = ChromeUtils.importESModule(
+ "resource:///modules/imThemes.sys.mjs"
+);
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+function Conversation(aName) {
+ this._name = aName;
+ this._observers = [];
+ let now = new Date();
+ this._date =
+ new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 42, 22) *
+ 1000;
+}
+Conversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ account: {
+ protocol: { name: "Fake Protocol" },
+ alias: "",
+ name: "Fake Account",
+ get statusInfo() {
+ return IMServices.core.globalUserStatus;
+ },
+ },
+};
+
+function Message(aWho, aMessage, aObject, aConversation) {
+ this._init(aWho, aMessage, aObject, aConversation);
+}
+Message.prototype = {
+ __proto__: GenericMessagePrototype,
+ get displayMessage() {
+ return this.originalMessage;
+ },
+};
+
+// Message style tooltips use this.
+function getBrowser() {
+ return document.getElementById("previewbrowser");
+}
+
+var previewObserver = {
+ _loaded: false,
+ load() {
+ let makeDate = function (aDateString) {
+ let array = aDateString.split(":");
+ let now = new Date();
+ return (
+ new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ array[0],
+ array[1],
+ array[2]
+ ) / 1000
+ );
+ };
+ let bundle = document.getElementById("themesBundle");
+ let msg = {};
+ [
+ "nick1",
+ "buddy1",
+ "nick2",
+ "buddy2",
+ "message1",
+ "message2",
+ "message3",
+ ].forEach(function (aText) {
+ msg[aText] = bundle.getString(aText);
+ });
+ let conv = new Conversation(msg.nick2);
+ conv.messages = [
+ new Message(
+ msg.buddy1,
+ msg.message1,
+ {
+ outgoing: true,
+ _alias: msg.nick1,
+ time: makeDate("10:42:22"),
+ },
+ conv
+ ),
+ new Message(
+ msg.buddy1,
+ msg.message2,
+ {
+ outgoing: true,
+ _alias: msg.nick1,
+ time: makeDate("10:42:25"),
+ },
+ conv
+ ),
+ new Message(
+ msg.buddy2,
+ msg.message3,
+ {
+ incoming: true,
+ _alias: msg.nick2,
+ time: makeDate("10:43:01"),
+ },
+ conv
+ ),
+ ];
+ previewObserver.conv = conv;
+
+ let themeName = document.getElementById("messagestyle-themename");
+ previewObserver.browser = document.getElementById("previewbrowser");
+
+ // If the preferences tab is opened straight to the message styles,
+ // loading the preview fails. Pushing this to back of the event queue
+ // prevents that failure.
+ setTimeout(() => {
+ previewObserver.displayTheme(themeName.value);
+ this._loaded = true;
+ });
+ },
+
+ currentThemeChanged() {
+ if (!this._loaded) {
+ return;
+ }
+
+ let currentTheme = document.getElementById("messagestyle-themename").value;
+ if (!currentTheme) {
+ return;
+ }
+
+ this.displayTheme(currentTheme);
+ },
+
+ _ignoreVariantChange: false,
+ currentVariantChanged() {
+ if (!this._loaded || this._ignoreVariantChange) {
+ return;
+ }
+
+ let variant = document.getElementById("themevariant").value;
+ if (!variant) {
+ return;
+ }
+
+ this.theme.variant = variant;
+ this.reloadPreview();
+ },
+
+ displayTheme(aTheme) {
+ try {
+ this.theme = getThemeByName(aTheme);
+ } catch (e) {
+ let previewBoxBrowser = document
+ .getElementById("previewBox")
+ .querySelector("browser");
+ if (previewBoxBrowser) {
+ previewBoxBrowser.hidden = true;
+ }
+ document.getElementById("noPreviewScreen").hidden = false;
+ return;
+ }
+
+ let menulist = document.getElementById("themevariant");
+ if (menulist.menupopup) {
+ menulist.menupopup.remove();
+ }
+ let popup = menulist.appendChild(document.createXULElement("menupopup"));
+ let variants = getThemeVariants(this.theme);
+
+ let defaultVariant = "";
+ if (
+ "DefaultVariant" in this.theme.metadata &&
+ variants.includes(this.theme.metadata.DefaultVariant)
+ ) {
+ defaultVariant = this.theme.metadata.DefaultVariant.replace(/_/g, " ");
+ }
+
+ let defaultText = defaultVariant;
+ if (!defaultText && "DisplayNameForNoVariant" in this.theme.metadata) {
+ defaultText = this.theme.metadata.DisplayNameForNoVariant;
+ }
+ // if the name in the metadata is 'Default', use the localized version
+ if (!defaultText || defaultText.toLowerCase() == "default") {
+ defaultText = document
+ .getElementById("themesBundle")
+ .getString("default");
+ }
+
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", defaultText);
+ menuitem.setAttribute("value", "default");
+ popup.appendChild(menuitem);
+ popup.appendChild(document.createXULElement("menuseparator"));
+
+ variants.sort().forEach(function (aVariantName) {
+ let displayName = aVariantName.replace(/_/g, " ");
+ if (displayName != defaultVariant) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", displayName);
+ menuitem.setAttribute("value", aVariantName);
+ popup.appendChild(menuitem);
+ }
+ });
+ this._ignoreVariantChange = true;
+ if (!this._loaded) {
+ menulist.value = this.theme.variant = menulist.value;
+ } else {
+ menulist.value = this.theme.variant; // (reset to "default")
+ Preferences.userChangedValue(menulist);
+ }
+ this._ignoreVariantChange = false;
+
+ // disable the variant menulist if there's no variant, or only one
+ // which is the default
+ menulist.disabled =
+ variants.length == 0 || (variants.length == 1 && defaultVariant);
+
+ this.reloadPreview();
+ document.getElementById("noPreviewScreen").hidden = true;
+ },
+
+ reloadPreview() {
+ this.browser.init(this.conv);
+ this.browser._theme = this.theme;
+ Services.obs.addObserver(this, "conversation-loaded");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "conversation-loaded" || aSubject != this.browser) {
+ return;
+ }
+
+ // We want to avoid the convbrowser trying to scroll to the last
+ // added message, as that causes the entire pref pane to jump up
+ // (bug 1179943). Therefore, we override the method convbrowser
+ // uses to determine if it should scroll, as well as its
+ // mirror in the contentWindow (that messagestyle JS can call).
+ this.browser.convScrollEnabled = () => false;
+ this.browser.contentWindow.convScrollEnabled = () => false;
+
+ // Display all queued messages. Use a timeout so that message text
+ // modifiers can be added with observers for this notification.
+ setTimeout(function () {
+ for (let message of previewObserver.conv.messages) {
+ aSubject.appendMessage(message, false);
+ }
+ }, 0);
+
+ Services.obs.removeObserver(this, "conversation-loaded");
+ },
+};
diff --git a/comm/mail/components/preferences/messengerLanguages.js b/comm/mail/components/preferences/messengerLanguages.js
new file mode 100644
index 0000000000..e66f5f7fd0
--- /dev/null
+++ b/comm/mail/components/preferences/messengerLanguages.js
@@ -0,0 +1,632 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This is exported by preferences.js but we can't import that in a subdialog.
+let { getAvailableLocales } = window.top;
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+});
+
+/* This dialog provides an interface for managing what language the messenger is
+ * displayed in.
+ *
+ * There is a list of "requested" locales and a list of "available" locales. The
+ * requested locales must be installed and enabled. Available locales could be
+ * installed and enabled, or fetched from the AMO language tools API.
+ *
+ * If a langpack is disabled, there is no way to determine what locale it is for and
+ * it will only be listed as available if that locale is also available on AMO and
+ * the user has opted to search for more languages.
+ */
+
+class OrderedListBox {
+ constructor({ richlistbox, upButton, downButton, removeButton, onRemove }) {
+ this.richlistbox = richlistbox;
+ this.upButton = upButton;
+ this.downButton = downButton;
+ this.removeButton = removeButton;
+ this.onRemove = onRemove;
+
+ this.items = [];
+
+ this.richlistbox.addEventListener("select", () => this.setButtonState());
+ this.upButton.addEventListener("command", () => this.moveUp());
+ this.downButton.addEventListener("command", () => this.moveDown());
+ this.removeButton.addEventListener("command", () => this.removeItem());
+ }
+
+ get selectedItem() {
+ return this.items[this.richlistbox.selectedIndex];
+ }
+
+ setButtonState() {
+ let { upButton, downButton, removeButton } = this;
+ let { selectedIndex, itemCount } = this.richlistbox;
+ upButton.disabled = selectedIndex <= 0;
+ downButton.disabled = selectedIndex == itemCount - 1;
+ removeButton.disabled = itemCount <= 1 || !this.selectedItem.canRemove;
+ }
+
+ moveUp() {
+ let { selectedIndex } = this.richlistbox;
+ if (selectedIndex == 0) {
+ return;
+ }
+ let { items } = this;
+ let selectedItem = items[selectedIndex];
+ let prevItem = items[selectedIndex - 1];
+ items[selectedIndex - 1] = items[selectedIndex];
+ items[selectedIndex] = prevItem;
+ let prevEl = document.getElementById(prevItem.id);
+ let selectedEl = document.getElementById(selectedItem.id);
+ this.richlistbox.insertBefore(selectedEl, prevEl);
+ this.richlistbox.ensureElementIsVisible(selectedEl);
+ this.setButtonState();
+ }
+
+ moveDown() {
+ let { selectedIndex } = this.richlistbox;
+ if (selectedIndex == this.items.length - 1) {
+ return;
+ }
+ let { items } = this;
+ let selectedItem = items[selectedIndex];
+ let nextItem = items[selectedIndex + 1];
+ items[selectedIndex + 1] = items[selectedIndex];
+ items[selectedIndex] = nextItem;
+ let nextEl = document.getElementById(nextItem.id);
+ let selectedEl = document.getElementById(selectedItem.id);
+ this.richlistbox.insertBefore(nextEl, selectedEl);
+ this.richlistbox.ensureElementIsVisible(selectedEl);
+ this.setButtonState();
+ }
+
+ removeItem() {
+ let { selectedIndex } = this.richlistbox;
+
+ if (selectedIndex == -1) {
+ return;
+ }
+
+ let [item] = this.items.splice(selectedIndex, 1);
+ this.richlistbox.selectedItem.remove();
+ this.richlistbox.selectedIndex = Math.min(
+ selectedIndex,
+ this.richlistbox.itemCount - 1
+ );
+ this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
+ this.onRemove(item);
+ }
+
+ setItems(items) {
+ this.items = items;
+ this.populate();
+ this.setButtonState();
+ }
+
+ /**
+ * Add an item to the top of the ordered list.
+ *
+ * @param {object} item The item to insert.
+ */
+ addItem(item) {
+ this.items.unshift(item);
+ this.richlistbox.insertBefore(
+ this.createItem(item),
+ this.richlistbox.firstElementChild
+ );
+ this.richlistbox.selectedIndex = 0;
+ this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
+ }
+
+ populate() {
+ this.richlistbox.textContent = "";
+
+ let frag = document.createDocumentFragment();
+ for (let item of this.items) {
+ frag.appendChild(this.createItem(item));
+ }
+ this.richlistbox.appendChild(frag);
+
+ this.richlistbox.selectedIndex = 0;
+ this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
+ }
+
+ createItem({ id, label, value }) {
+ let listitem = document.createXULElement("richlistitem");
+ listitem.id = id;
+ listitem.setAttribute("value", value);
+
+ let labelEl = document.createXULElement("label");
+ labelEl.textContent = label;
+ listitem.appendChild(labelEl);
+
+ return listitem;
+ }
+}
+
+/**
+ * The sorted select list of Locales available for the app.
+ */
+class SortedItemSelectList {
+ constructor({ menulist, button, onSelect, onChange, compareFn }) {
+ /** @type {XULElement} */
+ this.menulist = menulist;
+
+ /** @type {XULElement} */
+ this.popup = menulist.menupopup;
+
+ /** @type {XULElement} */
+ this.button = button;
+
+ /** @type {(a: LocaleDisplayInfo, b: LocaleDisplayInfo) => number} */
+ this.compareFn = compareFn;
+
+ /** @type {Array<LocaleDisplayInfo>} */
+ this.items = [];
+
+ menulist.addEventListener("command", () => {
+ button.disabled = !menulist.selectedItem;
+ if (menulist.selectedItem) {
+ onChange(this.items[menulist.selectedIndex]);
+ }
+ });
+ button.addEventListener("command", () => {
+ if (!menulist.selectedItem) {
+ return;
+ }
+
+ let [item] = this.items.splice(menulist.selectedIndex, 1);
+ menulist.selectedItem.remove();
+ menulist.setAttribute("label", menulist.getAttribute("placeholder"));
+ button.disabled = true;
+ menulist.disabled = menulist.itemCount == 0;
+ menulist.selectedIndex = -1;
+
+ onSelect(item);
+ });
+ }
+
+ /**
+ * @param {Array<LocaleDisplayInfo>} items
+ */
+ setItems(items) {
+ this.items = items.sort(this.compareFn);
+ this.populate();
+ }
+
+ populate() {
+ let { button, items, menulist, popup } = this;
+ popup.textContent = "";
+
+ let frag = document.createDocumentFragment();
+ for (let item of items) {
+ frag.appendChild(this.createItem(item));
+ }
+ popup.appendChild(frag);
+
+ menulist.setAttribute("label", menulist.getAttribute("placeholder"));
+ menulist.disabled = menulist.itemCount == 0;
+ menulist.selectedIndex = -1;
+ button.disabled = true;
+ }
+
+ /**
+ * Add an item to the list sorted by the label.
+ *
+ * @param {object} item The item to insert.
+ */
+ addItem(item) {
+ let { compareFn, items, menulist, popup } = this;
+
+ // Find the index of the item to insert before.
+ let i = items.findIndex(el => compareFn(el, item) >= 0);
+ items.splice(i, 0, item);
+ popup.insertBefore(this.createItem(item), menulist.getItemAtIndex(i));
+ menulist.disabled = menulist.itemCount == 0;
+ }
+
+ createItem({ label, value, className, disabled }) {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", label);
+ if (value) {
+ item.value = value;
+ }
+ if (className) {
+ item.classList.add(className);
+ }
+ if (disabled) {
+ item.setAttribute("disabled", "true");
+ }
+ return item;
+ }
+
+ /**
+ * Disable the inputs and set a data-l10n-id on the menulist. This can be
+ * reverted with `enableWithMessageId()`.
+ */
+ disableWithMessageId(messageId) {
+ this.menulist.setAttribute("data-l10n-id", messageId);
+ this.menulist.setAttribute(
+ "image",
+ "chrome://global/skin/icons/loading.png"
+ );
+ this.menulist.disabled = true;
+ this.button.disabled = true;
+ }
+
+ /**
+ * Enable the inputs and set a data-l10n-id on the menulist. This can be
+ * reverted with `disableWithMessageId()`.
+ */
+ enableWithMessageId(messageId) {
+ this.menulist.setAttribute("data-l10n-id", messageId);
+ this.menulist.removeAttribute("image");
+ this.menulist.disabled = this.menulist.itemCount == 0;
+ this.button.disabled = !this.menulist.selectedItem;
+ }
+}
+
+/**
+ * @typedef LocaleDisplayInfo
+ * @type {object}
+ * @property {string} id - A unique ID.
+ * @property {string} label - The localized display name.
+ * @property {string} value - The BCP 47 locale identifier or the word "search".
+ * @property {boolean} canRemove - Locales that are part of the packaged locales cannot be
+ * removed.
+ * @property {boolean} installed - Whether or not the locale is installed.
+ */
+
+/**
+ * @param {Array<string>} localeCodes - List of BCP 47 locale identifiers.
+ * @returns {Array<LocaleDisplayInfo>}
+ */
+async function getLocaleDisplayInfo(localeCodes) {
+ let availableLocales = new Set(await getAvailableLocales());
+ let packagedLocales = new Set(Services.locale.packagedLocales);
+ let localeNames = Services.intl.getLocaleDisplayNames(
+ undefined,
+ localeCodes,
+ { preferNative: true }
+ );
+ return localeCodes.map((code, i) => {
+ return {
+ id: "locale-" + code,
+ label: localeNames[i],
+ value: code,
+ canRemove: !packagedLocales.has(code),
+ installed: availableLocales.has(code),
+ };
+ });
+}
+
+/**
+ * @param {LocaleDisplayInfo} a
+ * @param {LocaleDisplayInfo} b
+ * @returns {number}
+ */
+function compareItems(a, b) {
+ // Sort by installed.
+ if (a.installed != b.installed) {
+ return a.installed ? -1 : 1;
+
+ // The search label is always last.
+ } else if (a.value == "search") {
+ return 1;
+ } else if (b.value == "search") {
+ return -1;
+
+ // If both items are locales, sort by label.
+ } else if (a.value && b.value) {
+ return a.label.localeCompare(b.label);
+
+ // One of them is a label, put it first.
+ } else if (a.value) {
+ return 1;
+ }
+ return -1;
+}
+
+var gMessengerLanguagesDialog = {
+ /**
+ * The publicly readable list of selected locales. It is only set when the dialog is
+ * accepted, and can be retrieved elsewhere by directly reading the property
+ * on gMessengerLanguagesDialog.
+ *
+ * let { selected } = gMessengerLanguagesDialog;
+ *
+ * @type {null | Array<string>}
+ */
+ selected: null,
+
+ /**
+ * @type {SortedItemSelectList}
+ */
+ _availableLocalesUI: null,
+
+ /**
+ * @type {OrderedListBox}
+ */
+ _selectedLocalesUI: null,
+
+ get downloadEnabled() {
+ // Downloading langpacks isn't always supported, check the pref.
+ return Services.prefs.getBoolPref("intl.multilingual.downloadEnabled");
+ },
+
+ async onLoad() {
+ /**
+ * @typedef {object} Options - Options passed in to configure the subdialog.
+ * @property {Array<string>} [selectedLocalesForRestart] The optional list of
+ * previously selected locales for when a restart is required. This list is
+ * preserved between openings of the dialog.
+ * @property {boolean} search Whether the user opened this from "Search for more
+ * languages" option.
+ */
+
+ /** @type {Options} */
+ let { selectedLocalesForRestart, search } = window.arguments[0];
+
+ // This is a list of available locales that the user selected. It's more
+ // restricted than the Intl notion of `requested` as it only contains
+ // locale codes for which we have matching locales available.
+ // The first time this dialog is opened, populate with appLocalesAsBCP47.
+ let selectedLocales =
+ selectedLocalesForRestart || Services.locale.appLocalesAsBCP47;
+ let selectedLocaleSet = new Set(selectedLocales);
+ let available = await getAvailableLocales();
+ let availableSet = new Set(available);
+
+ // Filter selectedLocales since the user may select a locale when it is
+ // available and then disable it.
+ selectedLocales = selectedLocales.filter(locale =>
+ availableSet.has(locale)
+ );
+ // Nothing in available should be in selectedSet.
+ available = available.filter(locale => !selectedLocaleSet.has(locale));
+
+ await this.initSelectedLocales(selectedLocales);
+ await this.initAvailableLocales(available, search);
+
+ this.initialized = true;
+
+ // Now the component is initialized, it's safe to accept the results.
+ document
+ .getElementById("MessengerLanguagesDialog")
+ .addEventListener("beforeaccept", () => {
+ this.selected = this._selectedLocalesUI.items.map(item => item.value);
+ });
+ },
+
+ /**
+ * @param {string[]} selectedLocales - BCP 47 locale identifiers
+ */
+ async initSelectedLocales(selectedLocales) {
+ this._selectedLocalesUI = new OrderedListBox({
+ richlistbox: document.getElementById("selectedLocales"),
+ upButton: document.getElementById("up"),
+ downButton: document.getElementById("down"),
+ removeButton: document.getElementById("remove"),
+ onRemove: item => this.selectedLocaleRemoved(item),
+ });
+ this._selectedLocalesUI.setItems(
+ await getLocaleDisplayInfo(selectedLocales)
+ );
+ },
+
+ /**
+ * @param {Set<string>} available - The set of available BCP 47 locale identifiers.
+ * @param {boolean} search - Whether the user opened this from "Search for more
+ * languages" option.
+ */
+ async initAvailableLocales(available, search) {
+ this._availableLocalesUI = new SortedItemSelectList({
+ menulist: document.getElementById("availableLocales"),
+ button: document.getElementById("add"),
+ compareFn: compareItems,
+ onSelect: item => this.availableLanguageSelected(item),
+ onChange: item => {
+ this.hideError();
+ if (item.value == "search") {
+ this.loadLocalesFromAMO();
+ }
+ },
+ });
+
+ // Populate the list with the installed locales even if the user is
+ // searching in case the download fails.
+ await this.loadLocalesFromInstalled(available);
+
+ // If the user opened this from the "Search for more languages" option,
+ // search AMO for available locales.
+ if (search) {
+ return this.loadLocalesFromAMO();
+ }
+
+ return undefined;
+ },
+
+ async loadLocalesFromAMO() {
+ if (!this.downloadEnabled) {
+ return;
+ }
+
+ // Disable the dropdown while we hit the network.
+ this._availableLocalesUI.disableWithMessageId(
+ "messenger-languages-searching"
+ );
+
+ // Fetch the available langpacks from AMO.
+ let availableLangpacks;
+ try {
+ availableLangpacks = await AddonRepository.getAvailableLangpacks();
+ } catch (e) {
+ this.showError();
+ return;
+ }
+
+ // Store the available langpack info for later use.
+ this.availableLangpacks = new Map();
+ for (let { target_locale, url, hash } of availableLangpacks) {
+ this.availableLangpacks.set(target_locale, { url, hash });
+ }
+
+ // Remove the installed locales from the available ones.
+ let installedLocales = new Set(await getAvailableLocales());
+ let notInstalledLocales = availableLangpacks
+ .filter(({ target_locale }) => !installedLocales.has(target_locale))
+ .map(lang => lang.target_locale);
+
+ // Create the rows for the remote locales.
+ let availableItems = await getLocaleDisplayInfo(notInstalledLocales);
+ availableItems.push({
+ label: await document.l10n.formatValue(
+ "messenger-languages-available-label"
+ ),
+ className: "label-item",
+ disabled: true,
+ installed: false,
+ });
+
+ // Remove the search option and add the remote locales.
+ let items = this._availableLocalesUI.items;
+ items.pop();
+ items = items.concat(availableItems);
+
+ // Update the dropdown and enable it again.
+ this._availableLocalesUI.setItems(items);
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+ },
+
+ /**
+ * @param {Set<string>} available - The set of available (BCP 47) locales.
+ */
+ async loadLocalesFromInstalled(available) {
+ let items;
+ if (available.length > 0) {
+ items = await getLocaleDisplayInfo(available);
+ items.push(await this.createInstalledLabel());
+ } else {
+ items = [];
+ }
+ if (this.downloadEnabled) {
+ items.push({
+ label: await document.l10n.formatValue("messenger-languages-search"),
+ value: "search",
+ });
+ }
+ this._availableLocalesUI.setItems(items);
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async availableLanguageSelected(item) {
+ if ((await getAvailableLocales()).includes(item.value)) {
+ await this.requestLocalLanguage(item);
+ } else if (this.availableLangpacks.has(item.value)) {
+ await this.requestRemoteLanguage(item);
+ } else {
+ this.showError();
+ }
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async requestLocalLanguage(item) {
+ this._selectedLocalesUI.addItem(item);
+ let selectedCount = this._selectedLocalesUI.items.length;
+ let availableCount = (await getAvailableLocales()).length;
+ if (selectedCount == availableCount) {
+ // Remove the installed label, they're all installed.
+ this._availableLocalesUI.items.shift();
+ this._availableLocalesUI.setItems(this._availableLocalesUI.items);
+ }
+
+ // The label isn't always reset when the selected item is removed, so set it again.
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async requestRemoteLanguage(item) {
+ this._availableLocalesUI.disableWithMessageId(
+ "messenger-languages-downloading"
+ );
+
+ let { url, hash } = this.availableLangpacks.get(item.value);
+ let addon;
+
+ try {
+ addon = await AddonManager.getInstallForURL(url, { hash });
+ await addon.install();
+ } catch (e) {
+ this.showError();
+ return;
+ }
+
+ // If the add-on was previously installed, it might be disabled still.
+ if (addon.userDisabled) {
+ await addon.enable();
+ }
+
+ item.installed = true;
+ this._selectedLocalesUI.addItem(item);
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+ },
+
+ showError() {
+ document.getElementById("warning-message").hidden = false;
+ this._availableLocalesUI.enableWithMessageId(
+ "messenger-languages-select-language"
+ );
+
+ // The height has likely changed, find our SubDialog and tell it to resize.
+ requestAnimationFrame(() => {
+ let dialogs = window.opener.gSubDialog._dialogs;
+ let index = dialogs.findIndex(d => d._frame.contentDocument == document);
+ if (index != -1) {
+ dialogs[index].resizeDialog();
+ }
+ });
+ },
+
+ hideError() {
+ document.getElementById("warning-message").hidden = true;
+ },
+
+ /**
+ * @param {LocaleDisplayInfo} item
+ */
+ async selectedLocaleRemoved(item) {
+ this._availableLocalesUI.addItem(item);
+
+ // If the item we added is at the top of the list, it needs the label.
+ if (this._availableLocalesUI.items[0] == item) {
+ this._availableLocalesUI.addItem(await this.createInstalledLabel());
+ }
+ },
+
+ async createInstalledLabel() {
+ return {
+ label: await document.l10n.formatValue(
+ "messenger-languages-installed-label"
+ ),
+ className: "label-item",
+ disabled: true,
+ installed: true,
+ };
+ },
+};
diff --git a/comm/mail/components/preferences/messengerLanguages.xhtml b/comm/mail/components/preferences/messengerLanguages.xhtml
new file mode 100644
index 0000000000..116ee4e5b2
--- /dev/null
+++ b/comm/mail/components/preferences/messengerLanguages.xhtml
@@ -0,0 +1,93 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="messenger-languages-window2"
+ onload="gMessengerLanguagesDialog.onLoad();"
+>
+ <dialog id="MessengerLanguagesDialog" buttons="accept,cancel">
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/languages.ftl"
+ />
+ </linkset>
+
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/messengerLanguages.js" />
+
+ <vbox
+ id="messengerLanguagesDialogPane"
+ class="prefpane largeDialogContainer"
+ flex="1"
+ >
+ <description data-l10n-id="messenger-languages-description" />
+ <hbox flex="1">
+ <vbox flex="1">
+ <richlistbox id="selectedLocales" flex="1" />
+ <menulist
+ id="availableLocales"
+ class="available-locales-list"
+ data-l10n-id="messenger-languages-select-language"
+ data-l10n-attrs="placeholder,label"
+ >
+ <menupopup />
+ </menulist>
+ </vbox>
+ <vbox>
+ <button
+ id="up"
+ class="action-button"
+ disabled="true"
+ data-l10n-id="languages-customize-moveup"
+ />
+ <button
+ id="down"
+ class="action-button"
+ disabled="true"
+ data-l10n-id="languages-customize-movedown"
+ />
+ <button
+ id="remove"
+ class="action-button"
+ disabled="true"
+ data-l10n-id="languages-customize-remove"
+ />
+ <vbox flex="1" pack="end">
+ <button
+ id="add"
+ class="add-messenger-language action-button"
+ data-l10n-id="languages-customize-add"
+ disabled="true"
+ />
+ </vbox>
+ </vbox>
+ </hbox>
+ <hbox
+ id="warning-message"
+ class="message-bar message-bar-warning"
+ hidden="true"
+ >
+ <html:img
+ class="message-bar-icon"
+ src="chrome://global/skin/icons/warning.svg"
+ alt=""
+ />
+ <description
+ class="message-bar-description"
+ data-l10n-id="messenger-languages-error"
+ />
+ </hbox>
+ <separator class="thin" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/moz.build b/comm/mail/components/preferences/moz.build
new file mode 100644
index 0000000000..4eff028aeb
--- /dev/null
+++ b/comm/mail/components/preferences/moz.build
@@ -0,0 +1,18 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+DEFINES["MOZ_MACBUNDLE_NAME"] = CONFIG["MOZ_MACBUNDLE_NAME"]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk", "cocoa"):
+ DEFINES["HAVE_SHELL_SERVICE"] = 1
+
+if CONFIG["MOZ_UPDATER"]:
+ DEFINES["MOZ_UPDATER"] = 1
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
diff --git a/comm/mail/components/preferences/notifications.js b/comm/mail/components/preferences/notifications.js
new file mode 100644
index 0000000000..0970a944bd
--- /dev/null
+++ b/comm/mail/components/preferences/notifications.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "mail.biff.alert.show_preview", type: "bool" },
+ { id: "mail.biff.alert.show_subject", type: "bool" },
+ { id: "mail.biff.alert.show_sender", type: "bool" },
+ { id: "alerts.totalOpenTime", type: "int" },
+]);
+
+var gNotificationsDialog = {
+ init() {
+ let element = document.getElementById("totalOpenTime");
+ Preferences.addSyncFromPrefListener(
+ element,
+ () => Preferences.get("alerts.totalOpenTime").value / 1000
+ );
+ Preferences.addSyncToPrefListener(element, element => element.value * 1000);
+ },
+};
+
+window.addEventListener("load", () => gNotificationsDialog.init());
diff --git a/comm/mail/components/preferences/notifications.xhtml b/comm/mail/components/preferences/notifications.xhtml
new file mode 100644
index 0000000000..d9abb47e4e
--- /dev/null
+++ b/comm/mail/components/preferences/notifications.xhtml
@@ -0,0 +1,71 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="notifications-dialog-window"
+>
+ <dialog id="NotificationsDialog" dlgbuttons="accept,cancel">
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/notifications.ftl"
+ />
+ </linkset>
+ <description data-l10n-id="customize-alert-description" />
+
+ <checkbox
+ id="previewText"
+ class="indent"
+ data-l10n-id="preview-text-checkbox"
+ preference="mail.biff.alert.show_preview"
+ />
+ <checkbox
+ id="subject"
+ class="indent"
+ data-l10n-id="subject-checkbox"
+ preference="mail.biff.alert.show_subject"
+ />
+ <checkbox
+ id="sender"
+ class="indent"
+ data-l10n-id="sender-checkbox"
+ preference="mail.biff.alert.show_sender"
+ />
+
+ <separator />
+
+ <hbox align="center">
+ <label
+ id="totalOpenTimeBefore"
+ control="totalOpenTime"
+ data-l10n-id="open-time-label-before"
+ />
+ <html:input
+ id="totalOpenTime"
+ type="number"
+ class="size3"
+ min="1"
+ max="3600"
+ preference="alerts.totalOpenTime"
+ />
+ <label
+ id="totalOpenTimeEnd"
+ data-l10n-id="open-time-label-after"
+ class="startSpacing"
+ />
+ </hbox>
+ <separator />
+
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/notifications.js" />
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/offline.js b/comm/mail/components/preferences/offline.js
new file mode 100644
index 0000000000..ae33950cf1
--- /dev/null
+++ b/comm/mail/components/preferences/offline.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "offline.autoDetect", type: "bool" },
+ { id: "offline.startup_state", type: "int" },
+ { id: "offline.send.unsent_messages", type: "int" },
+ { id: "offline.download.download_messages", type: "int" },
+]);
+
+var kAutomatic = 4;
+var kRememberLastState = 0;
+
+var gOfflineDialog = {
+ dialogSetup() {
+ let offlineAutoDetection = Preferences.get("offline.autoDetect");
+ let offlineStartupStatePref = Preferences.get("offline.startup_state");
+
+ offlineStartupStatePref.disabled = offlineAutoDetection.value;
+ if (offlineStartupStatePref.disabled) {
+ offlineStartupStatePref.value = kAutomatic;
+ } else if (offlineStartupStatePref.value == kAutomatic) {
+ offlineStartupStatePref.value = kRememberLastState;
+ }
+ },
+};
+
+Preferences.get("offline.autoDetect").on("change", gOfflineDialog.dialogSetup);
diff --git a/comm/mail/components/preferences/offline.xhtml b/comm/mail/components/preferences/offline.xhtml
new file mode 100644
index 0000000000..77a86cfa86
--- /dev/null
+++ b/comm/mail/components/preferences/offline.xhtml
@@ -0,0 +1,77 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="gOfflineDialog.dialogSetup();"
+ data-l10n-id="offline-dialog-window"
+>
+ <dialog id="OfflineSettingsDialog" dlgbuttons="accept,cancel">
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/offline.js" />
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/offline.ftl" />
+ </linkset>
+
+ <checkbox
+ data-l10n-id="autodetect-online-label"
+ preference="offline.autoDetect"
+ />
+
+ <separator class="thin" />
+
+ <label
+ data-l10n-id="offline-preference-startup-label"
+ control="whenStartingUp"
+ />
+ <radiogroup
+ id="whenStartingUp"
+ class="indent"
+ preference="offline.startup_state"
+ >
+ <radio value="0" data-l10n-id="status-radio-remember" />
+ <radio value="1" data-l10n-id="status-radio-ask" />
+ <radio value="2" data-l10n-id="status-radio-always-online" />
+ <radio value="3" data-l10n-id="status-radio-always-offline" />
+ <radio value="4" hidden="true" />
+ </radiogroup>
+
+ <separator />
+
+ <label data-l10n-id="going-online-label" control="whengoingOnlinestate" />
+ <radiogroup
+ id="whengoingOnlinestate"
+ orient="horizontal"
+ class="indent"
+ preference="offline.send.unsent_messages"
+ >
+ <radio value="1" data-l10n-id="going-online-auto" />
+ <radio value="2" data-l10n-id="going-online-not" />
+ <radio value="0" data-l10n-id="going-online-ask" />
+ </radiogroup>
+
+ <separator class="thin" />
+
+ <label data-l10n-id="going-offline-label" control="whengoingOfflinestate" />
+ <radiogroup
+ id="whengoingOfflinestate"
+ orient="horizontal"
+ class="indent"
+ preference="offline.download.download_messages"
+ >
+ <radio value="1" data-l10n-id="going-offline-auto" />
+ <radio value="2" data-l10n-id="going-offline-not" />
+ <radio value="0" data-l10n-id="going-offline-ask" />
+ </radiogroup>
+ <separator />
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/passwordManager.js b/comm/mail/components/preferences/passwordManager.js
new file mode 100644
index 0000000000..67f08767d3
--- /dev/null
+++ b/comm/mail/components/preferences/passwordManager.js
@@ -0,0 +1,819 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** * =================== SAVED SIGNONS CODE =================== */
+/* eslint-disable-next-line no-var */
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+/* eslint-disable-next-line no-var */
+/* eslint-disable-next-line no-var */
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+// Default value for signon table sorting
+let lastSignonSortColumn = "origin";
+let lastSignonSortAscending = true;
+
+let showingPasswords = false;
+
+// password-manager lists
+let signons = [];
+let deletedSignons = [];
+
+// Elements that would be used frequently
+let filterField;
+let togglePasswordsButton;
+let signonsIntro;
+let removeButton;
+let removeAllButton;
+let signonsTree;
+
+let signonReloadDisplay = {
+ observe(subject, topic, data) {
+ if (topic == "passwordmgr-storage-changed") {
+ switch (data) {
+ case "addLogin":
+ case "modifyLogin":
+ case "removeLogin":
+ case "removeAllLogins":
+ if (!signonsTree) {
+ return;
+ }
+ signons.length = 0;
+ LoadSignons();
+ // apply the filter if needed
+ if (filterField && filterField.value != "") {
+ FilterPasswords();
+ }
+ signonsTree.ensureRowIsVisible(
+ signonsTree.view.selection.currentIndex
+ );
+ break;
+ }
+ Services.obs.notifyObservers(null, "passwordmgr-dialog-updated");
+ }
+ },
+};
+
+// Formatter for localization.
+let dateFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+});
+let dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+});
+
+function Startup() {
+ // be prepared to reload the display if anything changes
+ Services.obs.addObserver(signonReloadDisplay, "passwordmgr-storage-changed");
+
+ signonsTree = document.getElementById("signonsTree");
+ filterField = document.getElementById("filter");
+ togglePasswordsButton = document.getElementById("togglePasswords");
+ signonsIntro = document.getElementById("signonsIntro");
+ removeButton = document.getElementById("removeSignon");
+ removeAllButton = document.getElementById("removeAllSignons");
+
+ document.l10n.setAttributes(togglePasswordsButton, "show-passwords");
+ document.l10n.setAttributes(signonsIntro, "logins-description-all");
+ document.l10n.setAttributes(removeAllButton, "remove-all");
+
+ document
+ .getElementsByTagName("treecols")[0]
+ .addEventListener("click", event => {
+ let { target, button } = event;
+ let sortField = target.getAttribute("data-field-name");
+
+ if (target.nodeName != "treecol" || button != 0 || !sortField) {
+ return;
+ }
+
+ SignonColumnSort(sortField);
+ });
+
+ LoadSignons();
+
+ // filter the table if requested by caller
+ if (
+ window.arguments &&
+ window.arguments[0] &&
+ window.arguments[0].filterString
+ ) {
+ setFilter(window.arguments[0].filterString);
+ }
+
+ FocusFilterBox();
+ document.l10n
+ .translateElements(document.querySelectorAll("[data-l10n-id]"))
+ .then(() => window.sizeToContent());
+}
+
+function Shutdown() {
+ Services.obs.removeObserver(
+ signonReloadDisplay,
+ "passwordmgr-storage-changed"
+ );
+}
+
+function setFilter(aFilterString) {
+ filterField.value = aFilterString;
+ FilterPasswords();
+}
+
+let signonsTreeView = {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+ _filterSet: [],
+ _lastSelectedRanges: [],
+ selection: null,
+
+ rowCount: 0,
+ setTree(tree) {},
+ getImageSrc(row, column) {
+ if (column.element.getAttribute("id") !== "providerCol") {
+ return "";
+ }
+
+ const signon = GetVisibleLogins()[row];
+
+ return PlacesUtils.urlWithSizeRef(window, "page-icon:" + signon.origin, 16);
+ },
+ getCellValue(row, column) {},
+ getCellText(row, column) {
+ let time;
+ let signon = GetVisibleLogins()[row];
+ switch (column.id) {
+ case "providerCol":
+ return signon.httpRealm
+ ? signon.origin + " (" + signon.httpRealm + ")"
+ : signon.origin;
+ case "userCol":
+ return signon.username || "";
+ case "passwordCol":
+ return signon.password || "";
+ case "timeCreatedCol":
+ time = new Date(signon.timeCreated);
+ return dateFormatter.format(time);
+ case "timeLastUsedCol":
+ time = new Date(signon.timeLastUsed);
+ return dateAndTimeFormatter.format(time);
+ case "timePasswordChangedCol":
+ time = new Date(signon.timePasswordChanged);
+ return dateFormatter.format(time);
+ case "timesUsedCol":
+ return signon.timesUsed;
+ default:
+ return "";
+ }
+ },
+ isEditable(row, col) {
+ if (col.id == "userCol" || col.id == "passwordCol") {
+ return true;
+ }
+ return false;
+ },
+ isSeparator(index) {
+ return false;
+ },
+ isSorted() {
+ return false;
+ },
+ isContainer(index) {
+ return false;
+ },
+ cycleHeader(column) {},
+ getRowProperties(row) {
+ return "";
+ },
+ getColumnProperties(column) {
+ return "";
+ },
+ getCellProperties(row, column) {
+ if (column.element.getAttribute("id") == "providerCol") {
+ return "ltr";
+ }
+
+ return "";
+ },
+ setCellText(row, col, value) {
+ let table = GetVisibleLogins();
+ function _editLogin(field) {
+ if (value == table[row][field]) {
+ return;
+ }
+ let existingLogin = table[row].clone();
+ table[row][field] = value;
+ table[row].timePasswordChanged = Date.now();
+ Services.logins.modifyLogin(existingLogin, table[row]);
+ signonsTree.invalidateRow(row);
+ }
+
+ if (col.id == "userCol") {
+ _editLogin("username");
+ } else if (col.id == "passwordCol") {
+ if (!value) {
+ return;
+ }
+ _editLogin("password");
+ }
+ },
+};
+
+function SortTree(column, ascending) {
+ let table = GetVisibleLogins();
+ // remember which item was selected so we can restore it after the sort
+ let selections = GetTreeSelections();
+ let selectedNumber = selections.length ? table[selections[0]].number : -1;
+ function compareFunc(a, b) {
+ let valA, valB;
+ switch (column) {
+ case "origin":
+ let realmA = a.httpRealm;
+ let realmB = b.httpRealm;
+ realmA = realmA == null ? "" : realmA.toLowerCase();
+ realmB = realmB == null ? "" : realmB.toLowerCase();
+
+ valA = a[column].toLowerCase() + realmA;
+ valB = b[column].toLowerCase() + realmB;
+ break;
+ case "username":
+ case "password":
+ valA = a[column].toLowerCase();
+ valB = b[column].toLowerCase();
+ break;
+
+ default:
+ valA = a[column];
+ valB = b[column];
+ }
+
+ if (valA < valB) {
+ return -1;
+ }
+ if (valA > valB) {
+ return 1;
+ }
+ return 0;
+ }
+
+ // do the sort
+ table.sort(compareFunc);
+ if (!ascending) {
+ table.reverse();
+ }
+
+ // restore the selection
+ let selectedRow = -1;
+ if (selectedNumber >= 0 && false) {
+ for (let s = 0; s < table.length; s++) {
+ if (table[s].number == selectedNumber) {
+ // update selection
+ // note: we need to deselect before reselecting in order to trigger ...Selected()
+ signonsTree.view.selection.select(-1);
+ signonsTree.view.selection.select(s);
+ selectedRow = s;
+ break;
+ }
+ }
+ }
+
+ // display the results
+ signonsTree.invalidate();
+ if (selectedRow >= 0) {
+ signonsTree.ensureRowIsVisible(selectedRow);
+ }
+}
+
+function LoadSignons() {
+ // loads signons into table
+ try {
+ signons = Services.logins.getAllLogins();
+ } catch (e) {
+ signons = [];
+ }
+ signons.forEach(login => login.QueryInterface(Ci.nsILoginMetaInfo));
+ signonsTreeView.rowCount = signons.length;
+
+ // sort and display the table
+ signonsTree.view = signonsTreeView;
+ // The sort column didn't change. SortTree (called by
+ // SignonColumnSort) assumes we want to toggle the sort
+ // direction but here we don't so we have to trick it
+ lastSignonSortAscending = !lastSignonSortAscending;
+ SignonColumnSort(lastSignonSortColumn);
+
+ // disable "remove all signons" button if there are no signons
+ if (signons.length == 0) {
+ removeAllButton.setAttribute("disabled", "true");
+ togglePasswordsButton.setAttribute("disabled", "true");
+ } else {
+ removeAllButton.removeAttribute("disabled");
+ togglePasswordsButton.removeAttribute("disabled");
+ }
+
+ return true;
+}
+
+function GetVisibleLogins() {
+ return signonsTreeView._filterSet.length
+ ? signonsTreeView._filterSet
+ : signons;
+}
+
+function GetTreeSelections() {
+ let selections = [];
+ let select = signonsTree.view.selection;
+ if (select) {
+ let count = select.getRangeCount();
+ let min = {};
+ let max = {};
+ for (let i = 0; i < count; i++) {
+ select.getRangeAt(i, min, max);
+ for (let k = min.value; k <= max.value; k++) {
+ if (k != -1) {
+ selections[selections.length] = k;
+ }
+ }
+ }
+ }
+ return selections;
+}
+
+function SignonSelected() {
+ let selections = GetTreeSelections();
+ if (selections.length) {
+ removeButton.removeAttribute("disabled");
+ } else {
+ removeButton.setAttribute("disabled", true);
+ }
+}
+
+function DeleteSignon() {
+ let syncNeeded = signonsTreeView._filterSet.length != 0;
+ let tree = signonsTree;
+ let view = signonsTreeView;
+ let table = GetVisibleLogins();
+
+ // Turn off tree selection notifications during the deletion
+ tree.view.selection.selectEventsSuppressed = true;
+
+ // remove selected items from list (by setting them to null) and place in deleted list
+ let selections = GetTreeSelections();
+ for (let s = selections.length - 1; s >= 0; s--) {
+ let i = selections[s];
+ deletedSignons.push(table[i]);
+ table[i] = null;
+ }
+
+ // collapse list by removing all the null entries
+ for (let j = 0; j < table.length; j++) {
+ if (table[j] == null) {
+ let k = j;
+ while (k < table.length && table[k] == null) {
+ k++;
+ }
+ table.splice(j, k - j);
+ view.rowCount -= k - j;
+ tree.rowCountChanged(j, j - k);
+ }
+ }
+
+ // update selection and/or buttons
+ if (table.length) {
+ // update selection
+ let nextSelection =
+ selections[0] < table.length ? selections[0] : table.length - 1;
+ tree.view.selection.select(nextSelection);
+ } else {
+ // disable buttons
+ removeButton.setAttribute("disabled", "true");
+ removeAllButton.setAttribute("disabled", "true");
+ }
+ tree.view.selection.selectEventsSuppressed = false;
+ FinalizeSignonDeletions(syncNeeded);
+}
+
+async function DeleteAllSignons() {
+ // Confirm the user wants to remove all passwords
+ let dummy = { value: false };
+ let [title, message] = await document.l10n.formatValues([
+ { id: "remove-all-passwords-title" },
+ { id: "remove-all-passwords-prompt" },
+ ]);
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ Services.prompt.STD_YES_NO_BUTTONS + Services.prompt.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ null,
+ dummy
+ ) == 1
+ ) {
+ // 1 == "No" button
+ return;
+ }
+
+ let syncNeeded = signonsTreeView._filterSet.length != 0;
+ let view = signonsTreeView;
+ let table = GetVisibleLogins();
+
+ // remove all items from table and place in deleted table
+ for (let i = 0; i < table.length; i++) {
+ deletedSignons.push(table[i]);
+ }
+ table.length = 0;
+
+ // clear out selections
+ view.selection.select(-1);
+
+ // update the tree view and notify the tree
+ view.rowCount = 0;
+
+ signonsTree.rowCountChanged(0, -deletedSignons.length);
+ signonsTree.invalidate();
+
+ // disable buttons
+ removeButton.setAttribute("disabled", "true");
+ removeAllButton.setAttribute("disabled", "true");
+ FinalizeSignonDeletions(syncNeeded);
+}
+
+async function TogglePasswordVisible() {
+ if (showingPasswords || (await masterPasswordLogin(AskUserShowPasswords))) {
+ showingPasswords = !showingPasswords;
+ document.l10n.setAttributes(
+ togglePasswordsButton,
+ showingPasswords ? "hide-passwords" : "show-passwords"
+ );
+ document.getElementById("passwordCol").hidden = !showingPasswords;
+ FilterPasswords();
+ }
+
+ // Notify observers that the password visibility toggling is
+ // completed. (Mostly useful for tests)
+ Services.obs.notifyObservers(null, "passwordmgr-password-toggle-complete");
+}
+
+async function AskUserShowPasswords() {
+ let dummy = { value: false };
+
+ // Confirm the user wants to display passwords
+ return (
+ Services.prompt.confirmEx(
+ window,
+ null,
+ await document.l10n.formatValue("no-master-password-prompt"),
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ dummy
+ ) == 0
+ ); // 0=="Yes" button
+}
+
+function FinalizeSignonDeletions(syncNeeded) {
+ for (let s = 0; s < deletedSignons.length; s++) {
+ Services.logins.removeLogin(deletedSignons[s]);
+ }
+ // If the deletion has been performed in a filtered view, reflect the deletion in the unfiltered table.
+ // See bug 405389.
+ if (syncNeeded) {
+ try {
+ signons = Services.logins.getAllLogins();
+ } catch (e) {
+ signons = [];
+ }
+ }
+ deletedSignons.length = 0;
+}
+
+function HandleSignonKeyPress(e) {
+ // If editing is currently performed, don't do anything.
+ if (signonsTree.getAttribute("editing")) {
+ return;
+ }
+ if (
+ e.keyCode == KeyboardEvent.DOM_VK_DELETE ||
+ (AppConstants.platform == "macosx" &&
+ e.keyCode == KeyboardEvent.DOM_VK_BACK_SPACE)
+ ) {
+ DeleteSignon();
+ e.preventDefault();
+ }
+}
+
+function getColumnByName(column) {
+ switch (column) {
+ case "origin":
+ return document.getElementById("providerCol");
+ case "username":
+ return document.getElementById("userCol");
+ case "password":
+ return document.getElementById("passwordCol");
+ case "timeCreated":
+ return document.getElementById("timeCreatedCol");
+ case "timeLastUsed":
+ return document.getElementById("timeLastUsedCol");
+ case "timePasswordChanged":
+ return document.getElementById("timePasswordChangedCol");
+ case "timesUsed":
+ return document.getElementById("timesUsedCol");
+ }
+ return undefined;
+}
+
+function SignonColumnSort(column) {
+ let sortedCol = getColumnByName(column);
+ let lastSortedCol = getColumnByName(lastSignonSortColumn);
+
+ // clear out the sortDirection attribute on the old column
+ lastSortedCol.removeAttribute("sortDirection");
+
+ // determine if sort is to be ascending or descending
+ lastSignonSortAscending =
+ column == lastSignonSortColumn ? !lastSignonSortAscending : true;
+
+ // sort
+ lastSignonSortColumn = column;
+ SortTree(lastSignonSortColumn, lastSignonSortAscending);
+
+ // set the sortDirection attribute to get the styling going
+ // first we need to get the right element
+ sortedCol.setAttribute(
+ "sortDirection",
+ lastSignonSortAscending ? "ascending" : "descending"
+ );
+}
+
+function SignonClearFilter() {
+ let singleSelection = signonsTreeView.selection.count == 1;
+
+ // Clear the Tree Display
+ signonsTreeView.rowCount = 0;
+ signonsTree.rowCountChanged(0, -signonsTreeView._filterSet.length);
+ signonsTreeView._filterSet = [];
+
+ // Just reload the list to make sure deletions are respected
+ LoadSignons();
+
+ // Restore selection
+ if (singleSelection) {
+ signonsTreeView.selection.clearSelection();
+ for (let i = 0; i < signonsTreeView._lastSelectedRanges.length; ++i) {
+ let range = signonsTreeView._lastSelectedRanges[i];
+ signonsTreeView.selection.rangedSelect(range.min, range.max, true);
+ }
+ } else {
+ signonsTreeView.selection.select(0);
+ }
+ signonsTreeView._lastSelectedRanges = [];
+
+ document.l10n.setAttributes(signonsIntro, "logins-description-all");
+ document.l10n.setAttributes(removeAllButton, "remove-all");
+}
+
+function FocusFilterBox() {
+ if (filterField.getAttribute("focused") != "true") {
+ filterField.focus();
+ }
+}
+
+function SignonMatchesFilter(aSignon, aFilterValue) {
+ if (aSignon.origin.toLowerCase().includes(aFilterValue)) {
+ return true;
+ }
+ if (
+ aSignon.username &&
+ aSignon.username.toLowerCase().includes(aFilterValue)
+ ) {
+ return true;
+ }
+ if (
+ aSignon.httpRealm &&
+ aSignon.httpRealm.toLowerCase().includes(aFilterValue)
+ ) {
+ return true;
+ }
+ if (
+ showingPasswords &&
+ aSignon.password &&
+ aSignon.password.toLowerCase().includes(aFilterValue)
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function _filterPasswords(aFilterValue, view) {
+ aFilterValue = aFilterValue.toLowerCase();
+ return signons.filter(s => SignonMatchesFilter(s, aFilterValue));
+}
+
+function SignonSaveState() {
+ // Save selection
+ let seln = signonsTreeView.selection;
+ signonsTreeView._lastSelectedRanges = [];
+ let rangeCount = seln.getRangeCount();
+ for (let i = 0; i < rangeCount; ++i) {
+ let min = {};
+ let max = {};
+ seln.getRangeAt(i, min, max);
+ signonsTreeView._lastSelectedRanges.push({
+ min: min.value,
+ max: max.value,
+ });
+ }
+}
+
+function FilterPasswords() {
+ if (filterField.value == "") {
+ SignonClearFilter();
+ return;
+ }
+
+ let newFilterSet = _filterPasswords(filterField.value, signonsTreeView);
+ if (!signonsTreeView._filterSet.length) {
+ // Save Display Info for the Non-Filtered mode when we first
+ // enter Filtered mode.
+ SignonSaveState();
+ }
+ signonsTreeView._filterSet = newFilterSet;
+
+ // Clear the display
+ let oldRowCount = signonsTreeView.rowCount;
+ signonsTreeView.rowCount = 0;
+ signonsTree.rowCountChanged(0, -oldRowCount);
+ // Set up the filtered display
+ signonsTreeView.rowCount = signonsTreeView._filterSet.length;
+ signonsTree.rowCountChanged(0, signonsTreeView.rowCount);
+
+ // if the view is not empty then select the first item
+ if (signonsTreeView.rowCount > 0) {
+ signonsTreeView.selection.select(0);
+ }
+
+ document.l10n.setAttributes(signonsIntro, "logins-description-filtered");
+ document.l10n.setAttributes(removeAllButton, "remove-all-shown");
+}
+
+function CopyProviderUrl() {
+ // Copy selected provider url to clipboard
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ let row = signonsTree.currentIndex;
+ let url = signonsTreeView.getCellText(row, { id: "providerCol" });
+ clipboard.copyString(url);
+}
+
+async function CopyPassword() {
+ // Don't copy passwords if we aren't already showing the passwords & a master
+ // password hasn't been entered.
+ if (!showingPasswords && !(await masterPasswordLogin())) {
+ return;
+ }
+ // Copy selected signon's password to clipboard
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ let row = signonsTree.currentIndex;
+ let password = signonsTreeView.getCellText(row, { id: "passwordCol" });
+ clipboard.copyString(password);
+}
+
+function CopyUsername() {
+ // Copy selected signon's username to clipboard
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ let row = signonsTree.currentIndex;
+ let username = signonsTreeView.getCellText(row, { id: "userCol" });
+ clipboard.copyString(username);
+}
+
+function EditCellInSelectedRow(columnName) {
+ let row = signonsTree.currentIndex;
+ let columnElement = getColumnByName(columnName);
+ signonsTree.startEditing(
+ row,
+ signonsTree.columns.getColumnFor(columnElement)
+ );
+}
+
+function UpdateContextMenu() {
+ let singleSelection = signonsTreeView.selection.count == 1;
+ let menuItems = new Map();
+ let menupopup = document.getElementById("signonsTreeContextMenu");
+ for (let menuItem of menupopup.querySelectorAll("menuitem")) {
+ menuItems.set(menuItem.id, menuItem);
+ }
+
+ if (!singleSelection) {
+ for (let menuItem of menuItems.values()) {
+ menuItem.setAttribute("disabled", "true");
+ }
+ return;
+ }
+
+ let selectedRow = signonsTree.currentIndex;
+
+ // Disable "Copy Username" if the username is empty.
+ if (signonsTreeView.getCellText(selectedRow, { id: "userCol" }) != "") {
+ menuItems.get("context-copyusername").removeAttribute("disabled");
+ } else {
+ menuItems.get("context-copyusername").setAttribute("disabled", "true");
+ }
+
+ menuItems.get("context-copyproviderurl").removeAttribute("disabled");
+ menuItems.get("context-editusername").removeAttribute("disabled");
+ menuItems.get("context-copypassword").removeAttribute("disabled");
+
+ // Disable "Edit Password" if the password column isn't showing.
+ if (!document.getElementById("passwordCol").hidden) {
+ menuItems.get("context-editpassword").removeAttribute("disabled");
+ } else {
+ menuItems.get("context-editpassword").setAttribute("disabled", "true");
+ }
+}
+
+async function masterPasswordLogin(noPasswordCallback) {
+ // This doesn't harm if passwords are not encrypted
+ let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(
+ Ci.nsIPK11TokenDB
+ );
+ let token = tokendb.getInternalKeyToken();
+
+ // If there is no primary password, still give the user a chance to opt-out of displaying passwords
+ if (token.checkPassword("")) {
+ // The OS re-authentication on Linux isn't working (Bug 1527745),
+ // still add the confirm dialog for Linux.
+ if (
+ Services.prefs.getBoolPref("signon.management.page.os-auth.enabled") &&
+ AppConstants.platform !== "linux"
+ ) {
+ // Require OS authentication before the user can show the passwords or copy them.
+ let messageId = "password-os-auth-dialog-message";
+ if (AppConstants.platform == "macosx") {
+ // MacOS requires a special format of this dialog string.
+ // See preferences.ftl for more information.
+ messageId += "-macosx";
+ }
+ let [messageText, captionText] = await document.l10n.formatMessages([
+ {
+ id: messageId,
+ },
+ {
+ id: "password-os-auth-dialog-caption",
+ },
+ ]);
+ let win = Services.wm.getMostRecentWindow("");
+ let loggedIn = await OSKeyStore.ensureLoggedIn(
+ messageText.value,
+ captionText.value,
+ win,
+ false
+ );
+ if (!loggedIn.authenticated) {
+ return false;
+ }
+ return true;
+ }
+ return noPasswordCallback ? noPasswordCallback() : true;
+ }
+
+ // So there's a primary password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl).
+ try {
+ // Relogin and ask for the primary password.
+ token.login(true); // 'true' means always prompt for token password. User will be prompted until
+ // clicking 'Cancel' or entering the correct password.
+ } catch (e) {
+ // An exception will be thrown if the user cancels the login prompt dialog.
+ // User is also logged out of Software Security Device.
+ }
+
+ return token.isLoggedIn();
+}
+
+function escapeKeyHandler() {
+ // If editing is currently performed, don't do anything.
+ if (signonsTree.getAttribute("editing")) {
+ return;
+ }
+ window.close();
+}
diff --git a/comm/mail/components/preferences/passwordManager.xhtml b/comm/mail/components/preferences/passwordManager.xhtml
new file mode 100644
index 0000000000..594f2da8d8
--- /dev/null
+++ b/comm/mail/components/preferences/passwordManager.xhtml
@@ -0,0 +1,186 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at https://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/preferences/passwordmgr.css"?>
+
+<window
+ id="SignonViewerDialog"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="Startup();"
+ onunload="Shutdown();"
+ data-l10n-id="saved-logins"
+ persist="width height"
+>
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/passwordManager.ftl"
+ />
+ </linkset>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/preferences/passwordManager.js" />
+
+ <keyset>
+ <key keycode="VK_ESCAPE" oncommand="escapeKeyHandler();" />
+ <key
+ data-l10n-id="window-close"
+ modifiers="accel"
+ oncommand="escapeKeyHandler();"
+ />
+ <key
+ data-l10n-id="focus-search-primary-shortcut"
+ modifiers="accel"
+ oncommand="FocusFilterBox();"
+ />
+ <key
+ data-l10n-id="focus-search-alt-shortcut"
+ modifiers="accel"
+ oncommand="FocusFilterBox();"
+ />
+ </keyset>
+
+ <popupset id="signonsTreeContextSet">
+ <menupopup id="signonsTreeContextMenu" onpopupshowing="UpdateContextMenu()">
+ <menuitem
+ id="context-copyproviderurl"
+ data-l10n-id="copy-provider-url-cmd"
+ oncommand="CopyProviderUrl()"
+ />
+ <menuseparator />
+ <menuitem
+ id="context-copyusername"
+ data-l10n-id="copy-username-cmd"
+ oncommand="CopyUsername()"
+ />
+ <menuitem
+ id="context-editusername"
+ data-l10n-id="edit-username-cmd"
+ oncommand="EditCellInSelectedRow('username')"
+ />
+ <menuseparator />
+ <menuitem
+ id="context-copypassword"
+ data-l10n-id="copy-password-cmd"
+ oncommand="CopyPassword()"
+ />
+ <menuitem
+ id="context-editpassword"
+ data-l10n-id="edit-password-cmd"
+ oncommand="EditCellInSelectedRow('password')"
+ />
+ </menupopup>
+ </popupset>
+
+ <!-- saved signons -->
+ <vbox id="savedsignons" class="contentPane" flex="1">
+ <!-- filter -->
+ <hbox align="center">
+ <search-textbox
+ id="filter"
+ flex="1"
+ aria-controls="signonsTree"
+ oncommand="FilterPasswords();"
+ data-l10n-id="search-filter"
+ />
+ </hbox>
+
+ <label control="signonsTree" id="signonsIntro" />
+ <separator class="thin" />
+ <tree
+ id="signonsTree"
+ flex="1"
+ onkeypress="HandleSignonKeyPress(event)"
+ onselect="SignonSelected();"
+ editable="true"
+ context="signonsTreeContextMenu"
+ >
+ <treecols>
+ <treecol
+ id="providerCol"
+ data-l10n-id="column-heading-provider"
+ data-field-name="origin"
+ persist="width"
+ ignoreincolumnpicker="true"
+ sortDirection="ascending"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="userCol"
+ data-l10n-id="column-heading-username"
+ ignoreincolumnpicker="true"
+ data-field-name="username"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="passwordCol"
+ data-l10n-id="column-heading-password"
+ ignoreincolumnpicker="true"
+ data-field-name="password"
+ persist="width"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timeCreatedCol"
+ data-l10n-id="column-heading-time-created"
+ data-field-name="timeCreated"
+ persist="width hidden"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timeLastUsedCol"
+ data-l10n-id="column-heading-time-last-used"
+ data-field-name="timeLastUsed"
+ persist="width hidden"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timePasswordChangedCol"
+ data-l10n-id="column-heading-time-password-changed"
+ data-field-name="timePasswordChanged"
+ persist="width hidden"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="timesUsedCol"
+ data-l10n-id="column-heading-times-used"
+ data-field-name="timesUsed"
+ persist="width hidden"
+ hidden="true"
+ />
+ <splitter class="tree-splitter" />
+ </treecols>
+ <treechildren />
+ </tree>
+ <separator class="thin" />
+ <hbox id="SignonViewerButtons">
+ <button
+ id="removeSignon"
+ disabled="true"
+ data-l10n-id="remove"
+ oncommand="DeleteSignon();"
+ />
+ <button id="removeAllSignons" oncommand="DeleteAllSignons();" />
+ <spacer flex="1" />
+ <button id="togglePasswords" oncommand="TogglePasswordVisible();" />
+ </hbox>
+ </vbox>
+ <hbox align="end">
+ <hbox class="actionButtons" flex="1">
+ <spacer flex="1" />
+ <button
+ oncommand="window.close();"
+ data-l10n-id="password-close-button"
+ />
+ </hbox>
+ </hbox>
+</window>
diff --git a/comm/mail/components/preferences/permissions.js b/comm/mail/components/preferences/permissions.js
new file mode 100644
index 0000000000..3a75bc0ec6
--- /dev/null
+++ b/comm/mail/components/preferences/permissions.js
@@ -0,0 +1,501 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// toolkit/content/treeUtils.js
+/* globals gTreeUtils */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+var NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions";
+
+/**
+ * Magic URI base used so the permission manager can store
+ * remote content permissions for a given email address.
+ */
+var MAILURI_BASE = "chrome://messenger/content/email=";
+
+function Permission(principal, type, capability) {
+ this.principal = principal;
+ this.origin = principal.origin;
+ this.type = type;
+ this.capability = capability;
+}
+
+var gPermissionManager = {
+ _type: "",
+ _permissions: [],
+ _permissionsToAdd: new Map(),
+ _permissionsToDelete: new Map(),
+ _tree: null,
+ _observerRemoved: false,
+
+ _view: {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+ _rowCount: 0,
+ get rowCount() {
+ return this._rowCount;
+ },
+ getCellText(aRow, aColumn) {
+ if (aColumn.id == "siteCol") {
+ return gPermissionManager._permissions[aRow].origin.replace(
+ MAILURI_BASE,
+ ""
+ );
+ } else if (aColumn.id == "statusCol") {
+ return gPermissionManager._permissions[aRow].capability;
+ }
+ return "";
+ },
+
+ isSeparator(aIndex) {
+ return false;
+ },
+ isSorted() {
+ return false;
+ },
+ isContainer(aIndex) {
+ return false;
+ },
+ setTree(aTree) {},
+ getImageSrc(aRow, aColumn) {},
+ getProgressMode(aRow, aColumn) {},
+ getCellValue(aRow, aColumn) {},
+ cycleHeader(column) {},
+ getRowProperties(row) {
+ return "";
+ },
+ getColumnProperties(column) {
+ return "";
+ },
+ getCellProperties(row, column) {
+ if (column.element.getAttribute("id") == "siteCol") {
+ return "ltr";
+ }
+ return "";
+ },
+ },
+
+ async _getCapabilityString(aCapability) {
+ var stringKey = null;
+ switch (aCapability) {
+ case Ci.nsIPermissionManager.ALLOW_ACTION:
+ stringKey = "permission-can-label";
+ break;
+ case Ci.nsIPermissionManager.DENY_ACTION:
+ stringKey = "permission-cannot-label";
+ break;
+ case Ci.nsICookiePermission.ACCESS_ALLOW_FIRST_PARTY_ONLY:
+ stringKey = "permission-can-access-first-party-label";
+ break;
+ case Ci.nsICookiePermission.ACCESS_SESSION:
+ stringKey = "permission-can-session-label";
+ break;
+ }
+ let string = await document.l10n.formatValue(stringKey);
+ return string;
+ },
+
+ async addPermission(aCapability) {
+ var textbox = document.getElementById("url");
+ var input_url = textbox.value.trim();
+ let principal;
+ try {
+ // The origin accessor on the principal object will throw if the
+ // principal doesn't have a canonical origin representation. This will
+ // help catch cases where the URI parser parsed something like
+ // `localhost:8080` as having the scheme `localhost`, rather than being
+ // an invalid URI. A canonical origin representation is required by the
+ // permission manager for storage, so this won't prevent any valid
+ // permissions from being entered by the user.
+ let uri;
+ try {
+ uri = Services.io.newURI(input_url);
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ // If we have ended up with an unknown scheme, the following will throw.
+ principal.origin;
+ } catch (ex) {
+ let scheme =
+ this._type != "image" || !input_url.includes("@")
+ ? "http://"
+ : MAILURI_BASE;
+ uri = Services.io.newURI(scheme + input_url);
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ // If we have ended up with an unknown scheme, the following will throw.
+ principal.origin;
+ }
+ } catch (ex) {
+ let [title, message] = await document.l10n.formatValues([
+ { id: "invalid-uri-title" },
+ { id: "invalid-uri-message" },
+ ]);
+ Services.prompt.alert(window, title, message);
+ return;
+ }
+
+ var capabilityString = await this._getCapabilityString(aCapability);
+
+ // check whether the permission already exists, if not, add it
+ let permissionExists = false;
+ let capabilityExists = false;
+ for (var i = 0; i < this._permissions.length; ++i) {
+ // Thunderbird compares origins, not principals here.
+ if (this._permissions[i].principal.origin == principal.origin) {
+ permissionExists = true;
+ capabilityExists = this._permissions[i].capability == capabilityString;
+ if (!capabilityExists) {
+ this._permissions[i].capability = capabilityString;
+ }
+ break;
+ }
+ }
+
+ let permissionParams = {
+ principal,
+ type: this._type,
+ capability: aCapability,
+ };
+ if (!permissionExists) {
+ this._permissionsToAdd.set(principal.origin, permissionParams);
+ this._addPermission(permissionParams);
+ } else if (!capabilityExists) {
+ this._permissionsToAdd.set(principal.origin, permissionParams);
+ this._handleCapabilityChange();
+ }
+
+ textbox.value = "";
+ textbox.focus();
+
+ // covers a case where the site exists already, so the buttons don't disable
+ this.onHostInput(textbox);
+
+ // enable "remove all" button as needed
+ document.getElementById("removeAllPermissions").disabled =
+ this._permissions.length == 0;
+ },
+
+ _removePermission(aPermission) {
+ this._removePermissionFromList(aPermission.principal);
+
+ // If this permission was added during this session, let's remove
+ // it from the pending adds list to prevent calls to the
+ // permission manager.
+ let isNewPermission = this._permissionsToAdd.delete(
+ aPermission.principal.origin
+ );
+
+ if (!isNewPermission) {
+ this._permissionsToDelete.set(aPermission.principal.origin, aPermission);
+ }
+ },
+
+ _handleCapabilityChange() {
+ // Re-do the sort, if the status changed from Block to Allow
+ // or vice versa, since if we're sorted on status, we may no
+ // longer be in order.
+ if (this._lastPermissionSortColumn == "statusCol") {
+ this._resortPermissions();
+ }
+ this._tree.invalidate();
+ },
+
+ _addPermission(aPermission) {
+ this._addPermissionToList(aPermission);
+ ++this._view._rowCount;
+ this._tree.rowCountChanged(this._view.rowCount - 1, 1);
+ // Re-do the sort, since we inserted this new item at the end.
+ this._resortPermissions();
+ },
+
+ _resortPermissions() {
+ gTreeUtils.sort(
+ this._tree,
+ this._view,
+ this._permissions,
+ this._lastPermissionSortColumn,
+ this._permissionsComparator,
+ this._lastPermissionSortColumn,
+ !this._lastPermissionSortAscending
+ ); // keep sort direction
+ },
+
+ onHostInput(aSiteField) {
+ document.getElementById("btnSession").disabled = !aSiteField.value;
+ document.getElementById("btnBlock").disabled = !aSiteField.value;
+ document.getElementById("btnAllow").disabled = !aSiteField.value;
+ },
+
+ onWindowKeyPress(aEvent) {
+ if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ window.close();
+ }
+ },
+
+ onHostKeyPress(aEvent) {
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ document.getElementById("btnAllow").click();
+ }
+ },
+
+ onLoad() {
+ var params = window.arguments[0];
+ this.init(params);
+ },
+
+ init(aParams) {
+ if (this._type) {
+ // reusing an open dialog, clear the old observer
+ this.uninit();
+ }
+
+ this._type = aParams.permissionType;
+ this._manageCapability = aParams.manageCapability;
+
+ var permissionsText = document.getElementById("permissionsText");
+ while (permissionsText.hasChildNodes()) {
+ permissionsText.lastChild.remove();
+ }
+ permissionsText.appendChild(document.createTextNode(aParams.introText));
+
+ document.title = aParams.windowTitle;
+
+ document.getElementById("btnBlock").hidden = !aParams.blockVisible;
+ document.getElementById("btnSession").hidden = !aParams.sessionVisible;
+ document.getElementById("btnAllow").hidden = !aParams.allowVisible;
+
+ var urlFieldVisible =
+ aParams.blockVisible || aParams.sessionVisible || aParams.allowVisible;
+
+ var urlField = document.getElementById("url");
+ urlField.value = aParams.prefilledHost;
+ urlField.hidden = !urlFieldVisible;
+
+ this.onHostInput(urlField);
+
+ var urlLabel = document.getElementById("urlLabel");
+ urlLabel.hidden = !urlFieldVisible;
+
+ let treecols = document.getElementsByTagName("treecols")[0];
+ treecols.addEventListener("click", event => {
+ if (event.target.nodeName != "treecol" || event.button != 0) {
+ return;
+ }
+
+ let sortField = event.target.getAttribute("data-field-name");
+ if (!sortField) {
+ return;
+ }
+
+ gPermissionManager.onPermissionSort(sortField);
+ });
+
+ Services.obs.notifyObservers(
+ null,
+ NOTIFICATION_FLUSH_PERMISSIONS,
+ this._type
+ );
+ Services.obs.addObserver(this, "perm-changed");
+
+ this._loadPermissions().then(() => urlField.focus());
+ },
+
+ uninit() {
+ if (!this._observerRemoved) {
+ Services.obs.removeObserver(this, "perm-changed");
+
+ this._observerRemoved = true;
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "perm-changed") {
+ var permission = aSubject.QueryInterface(Ci.nsIPermission);
+
+ // Ignore unrelated permission types.
+ if (permission.type != this._type) {
+ return;
+ }
+
+ if (aData == "added") {
+ this._addPermission(permission);
+ } else if (aData == "changed") {
+ for (var i = 0; i < this._permissions.length; ++i) {
+ if (permission.matches(this._permissions[i].principal, true)) {
+ this._permissions[i].capability = this._getCapabilityString(
+ permission.capability
+ );
+ break;
+ }
+ }
+ this._handleCapabilityChange();
+ } else if (aData == "deleted") {
+ this._removePermissionFromList(permission);
+ }
+ }
+ },
+
+ onPermissionSelected() {
+ var hasSelection = this._tree.view.selection.count > 0;
+ var hasRows = this._tree.view.rowCount > 0;
+ document.getElementById("removePermission").disabled =
+ !hasRows || !hasSelection;
+ document.getElementById("removeAllPermissions").disabled = !hasRows;
+ },
+
+ onPermissionDeleted() {
+ if (!this._view.rowCount) {
+ return;
+ }
+ var removedPermissions = [];
+ gTreeUtils.deleteSelectedItems(
+ this._tree,
+ this._view,
+ this._permissions,
+ removedPermissions
+ );
+ for (var i = 0; i < removedPermissions.length; ++i) {
+ var p = removedPermissions[i];
+ this._removePermission(p);
+ }
+ document.getElementById("removePermission").disabled =
+ !this._permissions.length;
+ document.getElementById("removeAllPermissions").disabled =
+ !this._permissions.length;
+ },
+
+ onAllPermissionsDeleted() {
+ if (!this._view.rowCount) {
+ return;
+ }
+ var removedPermissions = [];
+ gTreeUtils.deleteAll(
+ this._tree,
+ this._view,
+ this._permissions,
+ removedPermissions
+ );
+ for (var i = 0; i < removedPermissions.length; ++i) {
+ var p = removedPermissions[i];
+ this._removePermission(p);
+ }
+ document.getElementById("removePermission").disabled = true;
+ document.getElementById("removeAllPermissions").disabled = true;
+ },
+
+ onPermissionKeyPress(aEvent) {
+ if (
+ aEvent.keyCode == KeyEvent.DOM_VK_DELETE ||
+ (AppConstants.platform == "macosx" &&
+ aEvent.keyCode == KeyEvent.DOM_VK_BACK_SPACE)
+ ) {
+ this.onPermissionDeleted();
+ }
+ },
+
+ _lastPermissionSortColumn: "",
+ _lastPermissionSortAscending: false,
+ _permissionsComparator(a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ },
+
+ onPermissionSort(aColumn) {
+ this._lastPermissionSortAscending = gTreeUtils.sort(
+ this._tree,
+ this._view,
+ this._permissions,
+ aColumn,
+ this._permissionsComparator,
+ this._lastPermissionSortColumn,
+ this._lastPermissionSortAscending
+ );
+ this._lastPermissionSortColumn = aColumn;
+ },
+
+ onApplyChanges() {
+ // Stop observing permission changes since we are about
+ // to write out the pending adds/deletes and don't need
+ // to update the UI
+ this.uninit();
+
+ for (let permissionParams of this._permissionsToAdd.values()) {
+ Services.perms.addFromPrincipal(
+ permissionParams.principal,
+ permissionParams.type,
+ permissionParams.capability
+ );
+ }
+
+ for (let p of this._permissionsToDelete.values()) {
+ Services.perms.removeFromPrincipal(p.principal, p.type);
+ }
+
+ window.close();
+ },
+
+ async _loadPermissions() {
+ this._tree = document.getElementById("permissionsTree");
+ this._permissions = [];
+
+ for (let perm of Services.perms.all) {
+ await this._addPermissionToList(perm);
+ }
+
+ this._view._rowCount = this._permissions.length;
+
+ // sort and display the table
+ this._tree.view = this._view;
+ this.onPermissionSort("origin");
+
+ // disable "remove all" button if there are none
+ document.getElementById("removeAllPermissions").disabled =
+ this._permissions.length == 0;
+ },
+
+ async _addPermissionToList(aPermission) {
+ if (
+ aPermission.type == this._type &&
+ (!this._manageCapability ||
+ aPermission.capability == this._manageCapability)
+ ) {
+ var principal = aPermission.principal;
+ var capabilityString = await this._getCapabilityString(
+ aPermission.capability
+ );
+ var p = new Permission(principal, aPermission.type, capabilityString);
+ this._permissions.push(p);
+ }
+ },
+
+ _removePermissionFromList(aPrincipal) {
+ for (let i = 0; i < this._permissions.length; ++i) {
+ // Thunderbird compares origins, not principals here.
+ if (this._permissions[i].principal.origin == aPrincipal.origin) {
+ this._permissions.splice(i, 1);
+ this._view._rowCount--;
+ this._tree.rowCountChanged(this._view.rowCount - 1, -1);
+ this._tree.invalidate();
+ break;
+ }
+ }
+ },
+
+ setOrigin(aOrigin) {
+ document.getElementById("url").value = aOrigin;
+ },
+};
+
+function setOrigin(aOrigin) {
+ gPermissionManager.setOrigin(aOrigin);
+}
+
+function initWithParams(aParams) {
+ gPermissionManager.init(aParams);
+}
diff --git a/comm/mail/components/preferences/permissions.xhtml b/comm/mail/components/preferences/permissions.xhtml
new file mode 100644
index 0000000000..33340d3f63
--- /dev/null
+++ b/comm/mail/components/preferences/permissions.xhtml
@@ -0,0 +1,128 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE dialog>
+
+<window
+ id="PermissionsDialog"
+ class="windowDialog"
+ data-l10n-id="permissions-reminder-window2"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="gPermissionManager.onLoad();"
+ onunload="gPermissionManager.uninit();"
+ persist="width height"
+ onkeypress="gPermissionManager.onWindowKeyPress(event);"
+>
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://global/content/treeUtils.js" />
+ <script src="chrome://messenger/content/preferences/permissions.js" />
+
+ <linkset>
+ <html:link
+ rel="localization"
+ href="messenger/preferences/permissions.ftl"
+ />
+ </linkset>
+
+ <keyset>
+ <key
+ data-l10n-id="permission-preferences-close-window"
+ data-l10n-attrs="key"
+ modifiers="accel"
+ oncommand="window.close();"
+ />
+ </keyset>
+
+ <vbox class="contentPane largeDialogContainer" flex="1">
+ <description id="permissionsText" control="url" />
+ <separator class="thin" />
+ <label id="urlLabel" control="url" data-l10n-id="website-address-label" />
+ <hbox align="start" class="input-container">
+ <html:input
+ id="url"
+ type="text"
+ oninput="gPermissionManager.onHostInput(event.target);"
+ onkeypress="gPermissionManager.onHostKeyPress(event);"
+ />
+ </hbox>
+ <hbox pack="end">
+ <button
+ id="btnBlock"
+ disabled="true"
+ data-l10n-id="block-button"
+ oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.DENY_ACTION);"
+ />
+ <button
+ id="btnSession"
+ disabled="true"
+ data-l10n-id="allow-session-button"
+ oncommand="gPermissionManager.addPermission(Ci.nsICookiePermission.ACCESS_SESSION);"
+ />
+ <button
+ id="btnAllow"
+ disabled="true"
+ data-l10n-id="allow-button"
+ default="true"
+ oncommand="gPermissionManager.addPermission(Ci.nsIPermissionManager.ALLOW_ACTION);"
+ />
+ </hbox>
+ <separator class="thin" />
+ <tree
+ id="permissionsTree"
+ flex="1"
+ style="height: 18em"
+ hidecolumnpicker="true"
+ onkeypress="gPermissionManager.onPermissionKeyPress(event)"
+ onselect="gPermissionManager.onPermissionSelected();"
+ >
+ <treecols>
+ <treecol
+ id="siteCol"
+ data-l10n-id="treehead-sitename-label"
+ data-field-name="rawHost"
+ persist="width"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="statusCol"
+ data-l10n-id="treehead-status-label"
+ data-field-name="capability"
+ persist="width"
+ />
+ </treecols>
+ <treechildren />
+ </tree>
+ </vbox>
+ <vbox>
+ <hbox class="actionButtons" flex="1">
+ <button
+ id="removePermission"
+ disabled="true"
+ data-l10n-id="remove-site-button"
+ oncommand="gPermissionManager.onPermissionDeleted();"
+ />
+ <button
+ id="removeAllPermissions"
+ data-l10n-id="remove-all-site-button"
+ oncommand="gPermissionManager.onAllPermissionsDeleted();"
+ />
+ </hbox>
+ <spacer flex="1" />
+ <hbox class="actionButtons" pack="end" flex="1">
+ <button oncommand="window.close();" data-l10n-id="cancel-button" />
+ <button
+ id="btnApplyChanges"
+ oncommand="gPermissionManager.onApplyChanges();"
+ data-l10n-id="save-button"
+ />
+ </hbox>
+ </vbox>
+</window>
diff --git a/comm/mail/components/preferences/preferences.js b/comm/mail/components/preferences/preferences.js
new file mode 100644
index 0000000000..1a123527ca
--- /dev/null
+++ b/comm/mail/components/preferences/preferences.js
@@ -0,0 +1,453 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+/* import-globals-from general.js */
+/* import-globals-from compose.js */
+/* import-globals-from downloads.js */
+/* import-globals-from privacy.js */
+/* import-globals-from chat.js */
+/* import-globals-from sync.js */
+/* import-globals-from findInPage.js */
+/* globals gCalendarPane */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { calendarDeactivator } = ChromeUtils.import(
+ "resource:///modules/calendar/calCalendarDeactivator.jsm"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+
+var paneDeck = document.getElementById("paneDeck");
+var defaultPane = "paneGeneral";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(this, "gSubDialog", function () {
+ const { SubDialogManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/SubDialog.sys.mjs"
+ );
+ return new SubDialogManager({
+ dialogStack: document.getElementById("dialogStack"),
+ dialogTemplate: document.getElementById("dialogTemplate"),
+ dialogOptions: {
+ styleSheets: [
+ "chrome://messenger/skin/preferences/dialog.css",
+ "chrome://messenger/skin/preferences/preferences.css",
+ ],
+ resizeCallback: ({ title, frame }) => {
+ UIFontSize.registerWindow(frame.contentWindow);
+
+ // Search within main document and highlight matched keyword.
+ gSearchResultsPane.searchWithinNode(title, gSearchResultsPane.query);
+
+ // Search within sub-dialog document and highlight matched keyword.
+ gSearchResultsPane.searchWithinNode(
+ frame.contentDocument.firstElementChild,
+ gSearchResultsPane.query
+ );
+
+ // Creating tooltips for all the instances found
+ for (let node of gSearchResultsPane.listSearchTooltips) {
+ if (!node.tooltipNode) {
+ gSearchResultsPane.createSearchTooltip(
+ node,
+ gSearchResultsPane.query
+ );
+ }
+ }
+
+ // Resize the dialog to fit the content with edited font size.
+ requestAnimationFrame(() => {
+ let dialogs = frame.ownerGlobal.gSubDialog._dialogs;
+ let dialog = dialogs.find(
+ d => d._frame.contentDocument == frame.contentDocument
+ );
+ if (dialog) {
+ UIFontSize.resizeSubDialog(dialog);
+ }
+ });
+ },
+ },
+ });
+});
+
+document.addEventListener("DOMContentLoaded", init, { once: true });
+
+var gCategoryInits = new Map();
+var gLastCategory = { category: undefined, subcategory: undefined };
+
+function init_category_if_required(category) {
+ let categoryInfo = gCategoryInits.get(category);
+ if (!categoryInfo) {
+ throw new Error(
+ "Unknown in-content prefs category! Can't init " + category
+ );
+ }
+ if (categoryInfo.inited) {
+ return null;
+ }
+ return categoryInfo.init();
+}
+
+function register_module(categoryName, categoryObject) {
+ gCategoryInits.set(categoryName, {
+ inited: false,
+ async init() {
+ let template = document.getElementById(categoryName);
+ if (template) {
+ // Replace the template element with the nodes inside of it.
+ let frag = template.content;
+ await document.l10n.translateFragment(frag);
+
+ // Actually insert them into the DOM.
+ document.l10n.pauseObserving();
+ template.replaceWith(frag);
+ document.l10n.resumeObserving();
+
+ // Asks Preferences to update the attribute value of the entire
+ // document again (this can be simplified if we could separate the
+ // preferences of each pane.)
+ Preferences.queueUpdateOfAllElements();
+ }
+ categoryObject.init();
+ this.inited = true;
+ },
+ });
+}
+
+function init() {
+ register_module("paneGeneral", gGeneralPane);
+ register_module("paneCompose", gComposePane);
+ register_module("panePrivacy", gPrivacyPane);
+ register_module("paneCalendar", gCalendarPane);
+ if (AppConstants.NIGHTLY_BUILD) {
+ register_module("paneSync", gSyncPane);
+ }
+ register_module("paneSearchResults", gSearchResultsPane);
+ if (Services.prefs.getBoolPref("mail.chat.enabled")) {
+ register_module("paneChat", gChatPane);
+ } else {
+ // Remove the pane from the DOM so it doesn't get incorrectly included in
+ // the search results.
+ document.getElementById("paneChat").remove();
+ }
+
+ // If no calendar is currently enabled remove it from the DOM so it doesn't
+ // get incorrectly included in the search results.
+ if (!calendarDeactivator.isCalendarActivated) {
+ document.getElementById("paneCalendar").remove();
+ document.getElementById("category-calendar").remove();
+ }
+ gSearchResultsPane.init();
+
+ let categories = document.getElementById("categories");
+ categories.addEventListener("select", event => gotoPref(event.target.value));
+
+ document.documentElement.addEventListener("keydown", event => {
+ if (event.key == "Tab") {
+ categories.setAttribute("keyboard-navigation", "true");
+ } else if ((event.ctrlKey || event.metaKey) && event.key == "f") {
+ document.getElementById("searchInput").focus();
+ event.preventDefault();
+ }
+ });
+
+ categories.addEventListener("mousedown", function () {
+ this.removeAttribute("keyboard-navigation");
+ });
+
+ window.addEventListener("hashchange", onHashChange);
+ let lastSelected = Services.xulStore.getValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected"
+ );
+ gotoPref(lastSelected);
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+}
+
+function onHashChange() {
+ gotoPref();
+}
+
+async function gotoPref(aCategory) {
+ let categories = document.getElementById("categories");
+ const kDefaultCategoryInternalName = "paneGeneral";
+ const kDefaultCategory = "general";
+ let hash = document.location.hash;
+
+ let category = aCategory || hash.substr(1) || kDefaultCategoryInternalName;
+ let breakIndex = category.indexOf("-");
+ // Subcategories allow for selecting smaller sections of the preferences
+ // until proper search support is enabled (bug 1353954).
+ let subcategory = breakIndex != -1 && category.substring(breakIndex + 1);
+ if (subcategory) {
+ category = category.substring(0, breakIndex);
+ }
+ category = friendlyPrefCategoryNameToInternalName(category);
+ if (category != "paneSearchResults") {
+ gSearchResultsPane.query = null;
+ gSearchResultsPane.searchInput.value = "";
+ gSearchResultsPane.getFindSelection(window).removeAllRanges();
+ gSearchResultsPane.removeAllSearchTooltips();
+ gSearchResultsPane.removeAllSearchMenuitemIndicators();
+ } else if (!gSearchResultsPane.searchInput.value) {
+ // Something tried to send us to the search results pane without
+ // a query string. Default to the General pane instead.
+ category = kDefaultCategoryInternalName;
+ document.location.hash = kDefaultCategory;
+ gSearchResultsPane.query = null;
+ }
+
+ // Updating the hash (below) or changing the selected category
+ // will re-enter gotoPref.
+ if (gLastCategory.category == category && !subcategory) {
+ return;
+ }
+
+ let item;
+ if (category != "paneSearchResults") {
+ // Hide second level headers in normal view
+ for (let element of document.querySelectorAll(".search-header")) {
+ element.hidden = true;
+ }
+
+ item = categories.querySelector(".category[value=" + category + "]");
+ if (!item) {
+ category = kDefaultCategoryInternalName;
+ item = categories.querySelector(".category[value=" + category + "]");
+ }
+ }
+
+ if (
+ gLastCategory.category ||
+ category != kDefaultCategoryInternalName ||
+ subcategory
+ ) {
+ let friendlyName = internalPrefCategoryNameToFriendlyName(category);
+ document.location.hash = friendlyName;
+ }
+ // Need to set the gLastCategory before setting categories.selectedItem since
+ // the categories 'select' event will re-enter the gotoPref codepath.
+ gLastCategory.category = category;
+ gLastCategory.subcategory = subcategory;
+ if (item) {
+ categories.selectedItem = item;
+ } else {
+ categories.clearSelection();
+ }
+ window.history.replaceState(category, document.title);
+
+ try {
+ await init_category_if_required(category);
+ } catch (ex) {
+ console.error(
+ new Error(
+ "Error initializing preference category " + category + ": " + ex
+ )
+ );
+ throw ex;
+ }
+
+ // Bail out of this goToPref if the category
+ // or subcategory changed during async operation.
+ if (
+ gLastCategory.category !== category ||
+ gLastCategory.subcategory !== subcategory
+ ) {
+ return;
+ }
+
+ search(category, "data-category");
+
+ let mainContent = document.querySelector(".main-content");
+ mainContent.scrollTop = 0;
+
+ spotlight(subcategory, category);
+
+ document.dispatchEvent(new CustomEvent("paneSelected", { bubbles: true }));
+ document.getElementById("preferencesContainer").scrollTo(0, 0);
+ document.getElementById("paneDeck").setAttribute("lastSelected", category);
+ Services.xulStore.setValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected",
+ category
+ );
+}
+
+function friendlyPrefCategoryNameToInternalName(aName) {
+ if (aName.startsWith("pane")) {
+ return aName;
+ }
+ return "pane" + aName.substring(0, 1).toUpperCase() + aName.substr(1);
+}
+
+// This function is duplicated inside of utilityOverlay.js's openPreferences.
+function internalPrefCategoryNameToFriendlyName(aName) {
+ return (aName || "").replace(/^pane./, function (toReplace) {
+ return toReplace[4].toLowerCase();
+ });
+}
+
+function search(aQuery, aAttribute) {
+ let paneDeck = document.getElementById("paneDeck");
+ let elements = paneDeck.children;
+ for (let element of elements) {
+ // If the "data-hidden-from-search" is "true", the
+ // element will not get considered during search.
+ if (
+ element.getAttribute("data-hidden-from-search") != "true" ||
+ element.getAttribute("data-subpanel") == "true"
+ ) {
+ let attributeValue = element.getAttribute(aAttribute);
+ if (attributeValue == aQuery) {
+ element.hidden = false;
+ } else {
+ element.hidden = true;
+ }
+ } else if (
+ element.getAttribute("data-hidden-from-search") == "true" &&
+ !element.hidden
+ ) {
+ element.hidden = true;
+ }
+ element.classList.remove("visually-hidden");
+ }
+
+ let keysets = paneDeck.getElementsByTagName("keyset");
+ for (let element of keysets) {
+ let attributeValue = element.getAttribute(aAttribute);
+ if (attributeValue == aQuery) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", true);
+ }
+ }
+}
+
+async function spotlight(subcategory, category) {
+ let highlightedElements = document.querySelectorAll(".spotlight");
+ if (highlightedElements.length) {
+ for (let element of highlightedElements) {
+ element.classList.remove("spotlight");
+ }
+ }
+ if (subcategory) {
+ scrollAndHighlight(subcategory, category);
+ }
+}
+
+async function scrollAndHighlight(subcategory, category) {
+ let element = document.querySelector(`[data-subcategory="${subcategory}"]`);
+ if (!element) {
+ return;
+ }
+ let header = getClosestDisplayedHeader(element);
+
+ scrollContentTo(header);
+ element.classList.add("spotlight");
+}
+
+/**
+ * If there is no visible second level header it will return first level header,
+ * otherwise return second level header.
+ *
+ * @returns {Element} The closest displayed header.
+ */
+function getClosestDisplayedHeader(element) {
+ let header = element.closest("groupbox");
+ let searchHeader = header.querySelector(".search-header");
+ if (
+ searchHeader &&
+ searchHeader.hidden &&
+ header.previousElementSibling.classList.contains("subcategory")
+ ) {
+ header = header.previousElementSibling;
+ }
+ return header;
+}
+
+function scrollContentTo(element) {
+ const STICKY_CONTAINER_HEIGHT =
+ document.querySelector(".sticky-container").clientHeight;
+ let mainContent = document.querySelector(".main-content");
+ let top = element.getBoundingClientRect().top - STICKY_CONTAINER_HEIGHT;
+ mainContent.scroll({
+ top,
+ behavior: "smooth",
+ });
+}
+
+/**
+ * Selects the specified preferences pane
+ *
+ * @param paneID ID of prefpane to select
+ * @param scrollPaneTo ID of the element to scroll into view
+ * @param otherArgs.subdialog ID of button to activate, opening a subdialog
+ */
+function selectPrefPane(paneID, scrollPaneTo, otherArgs) {
+ if (paneID) {
+ if (gLastCategory.category != paneID) {
+ gotoPref(paneID);
+ }
+ if (scrollPaneTo) {
+ showTab(scrollPaneTo, otherArgs ? otherArgs.subdialog : undefined);
+ }
+ }
+}
+
+/**
+ * Select the specified tab
+ *
+ * @param scrollPaneTo ID of the element to scroll into view
+ * @param subdialogID ID of button to activate, opening a subdialog
+ */
+function showTab(scrollPaneTo, subdialogID) {
+ setTimeout(function () {
+ let scrollTarget = document.getElementById(scrollPaneTo);
+ if (scrollTarget.closest("groupbox")) {
+ scrollTarget = scrollTarget.closest("groupbox");
+ }
+ scrollTarget.scrollIntoView();
+ if (subdialogID) {
+ document.getElementById(subdialogID).click();
+ }
+ });
+}
+
+/**
+ * Filter the lastFallbackLocale from availableLocales if it doesn't have all
+ * of the needed strings.
+ *
+ * When the lastFallbackLocale isn't the defaultLocale, then by default only
+ * fluent strings are included. To fully use that locale you need the langpack
+ * to be installed, so if it isn't installed remove it from availableLocales.
+ */
+async function getAvailableLocales() {
+ let { availableLocales, defaultLocale, lastFallbackLocale } = Services.locale;
+ // If defaultLocale isn't lastFallbackLocale, then we still need the langpack
+ // for lastFallbackLocale for it to be useful.
+ if (defaultLocale != lastFallbackLocale) {
+ let lastFallbackId = `langpack-${lastFallbackLocale}@thunderbird.mozilla.org`;
+ let lastFallbackInstalled = await AddonManager.getAddonByID(lastFallbackId);
+ if (!lastFallbackInstalled) {
+ return availableLocales.filter(locale => locale != lastFallbackLocale);
+ }
+ }
+ return availableLocales;
+}
diff --git a/comm/mail/components/preferences/preferences.xhtml b/comm/mail/components/preferences/preferences.xhtml
new file mode 100644
index 0000000000..8092f1af97
--- /dev/null
+++ b/comm/mail/components/preferences/preferences.xhtml
@@ -0,0 +1,256 @@
+<?xml version="1.0"?>
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://global/skin/popup.css"?>
+<?xml-stylesheet href="chrome://global/skin/autocomplete.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/preferences/applications.css"?>
+<?xml-stylesheet href="chrome://global/skin/in-content/common.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/preferences/preferences.css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-preferences.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar.css"?>
+
+<!DOCTYPE html [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd">
+%editorOverlayDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+%globalDTD;
+<!ENTITY % eventDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+%eventDTD;
+]>
+
+<html id="MailPreferences" xmlns="http://www.w3.org/1999/xhtml"
+ role="document"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false">
+<head>
+ <title data-l10n-id="preferences-doc-title2"></title>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome:; connect-src *; script-src chrome: 'unsafe-inline'; img-src chrome: moz-icon: https: data:; style-src chrome: data: 'unsafe-inline'; object-src 'none'" />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/preferences/fonts.ftl" />
+ <link rel="localization" href="messenger/preferences/languages.ftl" />
+ <link rel="localization" href="messenger/aboutDialog.ftl"/>
+
+ <!-- Links below are only used for search-l10n-ids into subdialogs -->
+ <link rel="localization" href="messenger/preferences/receipts.ftl" />
+ <link rel="localization" href="messenger/preferences/permissions.ftl" />
+ <link rel="localization" href="messenger/preferences/cookies.ftl" />
+ <link rel="localization" href="messenger/preferences/system-integration.ftl" />
+ <link rel="localization" href="messenger/preferences/colors.ftl" />
+ <link rel="localization" href="messenger/preferences/dock-options.ftl" />
+ <link rel="localization" href="messenger/preferences/notifications.ftl" />
+ <link rel="localization" href="messenger/preferences/new-tag.ftl" />
+ <link rel="localization" href="toolkit/updates/history.ftl" />
+ <link rel="localization" href="messenger/preferences/connection.ftl" />
+ <link rel="localization" href="messenger/preferences/offline.ftl" />
+ <link rel="localization" href="toolkit/about/config.ftl" />
+ <link rel="localization" href="messenger/preferences/attachment-reminder.ftl" />
+ <link rel="localization" href="messenger/preferences/passwordManager.ftl" />
+ <link rel="localization" href="security/certificates/certManager.ftl" />
+ <link rel="localization" href="security/certificates/deviceManager.ftl" />
+#ifdef NIGHTLY_BUILD
+ <link rel="localization" href="messenger/preferences/sync-dialog.ftl" />
+ <link rel="localization" href="messenger/firefoxAccounts.ftl" />
+#endif
+
+ <script defer="defer" src="chrome://global/content/preferencesBindings.js"></script>
+#ifdef MOZ_UPDATER
+ <script defer="defer" src="chrome://messenger/content/aboutDialog-appUpdater.js"></script>
+#endif
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script>
+
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/preferences/preferences.js"></script>
+ <script defer="defer" src="chrome://messenger/content/preferences/findInPage.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/general.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/alarms.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-notifications-setting.js"/>
+ <script defer="defer" src="chrome://calendar/content/preferences/notifications.js"/>
+ <script defer="defer" src="chrome://calendar/content/preferences/categories.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/views.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/calendar-preferences.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <stack id="preferences-stack" flex="1">
+ <hbox id="prefBox" class="main-content" flex="1">
+
+ <vbox id="pref-category-box">
+
+ <!-- category list -->
+ <richlistbox id="categories"
+ data-l10n-id="category-list"
+ data-l10n-attrs="aria-label">
+ <richlistitem id="category-general"
+ class="category"
+ value="paneGeneral"
+ data-l10n-id="category-general"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/settings.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-general-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-compose"
+ class="category"
+ value="paneCompose"
+ data-l10n-id="category-compose"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/pencil.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-compose-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-privacy"
+ class="category"
+ value="panePrivacy"
+ data-l10n-id="category-privacy"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/lock.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-privacy-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-chat"
+ class="category"
+ value="paneChat"
+ data-l10n-id="category-chat"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/chat.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-chat-title"/>
+ </richlistitem>
+
+ <richlistitem id="category-calendar"
+ class="category"
+ value="paneCalendar"
+ data-l10n-id="category-calendar"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/calendar.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-calendar-title"/>
+ </richlistitem>
+#ifdef NIGHTLY_BUILD
+ <richlistitem id="category-sync"
+ class="category"
+ value="paneSync"
+ data-l10n-id="category-sync"
+ align="center">
+ <html:img class="category-icon"
+ src="chrome://messenger/skin/icons/new/touch/sync.svg"
+ alt="" />
+ <label class="category-name" flex="1" data-l10n-id="pane-sync-title"/>
+ </richlistitem>
+#endif
+ </richlistbox>
+
+ <spacer flex="1"/>
+
+ <vbox class="sidebar-footer-list">
+ <html:a id="accountButton" class="sidebar-footer-link"
+ onclick="MsgAccountManager(null);">
+ <html:img class="sidebar-footer-icon account-icon"
+ src="chrome://messenger/skin/icons/new/compact/account-settings.svg"
+ alt="" />
+ <label data-l10n-id="account-button" class="sidebar-footer-label" flex="1"/>
+ </html:a>
+
+ <html:a id="addonsButton" class="sidebar-footer-link"
+ onclick="window.browsingContext.topChromeWindow.openAddonsMgr();">
+ <html:img class="sidebar-footer-icon"
+ src="chrome://messenger/skin/icons/new/compact/extension.svg"
+ alt="" />
+ <label class="sidebar-footer-label"
+ data-l10n-id="open-addons-sidebar-button"
+ flex="1"/>
+ </html:a>
+ </vbox>
+
+ </vbox>
+
+ <vbox id="preferencesContainer" flex="1" align="start">
+ <vbox class="paneDeckContainer">
+ <hbox class="sticky-container" pack="end" align="top" flex="1">
+ <search-textbox id="searchInput"
+ data-l10n-id="search-preferences-input2"
+ data-l10n-attrs="placeholder, style"
+ hidden="true"/>
+ </hbox>
+ <vbox id="paneDeck">
+#include searchResults.inc.xhtml
+#include general.inc.xhtml
+#include compose.inc.xhtml
+#include privacy.inc.xhtml
+#include chat.inc.xhtml
+#include ../../../calendar/base/content/preferences/calendar-preferences.inc.xhtml
+#ifdef NIGHTLY_BUILD
+#include sync.inc.xhtml
+#endif
+ </vbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ <stack id="dialogStack" hidden="true"/>
+ <vbox id="dialogTemplate"
+ class="dialogOverlay"
+ align="center"
+ pack="center"
+ topmost="true"
+ hidden="true">
+ <vbox class="dialogBox"
+ pack="end"
+ role="dialog"
+ aria-labelledby="dialogTitle">
+ <hbox class="dialogTitleBar" align="center">
+ <label class="dialogTitle" flex="1"/>
+ <button class="dialogClose close-icon" data-l10n-id="close-button"/>
+ </hbox>
+ <browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </vbox>
+ </vbox>
+ </stack>
+
+ <!-- Helpers for the FileLink options browser select and autocomplete. -->
+ <popupset>
+ <!-- For select dropdowns. The menupopup is what shows the list of options,
+ and the popuponly menulist makes things like the menuactive attributes
+ work correctly on the menupopup. ContentSelectDropdown expects the
+ popuponly menulist to be its immediate parent. -->
+ <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
+ <menupopup rolluponmousewheel="true"
+ activateontab="true"
+ position="after_start"
+ level="parent"
+#ifdef XP_WIN
+ consumeoutsideclicks="false"
+ ignorekeys="shortcuts"
+#endif
+ />
+ </menulist>
+ <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete"
+ type="autocomplete"
+ role="group"
+ noautofocus="true"/>
+ </popupset>
+</html:body>
+</html>
diff --git a/comm/mail/components/preferences/preferencesTab.js b/comm/mail/components/preferences/preferencesTab.js
new file mode 100644
index 0000000000..0a59fda382
--- /dev/null
+++ b/comm/mail/components/preferences/preferencesTab.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// mail/base/content/specialTabs.js
+/* globals contentTabBaseType, DOMLinkHandler */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+/**
+ * A tab to show Preferences.
+ */
+var preferencesTabType = {
+ __proto__: contentTabBaseType,
+ name: "preferencesTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ bundle: Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ ),
+ protoSvc: Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService(
+ Ci.nsIExternalProtocolService
+ ),
+
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ preferencesTab: {
+ type: "preferencesTab",
+ },
+ },
+
+ shouldSwitchTo(aArgs) {
+ if (!this.tab) {
+ return -1;
+ }
+ this.tab.browser.contentWindow.selectPrefPane(
+ aArgs.paneID,
+ aArgs.scrollPaneTo,
+ aArgs.otherArgs
+ );
+ return document.getElementById("tabmail").tabInfo.indexOf(this.tab);
+ },
+
+ closeTab(aTab) {
+ this.tab = null;
+ },
+
+ openTab(aTab, aArgs) {
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/settings.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("preferencesTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "preferencesTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ aTab.panel.setAttribute("id", "preferencesTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+
+ // Start setting up the browser.
+ aTab.browser = aTab.panel.querySelector("browser");
+ aTab.browser.setAttribute(
+ "id",
+ "preferencesTabBrowser" + this.lastBrowserId
+ );
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "preferencesTabBrowser" + this.lastBrowserId
+ );
+ aTab.panel.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ aTab.url = "about:preferences";
+ aTab.paneID = aArgs.paneID;
+ aTab.scrollPaneTo = aArgs.scrollPaneTo;
+ aTab.otherArgs = aArgs.otherArgs;
+
+ // Now set up the listeners.
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ // Wait for full loading of the tab and the automatic selecting of last tab.
+ // Then run the given onload code.
+ aTab.browser.addEventListener(
+ "paneSelected",
+ function (event) {
+ aTab.pageLoading = false;
+ aTab.pageLoaded = true;
+
+ if ("onLoad" in aArgs) {
+ // Let selection of the initial pane complete before selecting another.
+ // Otherwise we can end up with two panes selected at once.
+ aTab.browser.contentWindow.setTimeout(() => {
+ // By now, the tab could already be closed. Check that it isn't.
+ if (aTab.panel) {
+ aArgs.onLoad(event, aTab.browser);
+ }
+ }, 0);
+ }
+ },
+ { once: true }
+ );
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = true;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", aTab.browser);
+ let params = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ postData: aArgs.postData || null,
+ };
+ aTab.browser.loadURI(Services.io.newURI("about:preferences"), params);
+
+ this.tab = aTab;
+ this.lastBrowserId++;
+ },
+
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ return {
+ paneID: aTab.paneID,
+ scrollPaneTo: aTab.scrollPaneTo,
+ otherArgs: aTab.otherArgs,
+ };
+ },
+
+ restoreTab(aTabmail, aPersistedState) {
+ aTabmail.openTab("preferencesTab", {
+ paneID: aPersistedState.paneID,
+ scrollPaneTo: aPersistedState.scrollPaneTo,
+ otherArgs: aPersistedState.otherArgs,
+ });
+ },
+};
diff --git a/comm/mail/components/preferences/privacy.inc.xhtml b/comm/mail/components/preferences/privacy.inc.xhtml
new file mode 100644
index 0000000000..6afa5bb840
--- /dev/null
+++ b/comm/mail/components/preferences/privacy.inc.xhtml
@@ -0,0 +1,597 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ <script src="chrome://messenger/content/preferences/privacy.js"/>
+
+ <stringbundle id="bundlePreferences" src="chrome://messenger/locale/preferences/preferences.properties"/>
+ <html:template id="panePrivacy">
+ <hbox id="privacyCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-main-header"></html:h1>
+ </hbox>
+
+ <!-- Mail Content -->
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="mailContentGroup" data-category="panePrivacy">
+ <html:legend data-l10n-id="mail-content"></html:legend>
+ <hbox id="remoteContentBox">
+ <checkbox id="acceptRemoteContent"
+ preference="mailnews.message_display.disable_remote_image"
+ data-l10n-id="remote-content-label"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="remoteContentExceptions"
+ oncommand="gPrivacyPane.showRemoteContentExceptions();"
+ data-l10n-id="exceptions-button"
+ search-l10n-ids="
+ permissions-reminder-window2.title,
+ website-address-label.value,
+ block-button.label,
+ allow-session-button.label,
+ allow-button.label,
+ treehead-sitename-label.label,
+ treehead-status-label.label,
+ remove-site-button.label,
+ remove-all-site-button.label,
+ cancel-button.label,
+ save-button.label,
+ permission-can-label,
+ permission-can-access-first-party-label,
+ permission-can-session-label,
+ permission-cannot-label,
+ invalid-uri-message,
+ invalid-uri-title"/>
+ </hbox>
+ </hbox>
+ <hbox>
+ <label is="text-link" id="acceptRemoteContentInfo"
+ href="https://support.mozilla.org/kb/remote-content-in-messages"
+ data-l10n-id="remote-content-info"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Web Content -->
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="webContentGroup" data-category="panePrivacy">
+ <html:legend data-l10n-id="web-content"></html:legend>
+ <checkbox id="keepHistory"
+ preference="places.history.enabled"
+ data-l10n-id="history-label"/>
+ <hbox id="cookiesBox">
+ <checkbox id="acceptCookies"
+ preference="network.cookie.cookieBehavior"
+ data-l10n-id="cookies-label"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="cookieExceptions"
+ oncommand="gPrivacyPane.showCookieExceptions();"
+ data-l10n-id="exceptions-button"
+ preference="pref.privacy.disable_button.cookie_exceptions"
+ search-l10n-ids="
+ permissions-reminder-window2.title,
+ website-address-label.value,
+ block-button.label,
+ allow-session-button.label,
+ allow-button.label,
+ treehead-sitename-label.label,
+ treehead-status-label.label,
+ remove-site-button.label,
+ remove-all-site-button.label,
+ cancel-button.label,
+ save-button.label,
+ permission-can-label,
+ permission-can-access-first-party-label,
+ permission-can-session-label,
+ permission-cannot-label,
+ invalid-uri-message,
+ invalid-uri-title"/>
+ </hbox>
+ </hbox>
+ <hbox id="acceptThirdPartyRow" class="indent">
+ <hbox id="acceptThirdPartyBox" align="center">
+ <label id="acceptThirdPartyLabel" control="acceptThirdPartyMenu"
+ data-l10n-id="third-party-label"/>
+ <hbox>
+ <menulist id="acceptThirdPartyMenu" preference="network.cookie.cookieBehavior">
+ <menupopup>
+ <menuitem data-l10n-id="third-party-always" value="always"/>
+ <menuitem data-l10n-id="third-party-visited" value="visited"/>
+ <menuitem data-l10n-id="third-party-never" value="never"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <hbox flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="showCookiesButton"
+ data-l10n-id="cookies-button"
+ oncommand="gPrivacyPane.showCookies();"
+ preference="pref.privacy.disable_button.view_cookies"
+ search-l10n-ids="
+ cookies-window-dialog2.title,
+ filter-search-label.value,
+ cookies-on-system-label,
+ treecol-site-header.label,
+ treecol-name-header.label,
+ props-name-label.value,
+ props-value-label.value,
+ props-domain-label.value,
+ props-path-label.value,
+ props-secure-label.value,
+ props-expires-label.value,
+ props-container-label.value,
+ remove-cookie-button.label,
+ remove-all-cookies-button.label,
+ cookie-close-button.label"/>
+ </hbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center">
+ <checkbox id="privacyDoNotTrackCheckbox"
+ class="tail-with-learn-more"
+ data-l10n-id="do-not-track-label"
+ preference="privacy.donottrackheader.enabled"/>
+ <label is="text-link" id="doNotTrackInfo"
+ href="https://www.mozilla.org/dnt"
+ data-l10n-id="dnt-learn-more-button"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="privacyPasswordsCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-passwords-header"></html:h1>
+ </hbox>
+
+ <separator data-category="panePrivacy"/>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <hbox align="center">
+ <description data-l10n-id="passwords-description"/>
+ <spacer flex="1"/>
+ <hbox>
+ <button is="highlightable-button" id="showPasswords"
+ data-l10n-id="passwords-button"
+ oncommand="gPrivacyPane.showPasswords();"
+ preference="pref.privacy.disable_button.view_passwords"
+ search-l10n-ids="
+ saved-logins.title,
+ copy-provider-url-cmd.label,
+ copy-username-cmd.label,
+ edit-username-cmd.label,
+ copy-password-cmd.label,
+ edit-password-cmd.label,
+ search-filter.placeholder,
+ column-heading-provider.label,
+ column-heading-username.label,
+ column-heading-password.label,
+ column-heading-time-created.label,
+ column-heading-time-last-used.label,
+ column-heading-time-password-changed.label,
+ column-heading-times-used.label,
+ remove.label,
+ import.label,
+ show-passwords.label,
+ hide-passwords.label,
+ logins-description-all,
+ logins-description-filtered,
+ remove-all.label,
+ remove-all-shown.label,
+ remove-all-passwords-prompt,
+ remove-all-passwords-title,
+ no-master-password-prompt,
+ password-os-auth-dialog-message,
+ password-os-auth-dialog-message-macosx,
+ password-os-auth-dialog-caption"/>
+ </hbox>
+ </hbox>
+ <!-- XXX button to do a showExceptions()? -->
+
+ <separator class="thin"/>
+
+ <description data-l10n-id="primary-password-description"/>
+ <hbox>
+ <checkbox id="useMasterPassword"
+ data-l10n-id="primary-password-label"
+ oncommand="gPrivacyPane.updateMasterPasswordButton();"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="changeMasterPassword"
+ data-l10n-id="primary-password-button"
+ oncommand="gPrivacyPane.changeMasterPassword();"/>
+ </hbox>
+ <!--
+ Those two strings are meant to be invisible and will be used exclusively to provide
+ localization for an alert window.
+ -->
+ <label id="fips-title" hidden="true" data-l10n-id="forms-primary-pw-fips-title"></label>
+ <label id="fips-desc" hidden="true" data-l10n-id="forms-master-pw-fips-desc"></label>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="privacyJunkCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-junk-header"></html:h1>
+ </hbox>
+
+ <separator data-category="panePrivacy"/>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <description data-l10n-id="junk-description"/>
+ <separator class="thin"/>
+ <hbox>
+ <checkbox id="manualMark"
+ data-l10n-id="junk-label"
+ preference="mail.spam.manualMark"
+ oncommand="gPrivacyPane.updateManualMarkMode(this.checked);"/>
+ <spacer flex="1"/>
+ </hbox>
+ <radiogroup id="manualMarkMode"
+ class="indent"
+ preference="mail.spam.manualMarkMode"
+ aria-labelledby="manualMark">
+ <hbox>
+ <radio id="manualMarkMode0"
+ value="0"
+ data-l10n-id="junk-move-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox>
+ <radio id="manualMarkMode1"
+ value="1"
+ data-l10n-id="junk-delete-label"/>
+ <spacer flex="1"/>
+ </hbox>
+ </radiogroup>
+ <hbox>
+ <checkbox id="markAsReadOnSpam"
+ data-l10n-id="junk-read-label"
+ preference="mail.spam.markAsReadOnSpam"/>
+ <spacer flex="1"/>
+ </hbox>
+ <hbox align="start">
+ <checkbox id="enableJunkLogging" data-l10n-id="junk-log-label"
+ oncommand="gPrivacyPane.updateJunkLogButton(this.checked);"
+ preference="mail.spam.logging.enabled"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="openJunkLogButton"
+ data-l10n-id="junk-log-button"
+ oncommand="gPrivacyPane.openJunkLog();"/>
+ </hbox>
+ <hbox align="start">
+ <spacer flex="1"/>
+ <button is="highlightable-button"
+ data-l10n-id="reset-junk-button"
+ oncommand="gPrivacyPane.resetTrainingData()"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+#ifdef MOZ_DATA_REPORTING
+ <hbox id="privacyDataCollectionCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="collection-header"></html:h1>
+ </hbox>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <description>
+ <label class="tail-with-learn-more" data-l10n-id="collection-description"/>
+ <label id="dataCollectionPrivacyNotice"
+ class="learnMore" is="text-link"
+ data-l10n-id="collection-privacy-notice"/>
+ </description>
+ <description>
+ <html:div id="telemetry-container" hidden="hidden">
+ <html:img id="telemetryInfoIcon" alt=""
+ src="chrome://global/skin/icons/info.svg" />
+ <html:span id="telemetryDisabledDescription"
+ class="tail-with-learn-more"
+ data-l10n-id="collection-health-report-telemetry-disabled">
+ </html:span>
+ <button id="telemetryDataDeletionLearnMore"
+ class="learnMore" is="text-link"
+ data-l10n-id="collection-health-report-telemetry-disabled-link"/>
+ </html:div>
+ </description>
+ <vbox data-subcategory="reports">
+ <hbox align="center">
+ <checkbox id="submitHealthReportBox"
+ data-l10n-id="collection-health-report"
+ class="tail-with-learn-more"/>
+ <label id="FHRLearnMore"
+ class="learnMore" is="text-link"
+ data-l10n-id="collection-health-report-link"/>
+ </hbox>
+#ifndef MOZ_TELEMETRY_REPORTING
+ <description id="TelemetryDisabledDesc"
+ class="indent tip-caption" control="telemetryGroup"
+ data-l10n-id="collection-health-report-disabled"/>
+#endif
+
+#ifdef MOZ_CRASHREPORTER
+ <hbox align="center">
+ <checkbox id="automaticallySubmitCrashesBox"
+ class="tail-with-learn-more"
+ preference="browser.crashReports.unsubmittedCheck.autoSubmit2"
+ data-l10n-id="collection-backlogged-crash-reports"/>
+ <label id="crashReporterLearnMore"
+ class="learnMore" is="text-link" data-l10n-id="collection-backlogged-crash-reports-link"/>
+ </hbox>
+#endif
+ </vbox>
+ </html:fieldset>
+ </html:div>
+#endif
+
+ <hbox id="privacySecurityCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="privacy-security-header"></html:h1>
+ </hbox>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <html:legend data-l10n-id="privacy-scam-detection-title"></html:legend>
+ <description data-l10n-id="phishing-description"/>
+ <separator class="thin"/>
+ <hbox>
+ <checkbox id="enablePhishingDetector"
+ data-l10n-id="phishing-label"
+ preference="mail.phishing.detection.enabled"/>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Anti Virus -->
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <html:legend data-l10n-id="privacy-anti-virus-title"></html:legend>
+ <description data-l10n-id="antivirus-description"/>
+ <separator class="thin"/>
+ <hbox>
+ <checkbox id="enableAntiVirusQuarantine"
+ data-l10n-id="antivirus-label"
+ preference="mailnews.downloadToTempFile"/>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset data-category="panePrivacy">
+ <html:legend data-l10n-id="privacy-certificates-title"></html:legend>
+ <description id="CertSelectionDesc" control="certSelection"
+ data-l10n-id="certificate-description"/>
+
+ <!--
+ The values on these radio buttons may look like l12y issues, but
+ they're not - this preference uses *those strings* as its values.
+ I KID YOU NOT.
+ -->
+ <radiogroup id="certSelection" class="indent"
+ orient="horizontal" preftype="string"
+ preference="security.default_personal_cert"
+ aria-labelledby="CertGroupCaption CertSelectionDesc">
+ <radio id="certSelectionAuto"
+ data-l10n-id="certificate-auto"
+ value="Select Automatically"/>
+ <radio id="certSelectionAsk"
+ data-l10n-id="certificate-ask"
+ value="Ask Every Time"/>
+ </radiogroup>
+
+ <separator/>
+
+ <hbox align="start">
+ <checkbox id="enableOCSP"
+ data-l10n-id="ocsp-label"
+ preference="security.OCSP.enabled"
+ flex="1"/>
+ <spacer flex="1"/>
+ <vbox>
+ <hbox flex="1">
+ <button is="highlightable-button" id="manageCertificatesButton"
+ data-l10n-id="certificate-button"
+ flex="1"
+ oncommand="gPrivacyPane.showCertificates();"
+ preference="security.disable_button.openCertManager"
+ search-l10n-ids="
+ certmgr-title.title,
+ certmgr-tab-mine.label,
+ certmgr-tab-remembered.label,
+ certmgr-tab-people.label,
+ certmgr-tab-servers.label,
+ certmgr-tab-ca.label,
+ certmgr-mine,
+ certmgr-remembered,
+ certmgr-people,
+ certmgr-ca,
+ certmgr-server,
+ certmgr-edit-ca-cert2.title,
+ certmgr-edit-cert-edit-trust,
+ certmgr-edit-cert-trust-ssl.label,
+ certmgr-edit-cert-trust-email.label,
+ certmgr-delete-cert2.title,
+ certmgr-cert-host.label,
+ certmgr-cert-name.label,
+ certmgr-cert-server.label,
+ certmgr-token-name.label,
+ certmgr-begins-label.label,
+ certmgr-expires-label.label,
+ certmgr-email.label,
+ certmgr-serial.label,
+ certmgr-fingerprint-sha-256.label,
+ certmgr-view.label,
+ certmgr-edit.label,
+ certmgr-export.label,
+ certmgr-delete.label,
+ certmgr-delete-builtin.label,
+ certmgr-backup.label,
+ certmgr-backup-all.label,
+ certmgr-restore.label,
+ certmgr-add-exception.label,
+ exception-mgr.title,
+ exception-mgr-extra-button.label,
+ exception-mgr-supplemental-warning,
+ exception-mgr-cert-location-url.value,
+ exception-mgr-cert-location-download.label,
+ exception-mgr-cert-status-view-cert.label,
+ exception-mgr-permanent.label,
+ pk11-bad-password,
+ pkcs12-decode-err,
+ pkcs12-unknown-err-restore,
+ pkcs12-unknown-err-backup,
+ pkcs12-unknown-err,
+ pkcs12-info-no-smartcard-backup,
+ pkcs12-dup-data,
+ choose-p12-backup-file-dialog,
+ file-browse-pkcs12-spec,
+ choose-p12-restore-file-dialog,
+ file-browse-certificate-spec,
+ import-ca-certs-prompt,
+ import-email-cert-prompt,
+ delete-user-cert-title.title,
+ delete-user-cert-confirm,
+ delete-user-cert-impact,
+ delete-ca-cert-title.title,
+ delete-ca-cert-confirm,
+ delete-ca-cert-impact,
+ delete-ssl-override-title.title,
+ delete-ssl-override-confirm,
+ delete-ssl-override-impact,
+ delete-email-cert-title.title,
+ delete-email-cert-confirm,
+ delete-email-cert-impact,
+ send-no-client-certificate,
+ no-cert-stored-for-override,
+ permanent-override,
+ temporary-override,
+ add-exception-branded-warning,
+ add-exception-invalid-header,
+ add-exception-domain-mismatch-short,
+ add-exception-domain-mismatch-long,
+ add-exception-expired-short,
+ add-exception-expired-long,
+ add-exception-unverified-or-bad-signature-short,
+ add-exception-unverified-or-bad-signature-long,
+ add-exception-valid-short,
+ add-exception-valid-long,
+ add-exception-checking-short,
+ add-exception-checking-long,
+ add-exception-no-cert-short,
+ add-exception-no-cert-long,
+ save-cert-as,
+ cert-format-base64,
+ cert-format-base64-chain,
+ write-file-failure,
+ cert-format-der,
+ cert-format-pkcs7,
+ cert-format-pkcs7-chain"/>
+ </hbox>
+ <hbox flex="1">
+ <button is="highlightable-button" id="viewSecurityDevicesButton"
+ data-l10n-id="security-devices-button"
+ flex="1"
+ oncommand="gPrivacyPane.showSecurityDevices();"
+ preference="security.disable_button.openDeviceManager"
+ search-l10n-ids="
+ devmgr-window.title,
+ devmgr-devlist.label,
+ devmgr-header-details.label,
+ devmgr-header-value.label,
+ devmgr-button-login.label,
+ devmgr-button-logout.label,
+ devmgr-button-changepw.label,
+ devmgr-button-load.label,
+ devmgr-button-unload.label,
+ devmgr-button-enable-fips.label,
+ devmgr-button-disable-fips.label,
+ load-device.title,
+ load-device-info,
+ load-device-modname.value,
+ load-device-modname-default.value,
+ load-device-filename.value,
+ load-device-browse.label,
+ devinfo-status.label,
+ devinfo-status-disabled.label,
+ devinfo-status-not-present.label,
+ devinfo-status-uninitialized.label,
+ devinfo-status-not-logged-in.label,
+ devinfo-status-logged-in.label,
+ devinfo-status-ready.label,
+ devinfo-desc.label,
+ unable-to-toggle-fips,
+ load-pk11-module-file-picker-title,
+ fips-nonempty-primary-password-required,
+ load-module-help-root-certs-module-name.value,
+ add-module-failure,
+ del-module-warning,
+ del-module-error,
+ devinfo-man-id.label,
+ devinfo-hwversion.label,
+ load-module-help-empty-module-name.value,
+ devinfo-fwversion.label,
+ devinfo-modname.label,
+ devinfo-modpath.label,
+ login-failed,
+ devinfo-label.label,
+ devinfo-serialnum"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <!-- Email End-To-End Encryption -->
+ <hbox id="privacyCategory"
+ class="subcategory"
+ data-category="panePrivacy">
+ <html:h1 data-l10n-id="email-e2ee-header"></html:h1>
+ </hbox>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="emailE2eeGroupPreparation" data-category="panePrivacy">
+ <description data-l10n-id="email-e2ee-enable-info"/>
+ <html:button id="settingsButton"
+ type="button"
+ data-l10n-id="account-button"
+ class="button button-flat"
+ onclick="window.browsingContext.topChromeWindow.MsgAccountManager(null);">
+ </html:button>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="panePrivacy">
+ <html:fieldset id="emailE2eeGroupAutomatism" data-category="panePrivacy">
+ <html:legend data-l10n-id="email-e2ee-automatism"></html:legend>
+ <description data-l10n-id="email-e2ee-automatism-pre"/>
+ <separator class="thin"/>
+
+ <checkbox id="emailE2eeAutoEnable"
+ preference="mail.e2ee.auto_enable"
+ data-l10n-id="email-e2ee-auto-on"
+ oncommand="gPrivacyPane.updateE2eeCheckboxes();"/>
+ <checkbox id="emailE2eeAutoDisable"
+ preference="mail.e2ee.auto_disable"
+ data-l10n-id="email-e2ee-auto-off"
+ oncommand="gPrivacyPane.updateE2eeCheckboxes();"/>
+ <checkbox id="emailE2eeAutoDisableNotify"
+ preference="mail.e2ee.notify_on_auto_disable"
+ data-l10n-id="email-e2ee-auto-off-notify"
+ oncommand="gPrivacyPane.updateE2eeCheckboxes();"/>
+
+ <separator class="thin"/>
+ <description data-l10n-id="email-e2ee-automatism-post"/>
+ </html:fieldset>
+ </html:div>
+ </html:template>
diff --git a/comm/mail/components/preferences/privacy.js b/comm/mail/components/preferences/privacy.js
new file mode 100644
index 0000000000..2bb8cba6ad
--- /dev/null
+++ b/comm/mail/components/preferences/privacy.js
@@ -0,0 +1,562 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from preferences.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+const PREF_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
+
+Preferences.addAll([
+ { id: "mail.spam.manualMark", type: "bool" },
+ { id: "mail.spam.manualMarkMode", type: "int" },
+ { id: "mail.spam.markAsReadOnSpam", type: "bool" },
+ { id: "mail.spam.logging.enabled", type: "bool" },
+ { id: "mail.phishing.detection.enabled", type: "bool" },
+ { id: "browser.safebrowsing.enabled", type: "bool" },
+ { id: "mailnews.downloadToTempFile", type: "bool" },
+ { id: "pref.privacy.disable_button.view_passwords", type: "bool" },
+ { id: "pref.privacy.disable_button.cookie_exceptions", type: "bool" },
+ { id: "pref.privacy.disable_button.view_cookies", type: "bool" },
+ {
+ id: "mailnews.message_display.disable_remote_image",
+ type: "bool",
+ inverted: "true",
+ },
+ { id: "places.history.enabled", type: "bool" },
+ { id: "network.cookie.cookieBehavior", type: "int" },
+ { id: "network.cookie.blockFutureCookies", type: "bool" },
+ { id: "privacy.donottrackheader.enabled", type: "bool" },
+ { id: "security.default_personal_cert", type: "string" },
+ { id: "security.disable_button.openCertManager", type: "bool" },
+ { id: "security.disable_button.openDeviceManager", type: "bool" },
+ { id: "security.OCSP.enabled", type: "int" },
+ { id: "mail.e2ee.auto_enable", type: "bool" },
+ { id: "mail.e2ee.auto_disable", type: "bool" },
+ { id: "mail.e2ee.notify_on_auto_disable", type: "bool" },
+]);
+
+if (AppConstants.MOZ_DATA_REPORTING) {
+ Preferences.addAll([
+ // Preference instances for prefs that we need to monitor while the page is open.
+ { id: PREF_UPLOAD_ENABLED, type: "bool" },
+ ]);
+}
+
+// Data Choices tab
+if (AppConstants.MOZ_CRASHREPORTER) {
+ Preferences.add({
+ id: "browser.crashReports.unsubmittedCheck.autoSubmit2",
+ type: "bool",
+ });
+}
+
+function setEventListener(aId, aEventType, aCallback) {
+ document
+ .getElementById(aId)
+ .addEventListener(aEventType, aCallback.bind(gPrivacyPane));
+}
+
+var gPrivacyPane = {
+ init() {
+ this.updateManualMarkMode(Preferences.get("mail.spam.manualMark").value);
+ this.updateJunkLogButton(
+ Preferences.get("mail.spam.logging.enabled").value
+ );
+
+ this._initMasterPasswordUI();
+
+ if (AppConstants.MOZ_DATA_REPORTING) {
+ this.initDataCollection();
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ this.initSubmitCrashes();
+ }
+ this.initSubmitHealthReport();
+ setEventListener(
+ "submitHealthReportBox",
+ "command",
+ gPrivacyPane.updateSubmitHealthReport
+ );
+ setEventListener(
+ "telemetryDataDeletionLearnMore",
+ "command",
+ gPrivacyPane.showDataDeletion
+ );
+ }
+
+ this.readAcceptCookies();
+ let element = document.getElementById("acceptCookies");
+ Preferences.addSyncFromPrefListener(element, () =>
+ this.readAcceptCookies()
+ );
+ Preferences.addSyncToPrefListener(element, () => this.writeAcceptCookies());
+
+ element = document.getElementById("acceptThirdPartyMenu");
+ Preferences.addSyncFromPrefListener(element, () =>
+ this.readAcceptThirdPartyCookies()
+ );
+ Preferences.addSyncToPrefListener(element, () =>
+ this.writeAcceptThirdPartyCookies()
+ );
+
+ element = document.getElementById("enableOCSP");
+ Preferences.addSyncFromPrefListener(element, () => this.readEnableOCSP());
+ Preferences.addSyncToPrefListener(element, () => this.writeEnableOCSP());
+
+ this.initE2eeCheckboxes();
+ },
+
+ /**
+ * Reload the current message after a preference affecting the view
+ * has been changed.
+ */
+ reloadMessageInOpener() {
+ if (window.opener && typeof window.opener.ReloadMessage == "function") {
+ window.opener.ReloadMessage();
+ }
+ },
+
+ /**
+ * Reads the network.cookie.cookieBehavior preference value and
+ * enables/disables the rest of the cookie UI accordingly, returning true
+ * if cookies are enabled.
+ */
+ readAcceptCookies() {
+ let pref = Preferences.get("network.cookie.cookieBehavior");
+ let exceptionsButton = document.getElementById("cookieExceptions");
+ let acceptThirdPartyLabel = document.getElementById(
+ "acceptThirdPartyLabel"
+ );
+ let acceptThirdPartyMenu = document.getElementById("acceptThirdPartyMenu");
+ let showCookiesButton = document.getElementById("showCookiesButton");
+
+ // enable the rest of the UI for anything other than "disable all cookies"
+ let acceptCookies = pref.value != 2;
+ let cookieBehaviorLocked = Services.prefs.prefIsLocked(
+ "network.cookie.cookieBehavior"
+ );
+
+ exceptionsButton.disabled = cookieBehaviorLocked;
+ acceptThirdPartyLabel.disabled = acceptThirdPartyMenu.disabled =
+ !acceptCookies || cookieBehaviorLocked;
+ showCookiesButton.disabled = cookieBehaviorLocked;
+
+ return acceptCookies;
+ },
+
+ /**
+ * Enables/disables the "keep until" label and menulist in response to the
+ * "accept cookies" checkbox being checked or unchecked.
+ *
+ * @returns 0 if cookies are accepted, 2 if they are not;
+ * the value network.cookie.cookieBehavior should get
+ */
+ writeAcceptCookies() {
+ let accept = document.getElementById("acceptCookies");
+ let acceptThirdPartyMenu = document.getElementById("acceptThirdPartyMenu");
+ // if we're enabling cookies, automatically select 'accept third party always'
+ if (accept.checked) {
+ acceptThirdPartyMenu.selectedIndex = 0;
+ }
+
+ return accept.checked ? 0 : 2;
+ },
+
+ /**
+ * Displays fine-grained, per-site preferences for cookies.
+ */
+ showCookieExceptions() {
+ let bundle = document.getElementById("bundlePreferences");
+ let params = {
+ blockVisible: true,
+ sessionVisible: true,
+ allowVisible: true,
+ prefilledHost: "",
+ permissionType: "cookie",
+ windowTitle: bundle.getString("cookiepermissionstitle"),
+ introText: bundle.getString("cookiepermissionstext"),
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ undefined,
+ params
+ );
+ },
+
+ /**
+ * Displays all the user's cookies in a dialog.
+ */
+ showCookies(aCategory) {
+ gSubDialog.open("chrome://messenger/content/preferences/cookies.xhtml");
+ },
+
+ /**
+ * Converts between network.cookie.cookieBehavior and the third-party cookie UI
+ */
+ readAcceptThirdPartyCookies() {
+ let pref = Preferences.get("network.cookie.cookieBehavior");
+ switch (pref.value) {
+ case 0:
+ return "always";
+ case 1:
+ return "never";
+ case 2:
+ return "never";
+ case 3:
+ return "visited";
+ default:
+ return undefined;
+ }
+ },
+
+ writeAcceptThirdPartyCookies() {
+ let accept = document.getElementById("acceptThirdPartyMenu").selectedItem;
+ switch (accept.value) {
+ case "always":
+ return 0;
+ case "visited":
+ return 3;
+ case "never":
+ return 1;
+ default:
+ return undefined;
+ }
+ },
+
+ /**
+ * Displays fine-grained, per-site preferences for remote content.
+ * We use the "image" type for that, but it can also be stylesheets or
+ * iframes.
+ */
+ showRemoteContentExceptions() {
+ let bundle = document.getElementById("bundlePreferences");
+ let params = {
+ blockVisible: true,
+ sessionVisible: false,
+ allowVisible: true,
+ prefilledHost: "",
+ permissionType: "image",
+ windowTitle: bundle.getString("imagepermissionstitle"),
+ introText: bundle.getString("imagepermissionstext"),
+ };
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ undefined,
+ params
+ );
+ },
+ updateManualMarkMode(aEnableRadioGroup) {
+ document.getElementById("manualMarkMode").disabled = !aEnableRadioGroup;
+ },
+
+ updateJunkLogButton(aEnableButton) {
+ document.getElementById("openJunkLogButton").disabled = !aEnableButton;
+ },
+
+ openJunkLog() {
+ // The junk log dialog can't work as a sub-dialog, because that means
+ // loading it in a browser, and we can't load a chrome: page containing a
+ // file: page in a browser. Open it as a real dialog instead.
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://messenger/content/junkLog.xhtml"
+ );
+ },
+
+ resetTrainingData() {
+ // make sure the user really wants to do this
+ var bundle = document.getElementById("bundlePreferences");
+ var title = bundle.getString("confirmResetJunkTrainingTitle");
+ var text = bundle.getString("confirmResetJunkTrainingText");
+
+ // if the user says no, then just fall out
+ if (!Services.prompt.confirm(window, title, text)) {
+ return;
+ }
+
+ // otherwise go ahead and remove the training data
+ MailServices.junk.resetTrainingData();
+ },
+
+ /**
+ * Initializes primary password UI: the "use primary password" checkbox, selects
+ * the primary password button to show, and enables/disables it as necessary.
+ * The primary password is controlled by various bits of NSS functionality,
+ * so the UI for it can't be controlled by the normal preference bindings.
+ */
+ _initMasterPasswordUI() {
+ var noMP = !LoginHelper.isPrimaryPasswordSet();
+
+ var button = document.getElementById("changeMasterPassword");
+ button.disabled = noMP;
+
+ var checkbox = document.getElementById("useMasterPassword");
+ checkbox.checked = !noMP;
+ checkbox.disabled =
+ (noMP && !Services.policies.isAllowed("createMasterPassword")) ||
+ (!noMP && !Services.policies.isAllowed("removeMasterPassword"));
+ },
+
+ /**
+ * Enables/disables the primary password button depending on the state of the
+ * "use primary password" checkbox, and prompts for primary password removal
+ * if one is set.
+ */
+ async updateMasterPasswordButton() {
+ var checkbox = document.getElementById("useMasterPassword");
+ var button = document.getElementById("changeMasterPassword");
+ button.disabled = !checkbox.checked;
+
+ // unchecking the checkbox should try to immediately remove the master
+ // password, because it's impossible to non-destructively remove the master
+ // password used to encrypt all the passwords without providing it (by
+ // design), and it would be extremely odd to pop up that dialog when the
+ // user closes the prefwindow and saves his settings
+ if (!checkbox.checked) {
+ await this._removeMasterPassword();
+ } else {
+ await this.changeMasterPassword();
+ }
+
+ this._initMasterPasswordUI();
+ },
+
+ /**
+ * Displays the "remove primary password" dialog to allow the user to remove
+ * the current primary password. When the dialog is dismissed, primary password
+ * UI is automatically updated.
+ */
+ async _removeMasterPassword() {
+ var secmodDB = Cc["@mozilla.org/security/pkcs11moduledb;1"].getService(
+ Ci.nsIPKCS11ModuleDB
+ );
+ if (secmodDB.isFIPSEnabled) {
+ let title = document.getElementById("fips-title").textContent;
+ let desc = document.getElementById("fips-desc").textContent;
+ Services.prompt.alert(window, title, desc);
+ this._initMasterPasswordUI();
+ } else {
+ gSubDialog.open("chrome://mozapps/content/preferences/removemp.xhtml", {
+ closingCallback: this._initMasterPasswordUI.bind(this),
+ });
+ }
+ this._initMasterPasswordUI();
+ },
+
+ /**
+ * Displays a dialog in which the primary password may be changed.
+ */
+ async changeMasterPassword() {
+ // OS reauthenticate functionality is not available on Linux yet (bug 1527745)
+ if (
+ !LoginHelper.isPrimaryPasswordSet() &&
+ Services.prefs.getBoolPref("signon.management.page.os-auth.enabled") &&
+ AppConstants.platform != "linux"
+ ) {
+ let messageId =
+ "primary-password-os-auth-dialog-message-" + AppConstants.platform;
+ let [messageText, captionText] = await document.l10n.formatMessages([
+ {
+ id: messageId,
+ },
+ {
+ id: "master-password-os-auth-dialog-caption",
+ },
+ ]);
+ let win = Services.wm.getMostRecentWindow("");
+ let loggedIn = await OSKeyStore.ensureLoggedIn(
+ messageText.value,
+ captionText.value,
+ win,
+ false
+ );
+ if (!loggedIn.authenticated) {
+ return;
+ }
+ }
+
+ gSubDialog.open("chrome://mozapps/content/preferences/changemp.xhtml", {
+ closingCallback: this._initMasterPasswordUI.bind(this),
+ });
+ },
+
+ /**
+ * Shows the sites where the user has saved passwords and the associated
+ * login information.
+ */
+ showPasswords() {
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/passwordManager.xhtml"
+ );
+ },
+
+ updateDownloadedPhishingListState() {
+ document.getElementById("useDownloadedList").disabled =
+ !document.getElementById("enablePhishingDetector").checked;
+ },
+
+ /**
+ * Display the user's certificates and associated options.
+ */
+ showCertificates() {
+ gSubDialog.open("chrome://pippki/content/certManager.xhtml");
+ },
+
+ /**
+ * security.OCSP.enabled is an integer value for legacy reasons.
+ * A value of 1 means OCSP is enabled. Any other value means it is disabled.
+ */
+ readEnableOCSP() {
+ var preference = Preferences.get("security.OCSP.enabled");
+ // This is the case if the preference is the default value.
+ if (preference.value === undefined) {
+ return true;
+ }
+ return preference.value == 1;
+ },
+
+ /**
+ * See documentation for readEnableOCSP.
+ */
+ writeEnableOCSP() {
+ var checkbox = document.getElementById("enableOCSP");
+ return checkbox.checked ? 1 : 0;
+ },
+
+ /**
+ * Display a dialog from which the user can manage his security devices.
+ */
+ showSecurityDevices() {
+ gSubDialog.open("chrome://pippki/content/device_manager.xhtml");
+ },
+
+ /**
+ * Displays the learn more health report page when a user opts out of data collection.
+ */
+ showDataDeletion() {
+ let url =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "telemetry-clientid";
+ window.open(url, "_blank");
+ },
+
+ initDataCollection() {
+ this._setupLearnMoreLink(
+ "toolkit.datacollection.infoURL",
+ "dataCollectionPrivacyNotice"
+ );
+ },
+
+ initSubmitCrashes() {
+ this._setupLearnMoreLink(
+ "toolkit.crashreporter.infoURL",
+ "crashReporterLearnMore"
+ );
+ },
+
+ /**
+ * Set up or hide the Learn More links for various data collection options
+ */
+ _setupLearnMoreLink(pref, element) {
+ // set up the Learn More link with the correct URL
+ let url = Services.urlFormatter.formatURLPref(pref);
+ let el = document.getElementById(element);
+
+ if (url) {
+ el.setAttribute("href", url);
+ } else {
+ el.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Initialize the health report service reference and checkbox.
+ */
+ initSubmitHealthReport() {
+ this._setupLearnMoreLink(
+ "datareporting.healthreport.infoURL",
+ "FHRLearnMore"
+ );
+
+ let checkbox = document.getElementById("submitHealthReportBox");
+
+ // Telemetry is only sending data if MOZ_TELEMETRY_REPORTING is defined.
+ // We still want to display the preferences panel if that's not the case, but
+ // we want it to be disabled and unchecked.
+ if (
+ Services.prefs.prefIsLocked(PREF_UPLOAD_ENABLED) ||
+ !AppConstants.MOZ_TELEMETRY_REPORTING
+ ) {
+ checkbox.setAttribute("disabled", "true");
+ return;
+ }
+
+ checkbox.checked =
+ Services.prefs.getBoolPref(PREF_UPLOAD_ENABLED) &&
+ AppConstants.MOZ_TELEMETRY_REPORTING;
+ },
+
+ /**
+ * Update the health report preference with state from checkbox.
+ */
+ updateSubmitHealthReport() {
+ let checkbox = document.getElementById("submitHealthReportBox");
+
+ Services.prefs.setBoolPref(PREF_UPLOAD_ENABLED, checkbox.checked);
+
+ // If allow telemetry is checked, hide the box saying you're no longer
+ // allowing it.
+ document.getElementById("telemetry-container").hidden = checkbox.checked;
+ },
+
+ initE2eeCheckboxes() {
+ let on = document.getElementById("emailE2eeAutoEnable");
+ let off = document.getElementById("emailE2eeAutoDisable");
+ let notify = document.getElementById("emailE2eeAutoDisableNotify");
+
+ on.checked = Preferences.get("mail.e2ee.auto_enable").value;
+ off.checked = Preferences.get("mail.e2ee.auto_disable").value;
+ notify.checked = Preferences.get("mail.e2ee.notify_on_auto_disable").value;
+
+ if (!on.checked) {
+ off.disabled = true;
+ notify.disabled = true;
+ } else {
+ off.disabled = false;
+ notify.disabled = !off.checked;
+ }
+ },
+
+ updateE2eeCheckboxes() {
+ let on = document.getElementById("emailE2eeAutoEnable");
+ let off = document.getElementById("emailE2eeAutoDisable");
+ let notify = document.getElementById("emailE2eeAutoDisableNotify");
+
+ if (!on.checked) {
+ off.disabled = true;
+ notify.disabled = true;
+ } else {
+ off.disabled = false;
+ notify.disabled = !off.checked;
+ }
+ },
+};
+
+Preferences.get("mailnews.message_display.disable_remote_image").on(
+ "change",
+ gPrivacyPane.reloadMessageInOpener
+);
+Preferences.get("mail.phishing.detection.enabled").on(
+ "change",
+ gPrivacyPane.reloadMessageInOpener
+);
diff --git a/comm/mail/components/preferences/receipts.js b/comm/mail/components/preferences/receipts.js
new file mode 100644
index 0000000000..748a65d127
--- /dev/null
+++ b/comm/mail/components/preferences/receipts.js
@@ -0,0 +1,38 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../toolkit/content/preferencesBindings.js */
+
+Preferences.addAll([
+ { id: "mail.receipt.request_return_receipt_on", type: "bool" },
+ { id: "mail.incorporate.return_receipt", type: "int" },
+ { id: "mail.mdn.report.enabled", type: "bool" },
+ { id: "mail.mdn.report.not_in_to_cc", type: "int" },
+ { id: "mail.mdn.report.outside_domain", type: "int" },
+ { id: "mail.mdn.report.other", type: "int" },
+]);
+
+/**
+ * Enables/disables the labels and menulists depending whether
+ * sending of return receipts is enabled.
+ */
+function enableDisableAllowedReceipts() {
+ let enable = document.getElementById("receiptSend").value === "true";
+ enableElement(document.getElementById("notInToCcLabel"), enable);
+ enableElement(document.getElementById("notInToCcPref"), enable);
+ enableElement(document.getElementById("outsideDomainLabel"), enable);
+ enableElement(document.getElementById("outsideDomainPref"), enable);
+ enableElement(document.getElementById("otherCasesLabel"), enable);
+ enableElement(document.getElementById("otherCasesPref"), enable);
+}
+
+/**
+ * Set disabled state of aElement, unless its associated pref is locked.
+ */
+function enableElement(aElement, aEnable) {
+ let pref = aElement.getAttribute("preference");
+ let prefIsLocked = pref ? Preferences.get(pref).locked : false;
+ aElement.disabled = !aEnable || prefIsLocked;
+}
diff --git a/comm/mail/components/preferences/receipts.xhtml b/comm/mail/components/preferences/receipts.xhtml
new file mode 100644
index 0000000000..32f8a7237d
--- /dev/null
+++ b/comm/mail/components/preferences/receipts.xhtml
@@ -0,0 +1,120 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="receipts-dialog-window"
+ onload="enableDisableAllowedReceipts();"
+>
+ <dialog id="ReturnReceiptsDialog" dlgbuttons="accept,cancel">
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://messenger/content/preferences/receipts.js" />
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/receipts.ftl" />
+ </linkset>
+
+ <vbox id="returnReceiptSettings" align="start">
+ <checkbox
+ id="alwaysRequest"
+ data-l10n-id="return-receipt-checkbox-control"
+ preference="mail.receipt.request_return_receipt_on"
+ />
+ </vbox>
+
+ <separator class="thin" />
+ <separator class="groove" />
+ <separator class="thin" />
+
+ <label control="receiptFolder" data-l10n-id="receipt-arrive-label" />
+ <radiogroup
+ id="receiptFolder"
+ class="indent"
+ preference="mail.incorporate.return_receipt"
+ >
+ <radio value="0" data-l10n-id="receipt-leave-radio-control" />
+ <radio value="1" data-l10n-id="receipt-move-radio-control" />
+ </radiogroup>
+
+ <separator class="thin" />
+ <separator class="groove" />
+ <separator class="thin" />
+
+ <label control="receiptSend" data-l10n-id="receipt-request-label" />
+ <radiogroup
+ id="receiptSend"
+ class="indent"
+ preference="mail.mdn.report.enabled"
+ oncommand="enableDisableAllowedReceipts();"
+ >
+ <radio value="false" data-l10n-id="receipt-return-never-radio-control" />
+ <radio value="true" data-l10n-id="receipt-return-some-radio-control" />
+
+ <vbox class="indent">
+ <hbox align="center">
+ <hbox flex="1">
+ <label
+ id="notInToCcLabel"
+ data-l10n-id="receipt-not-to-cc-label"
+ control="notInToCcPref"
+ />
+ </hbox>
+ <menulist
+ id="notInToCcPref"
+ preference="mail.mdn.report.not_in_to_cc"
+ >
+ <menupopup>
+ <menuitem value="0" data-l10n-id="receipt-send-never-label" />
+ <menuitem value="1" data-l10n-id="receipt-send-always-label" />
+ <menuitem value="2" data-l10n-id="receipt-send-ask-label" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <hbox align="center">
+ <hbox flex="1">
+ <label
+ id="outsideDomainLabel"
+ data-l10n-id="sender-outside-domain-label"
+ control="outsideDomainPref"
+ />
+ </hbox>
+ <menulist
+ id="outsideDomainPref"
+ preference="mail.mdn.report.outside_domain"
+ >
+ <menupopup>
+ <menuitem value="0" data-l10n-id="receipt-send-never-label" />
+ <menuitem value="1" data-l10n-id="receipt-send-always-label" />
+ <menuitem value="2" data-l10n-id="receipt-send-ask-label" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <hbox align="center">
+ <hbox flex="1">
+ <label
+ id="otherCasesLabel"
+ data-l10n-id="other-cases-text-label"
+ control="otherCasesPref"
+ />
+ </hbox>
+ <menulist id="otherCasesPref" preference="mail.mdn.report.other">
+ <menupopup>
+ <menuitem value="0" data-l10n-id="receipt-send-never-label" />
+ <menuitem value="1" data-l10n-id="receipt-send-always-label" />
+ <menuitem value="2" data-l10n-id="receipt-send-ask-label" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </vbox>
+ </radiogroup>
+ <separator />
+ </dialog>
+</window>
diff --git a/comm/mail/components/preferences/searchResults.inc.xhtml b/comm/mail/components/preferences/searchResults.inc.xhtml
new file mode 100644
index 0000000000..9fa66a27c5
--- /dev/null
+++ b/comm/mail/components/preferences/searchResults.inc.xhtml
@@ -0,0 +1,24 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+ <hbox id="header-searchResults"
+ class="subcategory"
+ hidden="true"
+ data-hidden-from-search="true"
+ data-category="paneSearchResults">
+ <html:h1 data-l10n-id="search-results-header"/>
+ </hbox>
+ <groupbox id="no-results-message"
+ data-hidden-from-search="true"
+ data-category="paneSearchResults"
+ hidden="true">
+ <vbox class="no-results-container">
+ <label id="sorry-message" data-l10n-id="search-results-empty-message2">
+ <html:span data-l10n-name="query" id="sorry-message-query"/>
+ </label>
+ <label id="need-help" data-l10n-id="search-results-help-link">
+ <html:a class="text-link" data-l10n-name="url" target="_blank"></html:a>
+ </label>
+ </vbox>
+ </groupbox>
diff --git a/comm/mail/components/preferences/sync.inc.xhtml b/comm/mail/components/preferences/sync.inc.xhtml
new file mode 100644
index 0000000000..a101d4359b
--- /dev/null
+++ b/comm/mail/components/preferences/sync.inc.xhtml
@@ -0,0 +1,239 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+<script src="chrome://messenger/content/preferences/sync.js"/>
+
+<html:template id="paneSync">
+ <html:div id="syncPaneCategory"
+ class="subcategory"
+ data-category="paneSync"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <h1 data-l10n-id="sync-pane-header"></h1>
+
+ <section id="noFxaAccount" hidden="hidden">
+ <div id="noFxaInfo">
+ <h2 id="noFxaCaption" data-l10n-id="sync-signedout-caption"></h2>
+ <p id="noFxaDescription"
+ data-l10n-id="sync-signedout-description">
+ </p>
+ <button id="noFxaSignIn"
+ class="primary"
+ type="button"
+ data-l10n-id="sync-signedout-account-signin-btn">
+ </button>
+ </div>
+ <img id="noFxaSyncIllustration"
+ src="chrome://messenger/skin/illustrations/sync-devices.svg"
+ alt=""/>
+ </section>
+
+ <section id="hasFxaAccount" hidden="hidden">
+ <!-- Account NOT Verified -->
+ <section id="fxaLoginUnverified"
+ class="sync-account-section"
+ hidden="hidden">
+ <div id="photoDisplay">
+ <img class="contact-photo" src="" alt="" />
+ </div>
+ <div id="fxaLoginUnverifiedInfo">
+ <span id="fxaAccountMailNotVerified"
+ data-l10n-id="sync-pane-email-not-verified"
+ data-l10n-args='{"userEmail": ""}'>
+ </span>
+ <div id="fxaUnverifiedButtonOptions">
+ <button id="fxaResendVerification"
+ class="place-self-end primary"
+ data-l10n-id="sync-pane-resend-verification"
+ type="button">
+ </button>
+ <button id="fxaUnverifiedRemoveAccount"
+ class="place-self-end"
+ data-l10n-id="sync-pane-remove-account"
+ type="button">
+ </button>
+ </div>
+ </div>
+ </section>
+
+ <!-- Server Rejected Credientials -->
+ <section id="fxaLoginRejected"
+ class="sync-account-section"
+ hidden="hidden">
+ <div id="photoDisplay">
+ <img class="contact-photo" src="" alt="" />
+ </div>
+ <div id="fxaLoginRejectedInfo">
+ <span id="fxaAccountLoginRejected"
+ data-l10n-id="sync-signedin-login-failure"
+ data-l10n-args='{"userEmail": ""}'>
+ </span>
+ <div id="fxaRejectedButtonOptions">
+ <button id="fxaRejectedSignIn"
+ class="place-self-end primary"
+ data-l10n-id="sync-pane-sign-in"
+ type="button">
+ </button>
+ <button id="fxaRejectedRemoveAccount"
+ class="place-self-end"
+ data-l10n-id="sync-pane-remove-account"
+ type="button">
+ </button>
+ </div>
+ </div>
+ </section>
+
+ <!-- Account Verified -->
+ <section id="fxaLoginVerified"
+ class="sync-account-section"
+ hidden="hidden">
+ <div id="photoInput">
+ <button type="button" id="photoButton"
+ class="plain-button"
+ data-l10n-id="sync-pane-edit-photo">
+ <img id="fxaAvatar" class="contact-photo" src="" alt="" />
+ <div id="photoOverlay"></div>
+ </button>
+ </div>
+ <div id="fxaAccountInfo">
+ <span id="fxaDisplayName"></span>
+ <span id="fxaEmailAddress"></span>
+ <a id="verifiedManage" href="#" data-l10n-id="sync-pane-manage-account"></a>
+ </div>
+ <button id="fxaAccountSignOut"
+ class="place-self-end"
+ data-l10n-id="sync-pane-sign-out"
+ type="button">
+ </button>
+ </section>
+
+ <fieldset id="fxaDeviceInfo" hidden="hidden">
+ <legend data-l10n-id="sync-pane-device-name-title"></legend>
+ <input id="fxaDeviceNameInput" type="text" readonly="readonly"/>
+ <!-- Hidden by default, shown if #fxaDeviceNameChangeDeviceName is pressed -->
+ <button id="fxaDeviceNameCancel"
+ class="place-self-end"
+ data-l10n-id="sync-pane-cancel"
+ type="button"
+ hidden="hidden">
+ </button>
+ <button id="fxaDeviceNameSave"
+ class="place-self-end"
+ data-l10n-id="sync-pane-save"
+ type="button"
+ hidden="hidden">
+ </button>
+ <!-- Disappear once pressed to allow the previous two buttons to take
+ - its place, reappears once cancel or save is pressed -->
+ <button id="fxaDeviceNameChangeDeviceName"
+ class="place-self-end needs-account-ready"
+ data-l10n-id="sync-pane-change-device-name"
+ type="button">
+ </button>
+ </fieldset>
+
+ <div id="syncConnected" class="sync-section" hidden="hidden">
+ <div id="showSyncedHeader">
+ <h2 class="sync-header"
+ data-l10n-id="sync-pane-show-synced-header-on">
+ </h2>
+ <button id="syncShowSyncedSyncNow"
+ class="place-self-end needs-account-ready"
+ data-l10n-id="sync-pane-sync-now"
+ type="button">
+ </button>
+ </div>
+
+ <div class="sync-panel">
+ <div id="showSyncedListHeader">
+ <h3 id="showSyncedListHeading" data-l10n-id="show-synced-list-heading"></h3>
+ <a id="enginesLearnMore" href="#" data-l10n-id="show-synced-learn-more"></a>
+ </div>
+
+ <ul id="showSyncedList" class="synced-list">
+ <!-- For when we get per-account controls: -->
+ <!-- <li id="showSyncAccount">
+ <span id="showSyncAccountLabel"
+ class="synced-item"
+ data-l10n-id="show-synced-item-account">
+ </span>
+ <div id="syncedAccounts">
+
+ <div class="synced-account">
+ <h4 class="synced-account-name">nemo@thunderbird.net</h4>
+ <ul class="synced-list">
+ <li class="synced-item synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </li>
+ <li class="synced-item synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </li>
+ <li class="synced-item synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </li>
+ </ul>
+ </div>
+
+ <div class="synced-account">
+ <h4 class="synced-account-name">example@example.com</h4>
+ <ul class="synced-list">
+ <li class="synced-item synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </li>
+ <li class="synced-item synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </li>
+ <li class="synced-item synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </li>
+ </ul>
+ </div>
+
+ </div>
+ </li> -->
+ <li id="showSyncAccount"
+ class="synced-item"
+ data-l10n-id="show-synced-item-account">
+ </li>
+ <li id="showSyncIdentity"
+ class="synced-item"
+ data-l10n-id="show-synced-item-identity">
+ </li>
+ <li id="showSyncAddress"
+ class="synced-item"
+ data-l10n-id="show-synced-item-address">
+ </li>
+ <li id="showSyncCalendar"
+ class="synced-item"
+ data-l10n-id="show-synced-item-calendar">
+ </li>
+ <li id="showSyncPasswords"
+ class="synced-item"
+ data-l10n-id="show-synced-item-passwords">
+ </li>
+ </ul>
+
+ <button id="syncChangeOptions"
+ class="place-self-end primary"
+ data-l10n-id="show-synced-change"
+ type="button">
+ </button>
+ </div>
+ </div>
+
+ <div id="syncDisconnected" class="sync-section" hidden="hidden">
+ <h2 class="sync-header"
+ data-l10n-id="sync-pane-show-synced-header-off">
+ </h2>
+
+ <div class="sync-panel">
+ <p data-l10n-id="sync-disconnected-text"></p>
+ <button id="syncSetup"
+ class="place-self-start needs-account-ready"
+ data-l10n-id="sync-disconnected-turn-on-sync"
+ type="button">
+ </button>
+ </div>
+ </div>
+ </section>
+ </html:div>
+</html:template>
diff --git a/comm/mail/components/preferences/sync.js b/comm/mail/components/preferences/sync.js
new file mode 100644
index 0000000000..d8336c6e23
--- /dev/null
+++ b/comm/mail/components/preferences/sync.js
@@ -0,0 +1,377 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from preferences.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ UIState: "resource://services-sync/UIState.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+var { FxAccounts, getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+var fxAccounts = getFxAccountsSingleton();
+
+var gSyncPane = {
+ init() {
+ this._setupEventListeners();
+ this.setupEnginesUI();
+
+ Weave.Svc.Obs.add(UIState.ON_UPDATE, this.updateWeavePrefs, this);
+
+ window.addEventListener("unload", () => {
+ Weave.Svc.Obs.remove(UIState.ON_UPDATE, this.updateWeavePrefs, this);
+ });
+
+ let cachedComputerName = Services.prefs.getStringPref(
+ "identity.fxaccounts.account.device.name",
+ ""
+ );
+ if (cachedComputerName) {
+ this._populateComputerName(cachedComputerName);
+ }
+
+ this.updateWeavePrefs();
+ },
+
+ /**
+ * Update the UI based on the current state.
+ */
+ updateWeavePrefs() {
+ let state = UIState.get();
+
+ let noFxaAccount = document.getElementById("noFxaAccount");
+ let hasFxaAccount = document.getElementById("hasFxaAccount");
+ if (state.status == UIState.STATUS_NOT_CONFIGURED) {
+ noFxaAccount.hidden = false;
+ hasFxaAccount.hidden = true;
+ return;
+ }
+ noFxaAccount.hidden = true;
+ hasFxaAccount.hidden = false;
+
+ let syncReady = false; // Is sync able to actually sync?
+ let fxaLoginUnverified = document.getElementById("fxaLoginUnverified");
+ let fxaLoginRejected = document.getElementById("fxaLoginRejected");
+ let fxaLoginVerified = document.getElementById("fxaLoginVerified");
+ if (state.status == UIState.STATUS_LOGIN_FAILED) {
+ fxaLoginUnverified.hidden = true;
+ fxaLoginRejected.hidden = false;
+ fxaLoginVerified.hidden = true;
+ } else if (state.status == UIState.STATUS_NOT_VERIFIED) {
+ fxaLoginUnverified.hidden = false;
+ fxaLoginRejected.hidden = true;
+ fxaLoginVerified.hidden = true;
+ } else {
+ fxaLoginUnverified.hidden = true;
+ fxaLoginRejected.hidden = true;
+ fxaLoginVerified.hidden = false;
+ syncReady = true;
+ }
+
+ this._populateComputerName(Weave.Service.clientsEngine.localName);
+ for (let elt of document.querySelectorAll(".needs-account-ready")) {
+ elt.disabled = !syncReady;
+ }
+
+ let syncConnected = document.getElementById("syncConnected");
+ let syncDisconnected = document.getElementById("syncDisconnected");
+ syncConnected.hidden = !syncReady || !state.syncEnabled;
+ syncDisconnected.hidden = !syncReady || state.syncEnabled;
+
+ document.l10n.setAttributes(
+ document.getElementById("fxaAccountMailNotVerified"),
+ "sync-pane-email-not-verified",
+ { userEmail: state.email }
+ );
+ document.l10n.setAttributes(
+ document.getElementById("fxaAccountLoginRejected"),
+ "sync-signedin-login-failure",
+ { userEmail: state.email }
+ );
+
+ document.getElementById("fxaAvatar").src =
+ state.avatarURL && !state.avatarIsDefault ? state.avatarURL : "";
+ document.getElementById("fxaDisplayName").textContent = state.displayName;
+ document.getElementById("fxaEmailAddress").textContent = state.email;
+
+ this._updateSyncNow(state.syncing);
+ },
+
+ _toggleComputerNameControls(editMode) {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ textbox.readOnly = !editMode;
+ document.getElementById("fxaDeviceNameChangeDeviceName").hidden = editMode;
+ document.getElementById("fxaDeviceNameCancel").hidden = !editMode;
+ document.getElementById("fxaDeviceNameSave").hidden = !editMode;
+ },
+
+ _focusComputerNameTextbox() {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ let valLength = textbox.value.length;
+ textbox.focus();
+ textbox.setSelectionRange(valLength, valLength);
+ },
+
+ _blurComputerNameTextbox() {
+ document.getElementById("fxaDeviceNameInput").blur();
+ },
+
+ _focusAfterComputerNameTextbox() {
+ // Focus the most appropriate element that's *not* the "computer name" box.
+ Services.focus.moveFocus(
+ window,
+ document.getElementById("fxaDeviceNameInput"),
+ Services.focus.MOVEFOCUS_FORWARD,
+ 0
+ );
+ },
+
+ _updateComputerNameValue(save) {
+ if (save) {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ Weave.Service.clientsEngine.localName = textbox.value;
+ }
+ this._populateComputerName(Weave.Service.clientsEngine.localName);
+ },
+
+ _setupEventListeners() {
+ function setEventListener(id, eventType, callback) {
+ document
+ .getElementById(id)
+ .addEventListener(eventType, callback.bind(gSyncPane));
+ }
+
+ setEventListener("noFxaSignIn", "click", function () {
+ window.browsingContext.topChromeWindow.gSync.initFxA();
+ return false;
+ });
+ setEventListener(
+ "fxaResendVerification",
+ "click",
+ gSyncPane.verifyFirefoxAccount
+ );
+ setEventListener("fxaUnverifiedRemoveAccount", "click", function () {
+ /* No warning as account can't have previously synced. */
+ gSyncPane.unlinkFirefoxAccount(false);
+ });
+ setEventListener("fxaRejectedSignIn", "click", gSyncPane.reSignIn);
+ setEventListener("fxaRejectedRemoveAccount", "click", function () {
+ gSyncPane.unlinkFirefoxAccount(true);
+ });
+ setEventListener("photoButton", "click", function (event) {
+ window.browsingContext.topChromeWindow.gSync.openFxAAvatarPage(
+ "preferences"
+ );
+ });
+ setEventListener("verifiedManage", "click", function (event) {
+ window.browsingContext.topChromeWindow.gSync.openFxAManagePage(
+ "preferences"
+ );
+ event.preventDefault();
+ // Stop attempts to open this link in an external browser.
+ event.stopPropagation();
+ });
+ setEventListener("fxaAccountSignOut", "click", function () {
+ gSyncPane.unlinkFirefoxAccount(true);
+ });
+ setEventListener("fxaDeviceNameCancel", "click", function () {
+ // We explicitly blur the textbox because of bug 75324, then after
+ // changing the state of the buttons, force focus to whatever the focus
+ // manager thinks should be next (which on the mac, depends on an OSX
+ // keyboard access preference)
+ this._blurComputerNameTextbox();
+ this._toggleComputerNameControls(false);
+ this._updateComputerNameValue(false);
+ this._focusAfterComputerNameTextbox();
+ });
+ setEventListener("fxaDeviceNameSave", "click", function () {
+ // Work around bug 75324 - see above.
+ this._blurComputerNameTextbox();
+ this._toggleComputerNameControls(false);
+ this._updateComputerNameValue(true);
+ this._focusAfterComputerNameTextbox();
+ });
+ setEventListener("fxaDeviceNameChangeDeviceName", "click", function () {
+ this._toggleComputerNameControls(true);
+ this._focusComputerNameTextbox();
+ });
+ setEventListener("syncShowSyncedSyncNow", "click", function () {
+ // syncing can take a little time to send the "started" notification, so
+ // pretend we already got it.
+ this._updateSyncNow(true);
+ Weave.Service.sync({ why: "aboutprefs" });
+ });
+ setEventListener("enginesLearnMore", "click", function (event) {
+ // TODO: A real page.
+ window.browsingContext.topChromeWindow.openContentTab(
+ "https://example.org/?page=learnMore"
+ );
+ event.preventDefault();
+ // Stop attempts to open this link in an external browser.
+ event.stopPropagation();
+ });
+ setEventListener("syncChangeOptions", "click", function () {
+ gSyncPane._chooseWhatToSync(true);
+ });
+ setEventListener("syncSetup", "click", function () {
+ gSyncPane._chooseWhatToSync(false);
+ });
+ },
+
+ async _chooseWhatToSync(isAlreadySyncing) {
+ // Assuming another device is syncing and we're not, we update the engines
+ // selection so the correct checkboxes are pre-filled.
+ if (!isAlreadySyncing) {
+ try {
+ await Weave.Service.updateLocalEnginesState();
+ } catch (err) {
+ console.error("Error updating the local engines state", err);
+ }
+ }
+ let params = {};
+ if (isAlreadySyncing) {
+ // If we are already syncing then we also offer to disconnect.
+ params.disconnectFun = () => this.disconnectSync();
+ }
+ gSubDialog.open(
+ "chrome://messenger/content/preferences/syncDialog.xhtml",
+ {
+ features: "resizable=no",
+ closingCallback: event => {
+ if (!isAlreadySyncing && event.detail.button == "accept") {
+ // We weren't syncing but the user has accepted the dialog - so we
+ // want to start!
+ fxAccounts.telemetry
+ .recordConnection(["sync"], "ui")
+ .then(() => {
+ return Weave.Service.configure();
+ })
+ .catch(err => {
+ console.error("Failed to enable sync", err);
+ });
+ }
+ },
+ },
+ params
+ );
+ },
+
+ _updateSyncNow(syncing) {
+ let button = document.getElementById("syncShowSyncedSyncNow");
+ if (syncing) {
+ document.l10n.setAttributes(button, "sync-panel-sync-now-syncing");
+ button.disabled = true;
+ } else {
+ document.l10n.setAttributes(button, "sync-pane-sync-now");
+ button.disabled = false;
+ }
+ },
+
+ /**
+ * If connecting to Firefox Accounts failed, try again.
+ */
+ async reSignIn() {
+ // There's a bit of an edge-case here - we might be forcing reauth when we've
+ // lost the FxA account data - in which case we'll not get a URL as the re-auth
+ // URL embeds account info and the server endpoint complains if we don't
+ // supply it - so we just use the regular "sign in" URL in that case.
+ if (!(await FxAccounts.canConnectAccount())) {
+ return;
+ }
+
+ const url =
+ (await FxAccounts.config.promiseForceSigninURI("preferences")) ||
+ (await FxAccounts.config.promiseConnectAccountURI("preferences"));
+ window.browsingContext.topChromeWindow.openContentTab(url);
+ },
+
+ /**
+ * Send a confirmation email to the account's email address.
+ */
+ verifyFirefoxAccount() {
+ let onError = async () => {
+ let [title, body] = await document.l10n.formatValues([
+ "fxa-verification-not-sent-title",
+ "fxa-verification-not-sent-body",
+ ]);
+ new Notification(title, { body });
+ };
+
+ let onSuccess = async data => {
+ if (data) {
+ let [title, body] = await document.l10n.formatValues([
+ "fxa-verification-sent-title",
+ { id: "fxa-verification-sent-body", args: { userEmail: data.email } },
+ ]);
+ new Notification(title, { body });
+ } else {
+ onError();
+ }
+ };
+
+ fxAccounts
+ .resendVerificationEmail()
+ .then(() => fxAccounts.getSignedInUser(), onError)
+ .then(onSuccess, onError);
+ },
+
+ /**
+ * Disconnect the account, including everything linked.
+ *
+ * @param {boolean} confirm - If true, asks the user if they're sure.
+ */
+ unlinkFirefoxAccount(confirm) {
+ window.browsingContext.topChromeWindow.gSync.disconnect({ confirm });
+ },
+
+ /**
+ * Disconnect sync, leaving the FxA account connected.
+ */
+ disconnectSync() {
+ return window.browsingContext.topChromeWindow.gSync.disconnect({
+ confirm: true,
+ disconnectAccount: false,
+ });
+ },
+
+ _populateComputerName(value) {
+ let textbox = document.getElementById("fxaDeviceNameInput");
+ if (!textbox.hasAttribute("placeholder")) {
+ textbox.setAttribute(
+ "placeholder",
+ fxAccounts.device.getDefaultLocalName()
+ );
+ }
+ textbox.value = value;
+ },
+
+ /**
+ * Arranges to dynamically show or hide sync engine name elements based on
+ * the preferences used for the engines.
+ */
+ setupEnginesUI() {
+ let observe = (element, prefName) => {
+ element.hidden = !Services.prefs.getBoolPref(prefName, false);
+ };
+
+ let engineItems = {
+ showSyncAccount: "services.sync.engine.accounts",
+ showSyncIdentity: "services.sync.engine.identities",
+ showSyncAddress: "services.sync.engine.addressbooks",
+ showSyncCalendar: "services.sync.engine.calendars",
+ showSyncPasswords: "services.sync.engine.passwords",
+ };
+
+ for (let [id, prefName] of Object.entries(engineItems)) {
+ let obs = observe.bind(null, document.getElementById(id), prefName);
+ obs();
+ Services.prefs.addObserver(prefName, obs);
+ window.addEventListener("unload", () => {
+ Services.prefs.removeObserver(prefName, obs);
+ });
+ }
+ },
+};
diff --git a/comm/mail/components/preferences/syncDialog.js b/comm/mail/components/preferences/syncDialog.js
new file mode 100644
index 0000000000..6920a8aa59
--- /dev/null
+++ b/comm/mail/components/preferences/syncDialog.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const engineItems = {
+ configSyncAccount: "services.sync.engine.accounts",
+ configSyncAddress: "services.sync.engine.addressbooks",
+ configSyncCalendar: "services.sync.engine.calendars",
+ configSyncIdentity: "services.sync.engine.identities",
+ configSyncPasswords: "services.sync.engine.passwords",
+};
+
+window.addEventListener("load", function () {
+ for (let [id, prefName] of Object.entries(engineItems)) {
+ let element = document.getElementById(id);
+ element.checked = Services.prefs.getBoolPref(prefName, false);
+ }
+
+ let options = window.arguments[0];
+ if (options.disconnectFun) {
+ window.addEventListener("dialogextra2", function () {
+ options.disconnectFun().then(disconnected => {
+ if (disconnected) {
+ window.close();
+ }
+ });
+ });
+ } else {
+ document.querySelector("dialog").getButton("extra2").hidden = true;
+ }
+});
+
+window.addEventListener("dialogaccept", function () {
+ for (let [id, prefName] of Object.entries(engineItems)) {
+ let element = document.getElementById(id);
+ Services.prefs.setBoolPref(prefName, element.checked);
+ }
+});
diff --git a/comm/mail/components/preferences/syncDialog.xhtml b/comm/mail/components/preferences/syncDialog.xhtml
new file mode 100644
index 0000000000..ce035245ea
--- /dev/null
+++ b/comm/mail/components/preferences/syncDialog.xhtml
@@ -0,0 +1,210 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+>
+ <head>
+ <title data-l10n-id="config-sync-dailog-title"></title>
+
+ <link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/preferences/preferences.css"
+ />
+
+ <link rel="localization" href="messenger/preferences/preferences.ftl" />
+ <link rel="localization" href="messenger/preferences/sync-dialog.ftl" />
+
+ <script src="chrome://messenger/content/preferences/syncDialog.js"></script>
+ </head>
+ <body>
+ <xul:dialog
+ id="configSyncDialog"
+ buttons="accept,cancel,extra2"
+ style="min-width: 49em"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept, buttonlabelextra2, buttonaccesskeyextra2"
+ data-l10n-id="sync-dialog"
+ >
+ <div id="configSyncDialogContent">
+ <ul id="configSyncList" class="config-list">
+ <li id="configAccountsContainer">
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncAccount"
+ name="configSynced"
+ />
+ <label
+ for="configSyncAccount"
+ id="configSyncAccountLabel"
+ data-l10n-id="show-synced-item-account"
+ >
+ </label>
+ </div>
+ <!-- For when we get per-account controls: -->
+ <!-- <div id="configAccounts">
+
+ <div class="synced-account">
+ <div class="config-item synced-account-name">
+ <input type="checkbox"
+ id="configSyncAccount_1"
+ name="configSyncedAccounts"/>
+ <label for="configSyncAccount_1">
+ nemo@thunderbird.net
+ </label>
+ </div>
+ <ul class="config-list">
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncServer_1"
+ name="configSyncedAccount_1"/>
+ <label for="configSyncServer_1"
+ class="synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncFilters_1"
+ name="configSyncedAccount_1"/>
+ <label for="configSyncFilters_1"
+ class="synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncKeys_1"
+ name="configSyncedAccount_1"/>
+ <label for="configSyncKeys_1"
+ class="synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ <div class="synced-account">
+ <div class="config-item synced-account-name">
+ <input type="checkbox"
+ id="configSyncAccount_2"
+ name="configSyncedAccounts"/>
+ <label for="configSyncAccount_2">
+ example@example.com
+ </label>
+ </div>
+ <ul class="config-list">
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncServer_2"
+ name="configSyncedAccount_2"/>
+ <label for="configSyncServer_2"
+ class="synced-account-server-config"
+ data-l10n-id="synced-acount-item-server-config">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncFilters_2"
+ name="configSyncedAccount_2"/>
+ <label for="configSyncFilters_2"
+ class="synced-account-filters"
+ data-l10n-id="synced-acount-item-filters">
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input type="checkbox"
+ id="configSyncKeys_2"
+ name="configSyncedAccount_2"/>
+ <label for="configSyncKeys_2"
+ class="synced-account-keys"
+ data-l10n-id="synced-acount-item-keys">
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ </div> -->
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncIdentity"
+ name="configSynced"
+ />
+ <label
+ id="configSyncIdentityLabel"
+ for="configSyncIdentity"
+ data-l10n-id="show-synced-item-identity"
+ >
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncAddress"
+ name="configSynced"
+ />
+ <label
+ id="configSyncAddressLabel"
+ for="configSyncAddress"
+ data-l10n-id="show-synced-item-address"
+ >
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncCalendar"
+ name="configSynced"
+ />
+ <label
+ id="configSyncCalendarLabel"
+ for="configSyncCalendar"
+ data-l10n-id="show-synced-item-calendar"
+ >
+ </label>
+ </div>
+ </li>
+ <li>
+ <div class="config-item">
+ <input
+ type="checkbox"
+ id="configSyncPasswords"
+ name="configSynced"
+ />
+ <label
+ id="configSyncPasswordsLabel"
+ for="configSyncPasswords"
+ data-l10n-id="show-synced-item-passwords"
+ >
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/mail/components/preferences/tagDialog.xhtml b/comm/mail/components/preferences/tagDialog.xhtml
new file mode 100644
index 0000000000..32dd72268d
--- /dev/null
+++ b/comm/mail/components/preferences/tagDialog.xhtml
@@ -0,0 +1,26 @@
+<?xml version="1.0"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="tag-dialog-window"
+ style="min-width: 25em;"
+ onload="onLoad();">
+<dialog>
+
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/newTagDialog.js"/>
+#include ../../base/content/tagDialog.inc.xhtml
+</dialog>
+</window>
diff --git a/comm/mail/components/preferences/test/browser/browser.ini b/comm/mail/components/preferences/test/browser/browser.ini
new file mode 100644
index 0000000000..44b7d97a31
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser.ini
@@ -0,0 +1,20 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_chat.js]
+[browser_cloudfile.js]
+support-files = files/icon.svg files/management.html
+[browser_compose.js]
+[browser_general.js]
+[browser_openPreferences.js]
+[browser_privacy.js]
+[browser_sync.js]
+skip-if = !nightly_build
+support-files = files/avatar.png
diff --git a/comm/mail/components/preferences/test/browser/browser_chat.js b/comm/mail/components/preferences/test/browser/browser_chat.js
new file mode 100644
index 0000000000..009f2a9211
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_chat.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneChat",
+ "chatPaneCategory",
+ {
+ checkboxID: "reportIdle",
+ pref: "messenger.status.reportIdle",
+ enabledElements: ["#autoAway", "#timeBeforeAway"],
+ },
+ {
+ checkboxID: "sendTyping",
+ pref: "purple.conversations.im.send_typing",
+ },
+ {
+ checkboxID: "desktopChatNotifications",
+ pref: "mail.chat.show_desktop_notifications",
+ },
+ {
+ checkboxID: "getAttention",
+ pref: "messenger.options.getAttentionOnNewMessages",
+ },
+ {
+ checkboxID: "chatNotification",
+ pref: "mail.chat.play_sound",
+ enabledElements: ["#chatSoundType radio"],
+ }
+ );
+
+ Services.prefs.setBoolPref("messenger.status.reportIdle", true);
+ await testCheckboxes("paneChat", "chatPaneCategory", {
+ checkboxID: "autoAway",
+ pref: "messenger.status.awayWhenIdle",
+ enabledElements: ["#defaultIdleAwayMessage"],
+ });
+
+ Services.prefs.setBoolPref("mail.chat.play_sound", true);
+ await testRadioButtons("paneChat", "chatPaneCategory", {
+ pref: "mail.chat.play_sound.type",
+ states: [
+ {
+ id: "chatSoundSystemSound",
+ prefValue: 0,
+ },
+ {
+ id: "chatSoundCustom",
+ prefValue: 1,
+ enabledElements: ["#chatSoundUrlLocation", "#browseForChatSound"],
+ },
+ ],
+ });
+});
+
+add_task(async function testMessageStylePreview() {
+ await openNewPrefsTab("paneChat", "chatPaneCategory");
+ const conversationLoad = TestUtils.topicObserved("conversation-loaded");
+ const [subject] = await conversationLoad;
+ do {
+ await BrowserTestUtils.waitForEvent(subject, "MessagesDisplayed");
+ } while (subject.getPendingMessagesCount() > 0);
+ const messageParent = subject.contentChatNode;
+ let message = messageParent.firstElementChild;
+ const messages = new Set();
+ while (message) {
+ ok(message._originalMsg);
+ messages.add(message._originalMsg);
+ message = message.nextElementSibling;
+ }
+ is(messages.size, 3, "All 3 messages displayed");
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_cloudfile.js b/comm/mail/components/preferences/test/browser/browser_cloudfile.js
new file mode 100644
index 0000000000..9f395f9ab8
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_cloudfile.js
@@ -0,0 +1,796 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env webextensions */
+
+let { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+function ManagementScript() {
+ browser.test.onMessage.addListener((message, assertMessage, browserStyle) => {
+ if (message !== "check-style") {
+ return;
+ }
+ function verifyButton(buttonElement, expected) {
+ let buttonStyle = window.getComputedStyle(buttonElement);
+ let buttonBackgroundColor = buttonStyle.backgroundColor;
+ if (browserStyle && expected.hasBrowserStyleClass) {
+ browser.test.assertEq(
+ "rgb(9, 150, 248)",
+ buttonBackgroundColor,
+ assertMessage
+ );
+ } else {
+ browser.test.assertTrue(
+ buttonBackgroundColor !== "rgb(9, 150, 248)",
+ assertMessage
+ );
+ }
+ }
+
+ function verifyCheckboxOrRadio(element, expected) {
+ let style = window.getComputedStyle(element);
+ let styledBackground = element.checked
+ ? "rgb(9, 150, 248)"
+ : "rgb(255, 255, 255)";
+ if (browserStyle && expected.hasBrowserStyleClass) {
+ browser.test.assertEq(
+ styledBackground,
+ style.backgroundColor,
+ assertMessage
+ );
+ } else {
+ browser.test.assertTrue(
+ style.backgroundColor != styledBackground,
+ assertMessage
+ );
+ }
+ }
+
+ let normalButton = document.getElementById("normalButton");
+ let browserStyleButton = document.getElementById("browserStyleButton");
+ verifyButton(normalButton, { hasBrowserStyleClass: false });
+ verifyButton(browserStyleButton, { hasBrowserStyleClass: true });
+
+ let normalCheckbox1 = document.getElementById("normalCheckbox1");
+ let normalCheckbox2 = document.getElementById("normalCheckbox2");
+ let browserStyleCheckbox = document.getElementById("browserStyleCheckbox");
+ verifyCheckboxOrRadio(normalCheckbox1, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(normalCheckbox2, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(browserStyleCheckbox, {
+ hasBrowserStyleClass: true,
+ });
+
+ let normalRadio1 = document.getElementById("normalRadio1");
+ let normalRadio2 = document.getElementById("normalRadio2");
+ let browserStyleRadio = document.getElementById("browserStyleRadio");
+ verifyCheckboxOrRadio(normalRadio1, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(normalRadio2, { hasBrowserStyleClass: false });
+ verifyCheckboxOrRadio(browserStyleRadio, { hasBrowserStyleClass: true });
+
+ browser.test.notifyPass("management-ui-browser_style");
+ });
+ browser.test.sendMessage("management-ui-ready");
+}
+
+let extension;
+async function startExtension(browser_style) {
+ let cloud_file = {
+ name: "Mochitest",
+ management_url: "management.html",
+ };
+
+ switch (browser_style) {
+ case "true":
+ cloud_file.browser_style = true;
+ break;
+ case "false":
+ cloud_file.browser_style = false;
+ break;
+ }
+
+ extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ browser.test.onMessage.addListener(async message => {
+ if (message != "set-configured") {
+ return;
+ }
+ let accounts = await browser.cloudFile.getAllAccounts();
+ for (let account of accounts) {
+ await browser.cloudFile.updateAccount(account.id, {
+ configured: true,
+ });
+ }
+ browser.test.sendMessage("ready");
+ });
+ },
+ files: {
+ "management.html": `<html>
+ <body>
+ <a id="a" href="https://www.example.com/">Click me!</a>
+ <button id="normalButton" name="button" class="default">Default</button>
+ <button id="browserStyleButton" name="button" class="browser-style default">Default</button>
+
+ <input id="normalCheckbox1" type="checkbox"/>
+ <input id="normalCheckbox2" type="checkbox"/><label>Checkbox</label>
+ <div class="browser-style">
+ <input id="browserStyleCheckbox" type="checkbox"><label for="browserStyleCheckbox">Checkbox</label>
+ </div>
+
+ <input id="normalRadio1" type="radio"/>
+ <input id="normalRadio2" type="radio"/><label>Radio</label>
+ <div class="browser-style">
+ <input id="browserStyleRadio" checked="" type="radio"><label for="browserStyleRadio">Radio</label>
+ </div>
+ </body>
+ <script src="management.js" type="text/javascript"></script>
+ </html>`,
+ "management.js": ManagementScript,
+ },
+ manifest: {
+ cloud_file,
+ applications: { gecko: { id: "cloudfile@mochitest" } },
+ },
+ });
+
+ info("Starting extension");
+ await extension.startup();
+
+ if (accountIsConfigured) {
+ extension.sendMessage("set-configured");
+ await extension.awaitMessage("ready");
+ }
+}
+
+add_task(async () => {
+ // Register a fake provider representing a built-in provider. We don't
+ // currently ship any built-in providers, but if we did, we should check
+ // if they are present before doing this. Built-in providers can be
+ // problematic for artifact builds.
+ cloudFileAccounts.registerProvider("Fake-Test", {
+ displayName: "XYZ Fake",
+ type: "ext-fake@extensions.thunderbird.net",
+ });
+ registerCleanupFunction(() => {
+ cloudFileAccounts.unregisterProvider("Fake-Test");
+ });
+});
+
+let accountIsConfigured = false;
+
+// Mock the prompt service. We're going to be asked if we're sure
+// we want to remove an account, so let's say yes.
+
+/** @implements {nsIPromptService} */
+let mockPromptService = {
+ confirmCount: 0,
+ confirm() {
+ this.confirmCount++;
+ return true;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+};
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(aProtocolScheme) {},
+ getApplicationDescription(aScheme) {},
+ getProtocolHandlerInfo(aProtocolScheme) {},
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+ isExposedProtocol(aProtocolScheme) {},
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let originalPromptService = Services.prompt;
+Services.prompt = mockPromptService;
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ Services.prompt = originalPromptService;
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+add_task(async function addRemoveAccounts() {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Load the preferences tab.
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ // Check everything is as it should be.
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 0);
+
+ let buttonList = prefsDocument.getElementById("addCloudFileAccountButtons");
+ ok(!buttonList.hidden);
+ is(buttonList.childElementCount, 1);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+
+ let menuButton = prefsDocument.getElementById("addCloudFileAccount");
+ ok(menuButton.hidden);
+ is(menuButton.itemCount, 1);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+
+ let removeButton = prefsDocument.getElementById("removeCloudFileAccount");
+ ok(removeButton.disabled);
+
+ let cloudFileDefaultPanel = prefsDocument.getElementById(
+ "cloudFileDefaultPanel"
+ );
+ ok(!cloudFileDefaultPanel.hidden);
+
+ let browserWrapper = prefsDocument.getElementById("cloudFileSettingsWrapper");
+ is(browserWrapper.childElementCount, 0);
+
+ // Register our test provider.
+
+ await startExtension();
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 2);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(buttonList.children[1].getAttribute("value"), "ext-cloudfile@mochitest");
+ is(
+ buttonList.children[1].style.listStyleImage,
+ `url("chrome://messenger/content/extension.svg")`
+ );
+
+ is(menuButton.itemCount, 2);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(
+ menuButton.getItemAtIndex(1).getAttribute("value"),
+ "ext-cloudfile@mochitest"
+ );
+ is(
+ menuButton.getItemAtIndex(1).getAttribute("image"),
+ "chrome://messenger/content/extension.svg"
+ );
+
+ // Create a new account.
+
+ EventUtils.synthesizeMouseAtCenter(
+ buttonList.children[1],
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 0);
+
+ let account = cloudFileAccounts.accounts[0];
+ let accountKey = account.accountKey;
+ is(cloudFileAccounts.accounts[0].type, "ext-cloudfile@mochitest");
+
+ // Check prefs were updated.
+
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest"
+ );
+ is(
+ Services.prefs.getCharPref(`mail.cloud_files.accounts.${accountKey}.type`),
+ "ext-cloudfile@mochitest"
+ );
+
+ // Check UI was updated.
+
+ is(accountList.itemCount, 1);
+ is(accountList.selectedIndex, 0);
+ ok(!removeButton.disabled);
+
+ let accountListItem = accountList.selectedItem;
+ is(accountListItem.getAttribute("value"), accountKey);
+ is(
+ accountListItem.querySelector(".typeIcon:not(.configuredWarning)").src,
+ "chrome://messenger/content/extension.svg"
+ );
+ is(accountListItem.querySelector("label").value, "Mochitest");
+ is(accountListItem.querySelector(".configuredWarning").hidden, false);
+
+ ok(cloudFileDefaultPanel.hidden);
+ is(browserWrapper.childElementCount, 1);
+
+ let browser = browserWrapper.firstElementChild;
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(browser);
+ }
+ is(
+ browser.currentURI.pathQueryRef,
+ `/management.html?accountId=${accountKey}`
+ );
+ await extension.awaitMessage("management-ui-ready");
+
+ let tabmail = document.getElementById("tabmail");
+ let tabCount = tabmail.tabInfo.length;
+ BrowserTestUtils.synthesizeMouseAtCenter("a", {}, browser);
+ // It might take a moment to get to the external protocol service.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ ok(
+ mockExternalProtocolService.urlLoaded("https://www.example.com/"),
+ "Link click sent to external protocol service."
+ );
+ is(tabmail.tabInfo.length, tabCount, "No new tab opened");
+
+ // Rename the account.
+
+ EventUtils.synthesizeMouseAtCenter(
+ accountListItem,
+ { clickCount: 1 },
+ prefsWindow
+ );
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(
+ prefsDocument.activeElement.closest("input"),
+ accountListItem.querySelector("input")
+ );
+ ok(accountListItem.querySelector("label").hidden);
+ ok(!accountListItem.querySelector("input").hidden);
+ is(accountListItem.querySelector("input").value, "Mochitest");
+ EventUtils.synthesizeKey("VK_RIGHT", undefined, prefsWindow);
+ EventUtils.synthesizeKey("!", undefined, prefsWindow);
+ EventUtils.synthesizeKey("VK_RETURN", undefined, prefsWindow);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(prefsDocument.activeElement, accountList);
+ ok(!accountListItem.querySelector("label").hidden);
+ is(accountListItem.querySelector("label").value, "Mochitest!");
+ ok(accountListItem.querySelector("input").hidden);
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest!"
+ );
+
+ // Start to rename the account, but bail out.
+
+ EventUtils.synthesizeMouseAtCenter(
+ accountListItem,
+ { clickCount: 1 },
+ prefsWindow
+ );
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(
+ prefsDocument.activeElement.closest("input"),
+ accountListItem.querySelector("input")
+ );
+ EventUtils.synthesizeKey("O", undefined, prefsWindow);
+ EventUtils.synthesizeKey("o", undefined, prefsWindow);
+ EventUtils.synthesizeKey("p", undefined, prefsWindow);
+ EventUtils.synthesizeKey("s", undefined, prefsWindow);
+ EventUtils.synthesizeKey("VK_ESCAPE", undefined, prefsWindow);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(prefsDocument.activeElement, accountList);
+ ok(!accountListItem.querySelector("label").hidden);
+ is(accountListItem.querySelector("label").value, "Mochitest!");
+ ok(accountListItem.querySelector("input").hidden);
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest!"
+ );
+
+ // Configure the account.
+
+ account.configured = true;
+ accountIsConfigured = true;
+ cloudFileAccounts.emit("accountConfigured", account);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(accountListItem.querySelector(".configuredWarning").hidden, true);
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 1);
+
+ // Remove the test provider. The list item, button, and browser should disappear.
+
+ info("Stopping extension");
+ await extension.unload();
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 1);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(menuButton.itemCount, 1);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(accountList.itemCount, 0);
+ ok(!cloudFileDefaultPanel.hidden);
+ is(browserWrapper.childElementCount, 0);
+
+ // Re-add the test provider.
+
+ await startExtension();
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 1);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 2);
+ is(
+ buttonList.children[0].getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(buttonList.children[1].getAttribute("value"), "ext-cloudfile@mochitest");
+
+ is(menuButton.itemCount, 2);
+ is(
+ menuButton.getItemAtIndex(0).getAttribute("value"),
+ "ext-fake@extensions.thunderbird.net"
+ );
+ is(
+ menuButton.getItemAtIndex(1).getAttribute("value"),
+ "ext-cloudfile@mochitest"
+ );
+
+ is(accountList.itemCount, 1);
+ is(accountList.selectedIndex, -1);
+ ok(removeButton.disabled);
+
+ accountListItem = accountList.getItemAtIndex(0);
+ is(
+ Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ ),
+ "Mochitest!"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ accountList.getItemAtIndex(0),
+ { clickCount: 1 },
+ prefsWindow
+ );
+ ok(!removeButton.disabled);
+ EventUtils.synthesizeMouseAtCenter(
+ removeButton,
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(mockPromptService.confirmCount, 1);
+
+ ok(
+ !Services.prefs.prefHasUserValue(
+ `mail.cloud_files.accounts.${accountKey}.displayName`
+ )
+ );
+ ok(
+ !Services.prefs.prefHasUserValue(
+ `mail.cloud_files.accounts.${accountKey}.type`
+ )
+ );
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ info("Stopping extension");
+ await extension.unload();
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Close the preferences tab.
+
+ await closePrefsTab();
+});
+
+async function subtestBrowserStyle(assertMessage, expected) {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Load the preferences tab.
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ // Minimal check everything is as it should be.
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 0);
+
+ let buttonList = prefsDocument.getElementById("addCloudFileAccountButtons");
+ ok(!buttonList.hidden);
+
+ let browserWrapper = prefsDocument.getElementById("cloudFileSettingsWrapper");
+ is(browserWrapper.childElementCount, 0);
+
+ // Register our test provider.
+
+ await startExtension(expected.browser_style);
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ await new Promise(resolve => prefsWindow.requestAnimationFrame(resolve));
+
+ is(buttonList.childElementCount, 2);
+ is(buttonList.children[1].getAttribute("value"), "ext-cloudfile@mochitest");
+
+ // Create a new account.
+
+ EventUtils.synthesizeMouseAtCenter(
+ buttonList.children[1],
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(cloudFileAccounts.accounts.length, 1);
+ is(cloudFileAccounts.configuredAccounts.length, 0);
+
+ let account = cloudFileAccounts.accounts[0];
+ let accountKey = account.accountKey;
+ is(cloudFileAccounts.accounts[0].type, "ext-cloudfile@mochitest");
+
+ // Minimal check UI was updated.
+
+ is(accountList.itemCount, 1);
+ is(accountList.selectedIndex, 0);
+
+ let accountListItem = accountList.selectedItem;
+ is(accountListItem.getAttribute("value"), accountKey);
+
+ is(browserWrapper.childElementCount, 1);
+ let browser = browserWrapper.firstElementChild;
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(browser);
+ }
+ is(
+ browser.currentURI.pathQueryRef,
+ `/management.html?accountId=${accountKey}`
+ );
+ await extension.awaitMessage("management-ui-ready");
+
+ // Test browser_style
+
+ extension.sendMessage(
+ "check-style",
+ assertMessage,
+ expected.browser_style == "true"
+ );
+ await extension.awaitFinish("management-ui-browser_style");
+
+ // Remove the account
+
+ accountListItem = accountList.getItemAtIndex(0);
+ EventUtils.synthesizeMouseAtCenter(
+ accountList.getItemAtIndex(0),
+ { clickCount: 1 },
+ prefsWindow
+ );
+
+ let removeButton = prefsDocument.getElementById("removeCloudFileAccount");
+ ok(!removeButton.disabled);
+ EventUtils.synthesizeMouseAtCenter(
+ removeButton,
+ { clickCount: 1 },
+ prefsWindow
+ );
+ is(mockPromptService.confirmCount, expected.confirmCount);
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ info("Stopping extension");
+ await extension.unload();
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Close the preferences tab.
+
+ await closePrefsTab();
+}
+
+add_task(async function test_without_setting_browser_style() {
+ await subtestBrowserStyle(
+ "Expected correct style when browser_style is excluded",
+ {
+ confirmCount: 2,
+ browser_style: "default",
+ }
+ );
+});
+
+add_task(async function test_with_browser_style_set_to_true() {
+ await subtestBrowserStyle(
+ "Expected correct style when browser_style is set to `true`",
+ {
+ confirmCount: 3,
+ browser_style: "true",
+ }
+ );
+});
+
+add_task(async function test_with_browser_style_set_to_false() {
+ await subtestBrowserStyle(
+ "Expected no style when browser_style is set to `false`",
+ {
+ confirmCount: 4,
+ browser_style: "false",
+ }
+ );
+});
+
+add_task(async function accountListOverflow() {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Register our test provider.
+
+ await startExtension();
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ // Load the preferences tab.
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 0);
+
+ let buttonList = prefsDocument.getElementById("addCloudFileAccountButtons");
+ ok(!buttonList.hidden);
+ is(buttonList.childElementCount, 2);
+ is(buttonList.children[0].getAttribute("value"), "ext-cloudfile@mochitest");
+
+ let menuButton = prefsDocument.getElementById("addCloudFileAccount");
+ ok(menuButton.hidden);
+
+ // Add new accounts until the list overflows. The list of buttons should be hidden
+ // and the button with the drop-down should appear.
+
+ let count = 0;
+ do {
+ let readyPromise = extension.awaitMessage("management-ui-ready");
+ EventUtils.synthesizeMouseAtCenter(
+ buttonList.children[0],
+ { clickCount: 1 },
+ prefsWindow
+ );
+ await readyPromise;
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+ if (buttonList.hidden) {
+ break;
+ }
+ } while (++count < 25);
+
+ ok(count < 24); // If count reaches 25, we have a problem.
+ ok(!menuButton.hidden);
+
+ // Remove the added accounts. The list of buttons should not reappear and the
+ // button with the drop-down should remain.
+
+ let removeButton = prefsDocument.getElementById("removeCloudFileAccount");
+ do {
+ EventUtils.synthesizeMouseAtCenter(
+ accountList.getItemAtIndex(0),
+ { clickCount: 1 },
+ prefsWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ removeButton,
+ { clickCount: 1 },
+ prefsWindow
+ );
+ await new Promise(resolve => setTimeout(resolve));
+ } while (--count > 0);
+
+ ok(buttonList.hidden);
+ ok(!menuButton.hidden);
+
+ // Close the preferences tab.
+
+ await closePrefsTab();
+ info("Stopping extension");
+ await extension.unload();
+ Services.prefs.deleteBranch("mail.cloud_files.accounts");
+});
+
+add_task(async function accountListOrder() {
+ is(cloudFileAccounts.providers.length, 1);
+ is(cloudFileAccounts.accounts.length, 0);
+
+ for (let [key, displayName] of [
+ ["someKey1", "carl's Account"],
+ ["someKey2", "Amber's Account"],
+ ["someKey3", "alice's Account"],
+ ["someKey4", "Bob's Account"],
+ ]) {
+ Services.prefs.setCharPref(
+ `mail.cloud_files.accounts.${key}.type`,
+ "ext-cloudfile@mochitest"
+ );
+ Services.prefs.setCharPref(
+ `mail.cloud_files.accounts.${key}.displayName`,
+ displayName
+ );
+ }
+
+ // Register our test provider.
+
+ await startExtension();
+
+ is(cloudFileAccounts.providers.length, 2);
+ is(cloudFileAccounts.accounts.length, 4);
+
+ let { prefsDocument } = await openNewPrefsTab(
+ "paneCompose",
+ "compositionAttachmentsCategory"
+ );
+
+ let accountList = prefsDocument.getElementById("cloudFileView");
+ is(accountList.itemCount, 4);
+
+ is(accountList.getItemAtIndex(0).value, "someKey3");
+ is(accountList.getItemAtIndex(1).value, "someKey2");
+ is(accountList.getItemAtIndex(2).value, "someKey4");
+ is(accountList.getItemAtIndex(3).value, "someKey1");
+
+ await closePrefsTab();
+ info("Stopping extension");
+ await extension.unload();
+ Services.prefs.deleteBranch("mail.cloud_files.accounts");
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_compose.js b/comm/mail/components/preferences/test/browser/browser_compose.js
new file mode 100644
index 0000000000..ea253cb555
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_compose.js
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneCompose",
+ "compositionMainCategory",
+ {
+ checkboxID: "addExtension",
+ pref: "mail.forward_add_extension",
+ },
+ {
+ checkboxID: "autoSave",
+ pref: "mail.compose.autosave",
+ enabledElements: ["#autoSaveInterval"],
+ },
+ {
+ checkboxID: "mailWarnOnSendAccelKey",
+ pref: "mail.warn_on_send_accel_key",
+ },
+ {
+ checkboxID: "spellCheckBeforeSend",
+ pref: "mail.SpellCheckBeforeSend",
+ },
+ {
+ checkboxID: "inlineSpellCheck",
+ pref: "mail.spellcheck.inline",
+ }
+ );
+
+ await testCheckboxes(
+ "paneCompose",
+ "FontSelect",
+ {
+ checkboxID: "useReaderDefaults",
+ pref: "msgcompose.default_colors",
+ enabledInverted: true,
+ enabledElements: [
+ "#textColorLabel",
+ "#textColorButton",
+ "#backgroundColorLabel",
+ "#backgroundColorButton",
+ ],
+ },
+ {
+ checkboxID: "defaultToParagraph",
+ pref: "mail.compose.default_to_paragraph",
+ }
+ );
+
+ await testCheckboxes(
+ "paneCompose",
+ "compositionAddressingCategory",
+ {
+ checkboxID: "addressingAutocomplete",
+ pref: "mail.enable_autocomplete",
+ },
+ {
+ checkboxID: "autocompleteLDAP",
+ pref: "ldap_2.autoComplete.useDirectory",
+ enabledElements: ["#directoriesList", "#editButton"],
+ },
+ {
+ checkboxID: "emailCollectionOutgoing",
+ pref: "mail.collect_email_address_outgoing",
+ enabledElements: ["#localDirectoriesList"],
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneCompose",
+ "compositionAttachmentsCategory",
+ {
+ checkboxID: "attachment_reminder_label",
+ pref: "mail.compose.attachment_reminder",
+ enabledElements: ["#attachment_reminder_button"],
+ },
+ {
+ checkboxID: "enableThreshold",
+ pref: "mail.compose.big_attachments.notify",
+ enabledElements: ["#cloudFileThreshold"],
+ }
+ );
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_general.js b/comm/mail/components/preferences/test/browser/browser_general.js
new file mode 100644
index 0000000000..f011be1e38
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_general.js
@@ -0,0 +1,380 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_task(async () => {
+ requestLongerTimeout(2);
+
+ // Temporarily disable `Once` StaticPrefs check for this test so that we
+ // can change layers.acceleration.disabled without debug builds failing.
+ await SpecialPowers.pushPrefEnv({
+ set: [["preferences.force-disable.check.once.policy", true]],
+ });
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneGeneral",
+ "generalCategory",
+ {
+ checkboxID: "mailnewsStartPageEnabled",
+ pref: "mailnews.start_page.enabled",
+ enabledElements: [
+ "#mailnewsStartPageUrl",
+ "#mailnewsStartPageUrl + button",
+ ],
+ },
+ {
+ checkboxID: "alwaysCheckDefault",
+ pref: "mail.shell.checkDefaultClient",
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneGeneral",
+ "scrollingGroup",
+ {
+ checkboxID: "useAutoScroll",
+ pref: "general.autoScroll",
+ },
+ {
+ checkboxID: "useSmoothScrolling",
+ pref: "general.smoothScroll",
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "paneGeneral",
+ "enableGloda",
+ {
+ checkboxID: "enableGloda",
+ pref: "mailnews.database.global.indexer.enabled",
+ },
+ {
+ checkboxID: "allowHWAccel",
+ pref: "layers.acceleration.disabled",
+ prefValues: [true, false],
+ }
+ );
+});
+
+add_task(async () => {
+ if (AppConstants.platform != "macosx") {
+ await testCheckboxes(
+ "paneGeneral",
+ "incomingMailCategory",
+ {
+ checkboxID: "newMailNotification",
+ pref: "mail.biff.play_sound",
+ enabledElements: ["#soundType radio"],
+ },
+ {
+ checkboxID: "newMailNotificationAlert",
+ pref: "mail.biff.show_alert",
+ enabledElements: ["#customizeMailAlert"],
+ }
+ );
+ }
+});
+
+add_task(async () => {
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ Services.prefs.setBoolPref("mail.biff.play_sound", true);
+
+ await testRadioButtons("paneGeneral", "incomingMailCategory", {
+ pref: "mail.biff.play_sound.type",
+ states: [
+ {
+ id: "system",
+ prefValue: 0,
+ },
+ {
+ id: "custom",
+ prefValue: 1,
+ enabledElements: ["#soundUrlLocation", "#browseForSound"],
+ },
+ ],
+ });
+});
+
+add_task(async () => {
+ await testCheckboxes("paneGeneral", "fontsGroup", {
+ checkboxID: "displayGlyph",
+ pref: "mail.display_glyph",
+ });
+
+ await testCheckboxes(
+ "paneGeneral",
+ "readingAndDisplayCategory",
+ {
+ checkboxID: "automaticallyMarkAsRead",
+ pref: "mailnews.mark_message_read.auto",
+ enabledElements: ["#markAsReadAutoPreferences radio"],
+ },
+ {
+ checkboxID: "closeMsgOnMoveOrDelete",
+ pref: "mail.close_message_window.on_delete",
+ },
+ {
+ checkboxID: "showCondensedAddresses",
+ pref: "mail.showCondensedAddresses",
+ }
+ );
+});
+
+add_task(async () => {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true);
+
+ await testRadioButtons(
+ "paneGeneral",
+ "mark_read_immediately",
+ {
+ pref: "mailnews.mark_message_read.delay",
+ states: [
+ {
+ id: "mark_read_immediately",
+ prefValue: false,
+ },
+ {
+ id: "markAsReadAfterDelay",
+ prefValue: true,
+ enabledElements: ["#markAsReadDelay"],
+ },
+ ],
+ },
+ {
+ pref: "mail.openMessageBehavior",
+ states: [
+ {
+ id: "newTab",
+ prefValue: 2,
+ },
+ {
+ id: "newWindow",
+ prefValue: 0,
+ },
+ {
+ id: "existingWindow",
+ prefValue: 1,
+ },
+ ],
+ }
+ );
+});
+
+add_task(async () => {
+ // We don't want to wake up the platform search for this test.
+ // if (AppConstants.platform == "macosx") {
+ // tests.push({
+ // checkboxID: "searchIntegration",
+ // pref: "mail.spotlight.enable",
+ // });
+ // } else if (AppConstants.platform == "win") {
+ // tests.push({
+ // checkboxID: "searchIntegration",
+ // pref: "mail.winsearch.enable",
+ // });
+ // }
+
+ await testCheckboxes(
+ "paneGeneral",
+ "allowSmartSize",
+ {
+ checkboxID: "allowSmartSize",
+ pref: "browser.cache.disk.smart_size.enabled",
+ prefValues: [true, false],
+ enabledElements: ["#cacheSize"],
+ },
+ {
+ checkboxID: "offlineCompactFolder",
+ pref: "mail.prompt_purge_threshhold",
+ enabledElements: [
+ "#offlineCompactFolderMin",
+ "#offlineCompactFolderAutomatically",
+ ],
+ }
+ );
+});
+
+add_task(async () => {
+ await testRadioButtons("paneGeneral", "formatLocale", {
+ pref: "intl.regional_prefs.use_os_locales",
+ states: [
+ {
+ id: "appLocale",
+ prefValue: false,
+ },
+ {
+ id: "rsLocale",
+ prefValue: true,
+ },
+ ],
+ });
+});
+
+add_task(async () => {
+ await testRadioButtons("paneGeneral", "filesAttachmentCategory", {
+ pref: "browser.download.useDownloadDir",
+ states: [
+ {
+ id: "saveTo",
+ prefValue: true,
+ enabledElements: ["#downloadFolder", "#chooseFolder"],
+ },
+ {
+ id: "alwaysAsk",
+ prefValue: false,
+ },
+ ],
+ });
+});
+
+add_task(async function testTagDialog() {
+ const { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ "paneGeneral",
+ "tagsCategory"
+ );
+
+ let newTagDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+
+ EventUtils.sendString("tbird", dialogWindow);
+ // "#000080" == rgb(0, 0, 128);
+ dialogDocument.getElementById("tagColorPicker").value = "#000080";
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton("accept"),
+ {},
+ dialogWindow
+ );
+ await new Promise(r => setTimeout(r));
+ },
+ }
+ );
+
+ let newTagButton = prefsDocument.getElementById("newTagButton");
+ EventUtils.synthesizeMouseAtCenter(newTagButton, {}, prefsWindow);
+ await newTagDialogPromise;
+
+ let tagList = prefsDocument.getElementById("tagList");
+
+ Assert.ok(
+ tagList.querySelector('richlistitem[value="tbird"]'),
+ "new tbird tag should be in the list"
+ );
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]').style.color,
+ "rgb(0, 0, 128)",
+ "tbird tag color should be correct"
+ );
+ Assert.equal(
+ tagList.querySelectorAll('richlistitem[value="tbird"]').length,
+ 1,
+ "new tbird tag should be in the list exactly once"
+ );
+
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]'),
+ tagList.selectedItem,
+ "tbird tag should be selected"
+ );
+
+ // Now edit the tag. The key should stay the same, name and color will change.
+
+ let editTagDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/tagDialog.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+
+ Assert.equal(
+ dialogDocument.getElementById("name").value,
+ "tbird",
+ "should have existing tbird tag name prefilled"
+ );
+ Assert.equal(
+ dialogDocument.getElementById("tagColorPicker").value,
+ "#000080",
+ "should have existing tbird tag color prefilled"
+ );
+
+ EventUtils.sendString("-xx", dialogWindow); // => tbird-xx
+ // "#FFD700" == rgb(255, 215, 0);
+ dialogDocument.getElementById("tagColorPicker").value = "#FFD700";
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton("accept"),
+ {},
+ dialogWindow
+ );
+ await new Promise(r => setTimeout(r));
+ },
+ }
+ );
+
+ let editTagButton = prefsDocument.getElementById("editTagButton");
+ EventUtils.synthesizeMouseAtCenter(editTagButton, {}, prefsWindow);
+ await editTagDialogPromise;
+
+ Assert.ok(
+ tagList.querySelector(
+ 'richlistitem[value="tbird"] > label[value="tbird-xx"]'
+ ),
+ "tbird-xx tag should be in the list"
+ );
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]').style.color,
+ "rgb(255, 215, 0)",
+ "tbird-xx tag color should be correct"
+ );
+ Assert.equal(
+ tagList.querySelectorAll('richlistitem[value="tbird"]').length,
+ 1,
+ "tbird-xx tag should be in the list exactly once"
+ );
+
+ // And remove it.
+
+ EventUtils.synthesizeMouseAtCenter(
+ prefsDocument.getElementById("removeTagButton"),
+ {},
+ prefsWindow
+ );
+ await new Promise(r => setTimeout(r));
+
+ Assert.equal(
+ tagList.querySelector('richlistitem[value="tbird"]'),
+ null,
+ "tbird-xx (with key tbird) tag should have been removed from the list"
+ );
+
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_openPreferences.js b/comm/mail/components/preferences/test/browser/browser_openPreferences.js
new file mode 100644
index 0000000000..01aceb085a
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_openPreferences.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function getStoredLastSelected() {
+ return Services.xulStore.getValue(
+ "about:preferences",
+ "paneDeck",
+ "lastSelected"
+ );
+}
+
+add_task(async () => {
+ // Check that openPreferencesTab with no arguments and no stored value opens the first pane.
+ Services.xulStore.removeDocument("about:preferences");
+
+ let { prefsWindow } = await openNewPrefsTab();
+ is(prefsWindow.gLastCategory.category, "paneGeneral");
+
+ await closePrefsTab();
+});
+
+add_task(async () => {
+ // Check that openPreferencesTab with one argument opens the right pane…
+ Services.xulStore.removeDocument("about:preferences");
+
+ await openNewPrefsTab("panePrivacy");
+ is(getStoredLastSelected(), "panePrivacy");
+
+ await closePrefsTab();
+
+ // … even with a value in the XULStore.
+ await openNewPrefsTab("paneCompose");
+ is(getStoredLastSelected(), "paneCompose");
+
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_privacy.js b/comm/mail/components/preferences/test/browser/browser_privacy.js
new file mode 100644
index 0000000000..1b91ec35e8
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_privacy.js
@@ -0,0 +1,454 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ await testCheckboxes(
+ "panePrivacy",
+ "privacyCategory",
+ {
+ checkboxID: "acceptRemoteContent",
+ pref: "mailnews.message_display.disable_remote_image",
+ prefValues: [true, false],
+ },
+ {
+ checkboxID: "keepHistory",
+ pref: "places.history.enabled",
+ },
+ {
+ checkboxID: "acceptCookies",
+ pref: "network.cookie.cookieBehavior",
+ prefValues: [2, 0],
+ enabledElements: ["#acceptThirdPartyMenu"],
+ unaffectedElements: ["#cookieExceptions"],
+ },
+ {
+ checkboxID: "privacyDoNotTrackCheckbox",
+ pref: "privacy.donottrackheader.enabled",
+ }
+ );
+});
+
+add_task(async () => {
+ await testCheckboxes(
+ "panePrivacy",
+ "privacyJunkCategory",
+ {
+ checkboxID: "manualMark",
+ pref: "mail.spam.manualMark",
+ enabledElements: ["#manualMarkMode radio"],
+ },
+ {
+ checkboxID: "markAsReadOnSpam",
+ pref: "mail.spam.markAsReadOnSpam",
+ },
+ {
+ checkboxID: "enableJunkLogging",
+ pref: "mail.spam.logging.enabled",
+ enabledElements: ["#openJunkLogButton"],
+ }
+ );
+
+ await testCheckboxes("panePrivacy", "privacySecurityCategory", {
+ checkboxID: "enablePhishingDetector",
+ pref: "mail.phishing.detection.enabled",
+ });
+
+ await testCheckboxes("panePrivacy", "enableAntiVirusQuarantine", {
+ checkboxID: "enableAntiVirusQuarantine",
+ pref: "mailnews.downloadToTempFile",
+ });
+});
+
+add_task(async () => {
+ Services.prefs.setBoolPref("mail.spam.manualMark", true);
+
+ await testRadioButtons("panePrivacy", "privacyJunkCategory", {
+ pref: "mail.spam.manualMarkMode",
+ states: [
+ {
+ id: "manualMarkMode0",
+ prefValue: 0,
+ },
+ {
+ id: "manualMarkMode1",
+ prefValue: 1,
+ },
+ ],
+ });
+});
+
+add_task(async () => {
+ // Telemetry pref is locked.
+ // await testCheckboxes("paneAdvanced", undefined, {
+ // checkboxID: "submitTelemetryBox",
+ // pref: "toolkit.telemetry.enabled",
+ // });
+
+ await testCheckboxes("panePrivacy", "enableOCSP", {
+ checkboxID: "enableOCSP",
+ pref: "security.OCSP.enabled",
+ prefValues: [0, 1],
+ });
+});
+
+// Here we'd test the update choices, but I don't want to go near that.
+add_task(async () => {
+ await testRadioButtons("panePrivacy", "enableOCSP", {
+ pref: "security.default_personal_cert",
+ states: [
+ {
+ id: "certSelectionAuto",
+ prefValue: "Select Automatically",
+ },
+ {
+ id: "certSelectionAsk",
+ prefValue: "Ask Every Time",
+ },
+ ],
+ });
+});
+
+add_task(async function testRemoteContentDialog() {
+ const { prefsDocument, prefsWindow } = await openNewPrefsTab("panePrivacy");
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const url = dialogDocument.getElementById("url");
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ EventUtils.sendString("accept.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnAllow"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 1,
+ "new entry should be added to list"
+ );
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ dialogDocument.getElementById("btnSession")
+ ),
+ "session button should be hidden"
+ );
+
+ EventUtils.sendString("block.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnBlock"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "new entry should be added to list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ let remoteContentExceptions = prefsDocument.getElementById(
+ "remoteContentExceptions"
+ );
+ EventUtils.synthesizeMouseAtCenter(remoteContentExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let acceptURI = Services.io.newURI("http://accept.invalid/");
+ let acceptPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ acceptURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "image"),
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "accept permission should exist for accept.invalid"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let blockURI = Services.io.newURI("http://block.invalid/");
+ let blockPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ blockURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "image"),
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "block permission should exist for block.invalid"
+ );
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "list should be populated"
+ );
+
+ permissionsTree.view.selection.select(0);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removePermission"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 1,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removeAllPermissions"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 0,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(remoteContentExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "image"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for accept.invalid"
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "image"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for block.invalid"
+ );
+
+ await closePrefsTab();
+});
+
+add_task(async function testCookiesDialog() {
+ const { prefsDocument, prefsWindow } = await openNewPrefsTab("panePrivacy");
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const url = dialogDocument.getElementById("url");
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ EventUtils.sendString("accept.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnAllow"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 1,
+ "new entry should be added to list"
+ );
+
+ EventUtils.sendString("session.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnSession"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "new entry should be added to list"
+ );
+
+ EventUtils.sendString("block.invalid", dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnBlock"),
+ {},
+ dialogWindow
+ );
+ await new Promise(f => setTimeout(f));
+ Assert.equal(url.value, "", "url input should be cleared");
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 3,
+ "new entry should be added to list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ let cookieExceptions = prefsDocument.getElementById("cookieExceptions");
+ EventUtils.synthesizeMouseAtCenter(cookieExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let acceptURI = Services.io.newURI("http://accept.invalid/");
+ let acceptPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ acceptURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "cookie"),
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "accept permission should exist for accept.invalid"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let sessionURI = Services.io.newURI("http://session.invalid/");
+ let sessionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ sessionURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(sessionPrincipal, "cookie"),
+ Ci.nsICookiePermission.ACCESS_SESSION,
+ "session permission should exist for session.invalid"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let blockURI = Services.io.newURI("http://block.invalid/");
+ let blockPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ blockURI,
+ {}
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "cookie"),
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "block permission should exist for block.invalid"
+ );
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/permissions.xhtml",
+ {
+ isSubDialog: true,
+ async callback(dialogWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == dialogWindow,
+ "waiting for subdialog to be focused"
+ );
+
+ const dialogDocument = dialogWindow.document;
+ const permissionsTree =
+ dialogDocument.getElementById("permissionsTree");
+
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 3,
+ "list should be populated"
+ );
+
+ permissionsTree.view.selection.select(0);
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removePermission"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 2,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("removeAllPermissions"),
+ {},
+ dialogWindow
+ );
+ Assert.equal(
+ permissionsTree.view.rowCount,
+ 0,
+ "row should be removed from list"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.getElementById("btnApplyChanges"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(cookieExceptions, {}, prefsWindow);
+ await dialogPromise;
+
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(acceptPrincipal, "cookie"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for accept.invalid"
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(sessionPrincipal, "cookie"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for session.invalid"
+ );
+ Assert.equal(
+ Services.perms.testPermissionFromPrincipal(blockPrincipal, "cookie"),
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "permission should be removed for block.invalid"
+ );
+
+ await closePrefsTab();
+});
diff --git a/comm/mail/components/preferences/test/browser/browser_sync.js b/comm/mail/components/preferences/test/browser/browser_sync.js
new file mode 100644
index 0000000000..108695ebb5
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/browser_sync.js
@@ -0,0 +1,419 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+FxAccounts.config.promiseConnectAccountURI = entryPoint =>
+ `https://example.org/?page=connect&entryPoint=${entryPoint}`;
+FxAccounts.config.promiseManageURI = entryPoint =>
+ `https://example.org/?page=manage&entryPoint=${entryPoint}`;
+FxAccounts.config.promiseChangeAvatarURI = entryPoint =>
+ `https://example.org/?page=avatar&entryPoint=${entryPoint}`;
+
+const ALL_ENGINES = [
+ "accounts",
+ "identities",
+ "addressbooks",
+ "calendars",
+ "passwords",
+];
+const PREF_PREFIX = "services.sync.engine";
+
+let prefsWindow, prefsDocument, tabmail;
+
+add_setup(async function () {
+ for (let engine of ALL_ENGINES) {
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.${engine}`, true);
+ }
+
+ ({ prefsWindow, prefsDocument } = await openNewPrefsTab("paneSync"));
+ tabmail = document.getElementById("tabmail");
+
+ /** @implements {nsIExternalProtocolService} */
+ let mockExternalProtocolService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+ externalProtocolHandlerExists(protocolScheme) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `should not be opening ${uri.spec} in an external browser`
+ );
+ },
+ };
+
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+});
+
+add_task(async function testSectionStates() {
+ let noFxaAccount = prefsDocument.getElementById("noFxaAccount");
+ let hasFxaAccount = prefsDocument.getElementById("hasFxaAccount");
+ let accountStates = [noFxaAccount, hasFxaAccount];
+
+ let fxaLoginUnverified = prefsDocument.getElementById("fxaLoginUnverified");
+ let fxaLoginRejected = prefsDocument.getElementById("fxaLoginRejected");
+ let fxaLoginVerified = prefsDocument.getElementById("fxaLoginVerified");
+ let loginStates = [fxaLoginUnverified, fxaLoginRejected, fxaLoginVerified];
+
+ let fxaDeviceInfo = prefsDocument.getElementById("fxaDeviceInfo");
+ let syncConnected = prefsDocument.getElementById("syncConnected");
+ let syncDisconnected = prefsDocument.getElementById("syncDisconnected");
+ let syncStates = [syncConnected, syncDisconnected];
+
+ function assertStateVisible(states, visibleState) {
+ for (let state of states) {
+ let visible = BrowserTestUtils.is_visible(state);
+ Assert.equal(
+ visible,
+ state == visibleState,
+ `${state.id} should be ${state == visibleState ? "visible" : "hidden"}`
+ );
+ }
+ }
+
+ function checkStates({
+ accountState,
+ loginState = null,
+ deviceInfoVisible = false,
+ syncState = null,
+ }) {
+ prefsWindow.gSyncPane.updateWeavePrefs();
+ assertStateVisible(accountStates, accountState);
+ assertStateVisible(loginStates, loginState);
+ Assert.equal(
+ BrowserTestUtils.is_visible(fxaDeviceInfo),
+ deviceInfoVisible,
+ `fxaDeviceInfo should be ${deviceInfoVisible ? "visible" : "hidden"}`
+ );
+ assertStateVisible(syncStates, syncState);
+ }
+
+ async function assertTabOpens(target, expectedURL) {
+ if (typeof target == "string") {
+ target = prefsDocument.getElementById(target);
+ }
+
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(target, {}, prefsWindow);
+ await tabPromise;
+ let tab = tabmail.currentTabInfo;
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ Assert.equal(
+ tab.browser.currentURI.spec,
+ `https://example.org/${expectedURL}`,
+ "a tab opened to the correct URL"
+ );
+ tabmail.closeTab(tab);
+ }
+
+ info("No account");
+ Assert.equal(prefsWindow.UIState.get().status, "not_configured");
+ checkStates({ accountState: noFxaAccount });
+
+ // Check clicking the Sign In button opens the connect page in a tab.
+ await assertTabOpens("noFxaSignIn", "?page=connect&entryPoint=");
+
+ // Override the window's UIState object with mock values.
+ let baseState = {
+ email: "test@invalid",
+ displayName: "Testy McTest",
+ avatarURL:
+ "https://example.org/browser/comm/mail/components/preferences/test/browser/files/avatar.png",
+ avatarIsDefault: false,
+ };
+ let mockState;
+ prefsWindow.UIState = {
+ ON_UPDATE: "sync-ui-state:update",
+ STATUS_LOGIN_FAILED: "login_failed",
+ STATUS_NOT_CONFIGURED: "not_configured",
+ STATUS_NOT_VERIFIED: "not_verified",
+ STATUS_SIGNED_IN: "signed_in",
+ get() {
+ return mockState;
+ },
+ };
+
+ info("Login not verified");
+ mockState = { ...baseState, status: "not_verified" };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginUnverified,
+ deviceInfoVisible: true,
+ });
+ Assert.deepEqual(
+ await prefsDocument.l10n.getAttributes(
+ prefsDocument.getElementById("fxaAccountMailNotVerified")
+ ),
+ {
+ id: "sync-pane-email-not-verified",
+ args: { userEmail: "test@invalid" },
+ },
+ "email address set correctly"
+ );
+
+ // Untested: Resend and remove account buttons.
+
+ info("Login rejected");
+ mockState = { ...baseState, status: "login_failed" };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginRejected,
+ deviceInfoVisible: true,
+ });
+ Assert.deepEqual(
+ await prefsDocument.l10n.getAttributes(
+ prefsDocument.getElementById("fxaAccountLoginRejected")
+ ),
+ {
+ id: "sync-signedin-login-failure",
+ args: { userEmail: "test@invalid" },
+ },
+ "email address set correctly"
+ );
+
+ // Untested: Sign in and remove account buttons.
+
+ info("Logged in, sync disabled");
+ mockState = { ...baseState, status: "verified", syncEnabled: false };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginVerified,
+ deviceInfoVisible: true,
+ syncState: syncDisconnected,
+ });
+ let photo = fxaLoginVerified.querySelector(".contact-photo");
+ Assert.equal(
+ photo.src,
+ "https://example.org/browser/comm/mail/components/preferences/test/browser/files/avatar.png",
+ "avatar image set correctly"
+ );
+
+ // Check clicking the avatar image opens the avatar page in a tab.
+ await assertTabOpens(photo, "?page=avatar&entryPoint=preferences");
+
+ Assert.equal(
+ prefsDocument.getElementById("fxaDisplayName").textContent,
+ "Testy McTest",
+ "display name set correctly"
+ );
+ Assert.equal(
+ prefsDocument.getElementById("fxaEmailAddress").textContent,
+ "test@invalid",
+ "email address set correctly"
+ );
+
+ // Check clicking the management link opens the management page in a tab.
+ await assertTabOpens("verifiedManage", "?page=manage&entryPoint=preferences");
+
+ // Untested: Sign out button.
+
+ info("Device name section");
+ let deviceNameInput = prefsDocument.getElementById("fxaDeviceNameInput");
+ let deviceNameCancel = prefsDocument.getElementById("fxaDeviceNameCancel");
+ let deviceNameSave = prefsDocument.getElementById("fxaDeviceNameSave");
+ let deviceNameChange = prefsDocument.getElementById(
+ "fxaDeviceNameChangeDeviceName"
+ );
+ Assert.ok(deviceNameInput.readOnly, "input is read-only");
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameCancel),
+ "cancel button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameSave),
+ "save button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameChange),
+ "change button is visible"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(deviceNameChange, {}, prefsWindow);
+ Assert.ok(!deviceNameInput.readOnly, "input is writeable");
+ Assert.equal(prefsDocument.activeElement, deviceNameInput, "input is active");
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameCancel),
+ "cancel button is visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameSave),
+ "save button is visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameChange),
+ "change button is hidden"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(deviceNameCancel, {}, prefsWindow);
+ Assert.ok(deviceNameInput.readOnly, "input is read-only");
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameCancel),
+ "cancel button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(deviceNameSave),
+ "save button is hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(deviceNameChange),
+ "change button is visible"
+ );
+
+ // Check the turn on sync button works.
+ await openEngineDialog({ expectEngines: ALL_ENGINES, button: "syncSetup" });
+
+ info("Logged in, sync enabled");
+ mockState = { ...baseState, status: "verified", syncEnabled: true };
+ checkStates({
+ accountState: hasFxaAccount,
+ loginState: fxaLoginVerified,
+ deviceInfoVisible: true,
+ syncState: syncConnected,
+ });
+
+ // Untested: Sync now button.
+
+ // Check the learn more link opens a tab.
+ await assertTabOpens("enginesLearnMore", "?page=learnMore");
+
+ // Untested: Disconnect button.
+});
+
+add_task(async function testEngines() {
+ function assertEnginesEnabled(...expectedEnabled) {
+ for (let engine of ALL_ENGINES) {
+ let enabled = Services.prefs.getBoolPref(`${PREF_PREFIX}.${engine}`);
+ Assert.equal(
+ enabled,
+ expectedEnabled.includes(engine),
+ `${engine} should be ${
+ expectedEnabled.includes(engine) ? "enabled" : "disabled"
+ }`
+ );
+ }
+ }
+
+ function assertEnginesShown(...expectEngines) {
+ let ENGINES_TO_ITEMS = {
+ accounts: "showSyncAccount",
+ identities: "showSyncIdentity",
+ addressbooks: "showSyncAddress",
+ calendars: "showSyncCalendar",
+ passwords: "showSyncPasswords",
+ };
+ let expectItems = expectEngines.map(engine => ENGINES_TO_ITEMS[engine]);
+ let items = Array.from(
+ prefsDocument.querySelectorAll("#showSyncedList > li:not([hidden])"),
+ li => li.id
+ );
+ Assert.deepEqual(items, expectItems, "enabled engines shown correctly");
+ }
+
+ assertEnginesShown(...ALL_ENGINES);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.accounts`, false);
+ assertEnginesShown("identities", "addressbooks", "calendars", "passwords");
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.identities`, false);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.addressbooks`, false);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.calendars`, false);
+ assertEnginesShown("passwords");
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.passwords`, false);
+ assertEnginesShown();
+
+ info("Checking the engine selection dialog");
+ await openEngineDialog({
+ toggleEngines: ["accounts", "identities", "passwords"],
+ });
+
+ assertEnginesEnabled("accounts", "identities", "passwords");
+ assertEnginesShown("accounts", "identities", "passwords");
+
+ await openEngineDialog({
+ expectEngines: ["accounts", "identities", "passwords"],
+ toggleEngines: ["calendars", "passwords"],
+ action: "cancel",
+ });
+
+ assertEnginesEnabled("accounts", "identities", "passwords");
+ assertEnginesShown("accounts", "identities", "passwords");
+
+ await openEngineDialog({
+ expectEngines: ["accounts", "identities", "passwords"],
+ toggleEngines: ["calendars", "passwords"],
+ action: "accept",
+ });
+
+ assertEnginesEnabled("accounts", "identities", "calendars");
+ assertEnginesShown("accounts", "identities", "calendars");
+
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.addressbooks`, true);
+ Services.prefs.setBoolPref(`${PREF_PREFIX}.passwords`, true);
+ assertEnginesShown(...ALL_ENGINES);
+});
+
+async function openEngineDialog({
+ expectEngines = [],
+ toggleEngines = [],
+ action = "accept",
+ button = "syncChangeOptions",
+}) {
+ const ENGINES_TO_CHECKBOXES = {
+ accounts: "configSyncAccount",
+ identities: "configSyncIdentity",
+ addressbooks: "configSyncAddress",
+ calendars: "configSyncCalendar",
+ passwords: "configSyncPasswords",
+ };
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/preferences/syncDialog.xhtml",
+ { isSubDialog: true }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ prefsDocument.getElementById(button),
+ {},
+ prefsWindow
+ );
+ let dialogWindow = await dialogPromise;
+ let dialogDocument = dialogWindow.document;
+ await new Promise(resolve => dialogWindow.setTimeout(resolve));
+
+ let expectItems = expectEngines.map(engine => ENGINES_TO_CHECKBOXES[engine]);
+
+ let checkedItems = Array.from(
+ dialogDocument.querySelectorAll(`input[type="checkbox"]`)
+ )
+ .filter(cb => cb.checked)
+ .map(cb => cb.id);
+ Assert.deepEqual(
+ checkedItems,
+ expectItems,
+ "enabled engines checked correctly"
+ );
+
+ for (let toggleItem of toggleEngines) {
+ let checkbox = dialogDocument.getElementById(
+ ENGINES_TO_CHECKBOXES[toggleItem]
+ );
+ checkbox.checked = !checkbox.checked;
+ }
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton(action),
+ {},
+ dialogWindow
+ );
+}
diff --git a/comm/mail/components/preferences/test/browser/files/avatar.png b/comm/mail/components/preferences/test/browser/files/avatar.png
new file mode 100644
index 0000000000..ca0894316a
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/files/avatar.png
Binary files differ
diff --git a/comm/mail/components/preferences/test/browser/files/icon.svg b/comm/mail/components/preferences/test/browser/files/icon.svg
new file mode 100644
index 0000000000..6c1a552445
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/files/icon.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+ <circle cx="8" cy="8" r="7.5" fill="#ffffff" stroke="#00aa00" stroke-width="1.5"/>
+ <circle cx="5" cy="6" r="1.5" fill="#00aa00"/>
+ <circle cx="11" cy="6" r="1.5" fill="#00aa00"/>
+ <path d="M 12.83,9.30 C 12.24,11.48 10.26,13 8,13 5.75,13 3.74,11.48 3.17,9.29" fill="none" stroke="#00aa00" stroke-width="1.5"/>
+</svg>
diff --git a/comm/mail/components/preferences/test/browser/files/management.html b/comm/mail/components/preferences/test/browser/files/management.html
new file mode 100644
index 0000000000..7e3561d823
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/files/management.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title></title>
+</head>
+<body>
+ <a id="a" href="https://www.example.com/">Click me!</a>
+</body>
+</html>
diff --git a/comm/mail/components/preferences/test/browser/head.js b/comm/mail/components/preferences/test/browser/head.js
new file mode 100644
index 0000000000..12cbdb17f1
--- /dev/null
+++ b/comm/mail/components/preferences/test/browser/head.js
@@ -0,0 +1,314 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../../base/content/utilityOverlay.js */
+
+async function openNewPrefsTab(paneID, scrollPaneTo, otherArgs) {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ is(prefsTabMode.tabs.length, 0, "Prefs tab is not open");
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL.startsWith("about:preferences")) {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ subject.ownerGlobal.setTimeout(() => resolve(subject));
+ }
+ }, "chrome-document-loaded");
+ openPreferencesTab(paneID, scrollPaneTo, otherArgs);
+ });
+ ok(prefsDocument.URL.startsWith("about:preferences"), "Prefs tab is open");
+
+ prefsDocument = prefsTabMode.tabs[0].browser.contentDocument;
+ let prefsWindow = prefsDocument.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+
+ if (paneID) {
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ is(
+ prefsWindow.gLastCategory.category,
+ paneID,
+ `Selected pane is ${paneID}`
+ );
+ } else {
+ // If we don't wait here for other scripts to run, they
+ // could be in a bad state if our test closes the tab.
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ }
+
+ registerCleanupOnce();
+
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ let container = prefsDocument.getElementById("preferencesContainer");
+ if (scrollPaneTo && container.scrollHeight > container.clientHeight) {
+ Assert.greater(
+ container.scrollTop,
+ 0,
+ "Prefs page did scroll when it was supposed to"
+ );
+ }
+ return { prefsDocument, prefsWindow };
+}
+
+async function openExistingPrefsTab(paneID, scrollPaneTo, otherArgs) {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ is(prefsTabMode.tabs.length, 1, "Prefs tab is open");
+
+ let prefsDocument = prefsTabMode.tabs[0].browser.contentDocument;
+ let prefsWindow = prefsDocument.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+
+ if (paneID && prefsWindow.gLastCategory.category != paneID) {
+ openPreferencesTab(paneID, scrollPaneTo, otherArgs);
+ }
+
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ is(prefsWindow.gLastCategory.category, paneID, `Selected pane is ${paneID}`);
+
+ if (scrollPaneTo) {
+ Assert.greater(
+ prefsDocument.getElementById("preferencesContainer").scrollTop,
+ 0,
+ "Prefs page did scroll when it was supposed to"
+ );
+ }
+
+ registerCleanupOnce();
+ return { prefsDocument, prefsWindow };
+}
+
+function registerCleanupOnce() {
+ if (registerCleanupOnce.alreadyRegistered) {
+ return;
+ }
+ registerCleanupFunction(closePrefsTab);
+ registerCleanupOnce.alreadyRegistered = true;
+}
+
+async function closePrefsTab() {
+ info("Closing prefs tab");
+ let tabmail = document.getElementById("tabmail");
+ let prefsTab = tabmail.tabModes.preferencesTab.tabs[0];
+ if (prefsTab) {
+ tabmail.closeTab(prefsTab);
+ }
+}
+
+/**
+ * Tests a checkbox sets the preference is set in the right state when the preferences tab opens,
+ * that the preference it relates to is set properly, and any UI elements that should be disabled
+ * by it are disabled.
+ *
+ * Each of the tests arguments is an object describing a test, containing:
+ * checkboxID - the ID of the checkbox to test
+ * pref - the name of a preference,
+ * prefValues - an array of two values: pref value when not checked, pref value when checked
+ * (optional, defaults to [false, true])
+ * enabledElements - an array of CSS selectors (optional)
+ * enabledInverted - if the elements should be disabled when the checkbox is checked (optional)
+ * unaffectedElements - array of CSS selectors that should not be affected by
+ * the toggling of the checkbox.
+ */
+async function testCheckboxes(paneID, scrollPaneTo, ...tests) {
+ for (let initiallyChecked of [true, false]) {
+ info(`Opening ${paneID} with prefs set to ${initiallyChecked}`);
+
+ for (let test of tests) {
+ let wantedValue = initiallyChecked;
+ if (test.prefValues) {
+ wantedValue = wantedValue ? test.prefValues[1] : test.prefValues[0];
+ }
+ if (typeof wantedValue == "number") {
+ Services.prefs.setIntPref(test.pref, wantedValue);
+ } else {
+ Services.prefs.setBoolPref(test.pref, wantedValue);
+ }
+ }
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ paneID,
+ scrollPaneTo
+ );
+
+ let testUIState = function (test, checked) {
+ let wantedValue = checked;
+ if (test.prefValues) {
+ wantedValue = wantedValue ? test.prefValues[1] : test.prefValues[0];
+ }
+ let checkbox = prefsDocument.getElementById(test.checkboxID);
+ is(
+ checkbox.checked,
+ checked,
+ wantedValue,
+ "Checkbox " + (checked ? "is" : "isn't") + " checked"
+ );
+ if (typeof wantedValue == "number") {
+ is(
+ Services.prefs.getIntPref(test.pref, -999),
+ wantedValue,
+ `Pref is ${wantedValue}`
+ );
+ } else {
+ is(
+ Services.prefs.getBoolPref(test.pref),
+ wantedValue,
+ `Pref is ${wantedValue}`
+ );
+ }
+
+ if (test.enabledElements) {
+ let disabled = checked;
+ if (test.enabledInverted) {
+ disabled = !disabled;
+ }
+ for (let selector of test.enabledElements) {
+ let elements = prefsDocument.querySelectorAll(selector);
+ ok(
+ elements.length >= 1,
+ `At least one element matched '${selector}'`
+ );
+ for (let element of elements) {
+ is(
+ element.disabled,
+ !disabled,
+ "Element " + (disabled ? "isn't" : "is") + " disabled"
+ );
+ }
+ }
+ }
+ };
+
+ let testUnaffected = function (ids, states) {
+ ids.forEach((sel, index) => {
+ let isOk = prefsDocument.querySelector(sel).disabled === states[index];
+ is(isOk, true, `Element "${sel}" is unaffected`);
+ });
+ };
+
+ for (let test of tests) {
+ info(`Checking ${test.checkboxID}`);
+
+ let unaffectedSelectors = test.unaffectedElements || [];
+ let unaffectedStates = unaffectedSelectors.map(
+ sel => prefsDocument.querySelector(sel).disabled
+ );
+
+ let checkbox = prefsDocument.getElementById(test.checkboxID);
+ checkbox.scrollIntoView(false);
+ testUIState(test, initiallyChecked);
+
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prefsWindow);
+ testUIState(test, !initiallyChecked);
+ testUnaffected(unaffectedSelectors, unaffectedStates);
+
+ EventUtils.synthesizeMouseAtCenter(checkbox, {}, prefsWindow);
+ testUIState(test, initiallyChecked);
+ testUnaffected(unaffectedSelectors, unaffectedStates);
+ }
+
+ await closePrefsTab();
+ }
+}
+
+/**
+ * Tests a set of radio buttons is in the right state when the preferences tab opens, and when
+ * the selected button changes that the preference it relates to is set properly, and any related
+ * UI elements that should be disabled are disabled.
+ *
+ * Each of the tests arguments is an object describing a test, containing:
+ * pref - the name of an integer preference,
+ * states - an array with each element describing a radio button:
+ * id - the ID of the button to test,
+ * prefValue - the value the pref should be set to
+ * enabledElements - an array of CSS selectors to elements that should be enabled when this
+ * radio button is selected (optional)
+ */
+async function testRadioButtons(paneID, scrollPaneTo, ...tests) {
+ for (let { pref, states } of tests) {
+ for (let initialState of states) {
+ info(`Opening ${paneID} with ${pref} set to ${initialState.prefValue}`);
+
+ if (typeof initialState.prefValue == "number") {
+ Services.prefs.setIntPref(pref, initialState.prefValue);
+ } else if (typeof initialState.prefValue == "boolean") {
+ Services.prefs.setBoolPref(pref, initialState.prefValue);
+ } else {
+ Services.prefs.setCharPref(pref, initialState.prefValue);
+ }
+
+ let { prefsDocument, prefsWindow } = await openNewPrefsTab(
+ paneID,
+ scrollPaneTo
+ );
+
+ let testUIState = function (currentState) {
+ info(`Testing with ${pref} set to ${currentState.prefValue}`);
+ for (let state of states) {
+ let isCurrentState = state == currentState;
+ let radio = prefsDocument.getElementById(state.id);
+ is(radio.selected, isCurrentState, `${state.id}.selected`);
+
+ if (state.enabledElements) {
+ for (let selector of state.enabledElements) {
+ let elements = prefsDocument.querySelectorAll(selector);
+ ok(
+ elements.length >= 1,
+ `At least one element matched '${selector}'`
+ );
+ for (let element of elements) {
+ is(
+ element.disabled,
+ !isCurrentState,
+ "Element " + (isCurrentState ? "isn't" : "is") + " disabled"
+ );
+ }
+ }
+ }
+ }
+ if (typeof initialState.prefValue == "number") {
+ is(
+ Services.prefs.getIntPref(pref, -999),
+ currentState.prefValue,
+ `Pref is ${currentState.prefValue}`
+ );
+ } else if (typeof initialState.prefValue == "boolean") {
+ is(
+ Services.prefs.getBoolPref(pref),
+ currentState.prefValue,
+ `Pref is ${currentState.prefValue}`
+ );
+ } else {
+ is(
+ Services.prefs.getCharPref(pref, "FAKE VALUE"),
+ currentState.prefValue,
+ `Pref is ${currentState.prefValue}`
+ );
+ }
+ };
+
+ // Check the initial setup is correct.
+ testUIState(initialState);
+ // Cycle through possible values, checking each one.
+ for (let state of states) {
+ if (state == initialState) {
+ continue;
+ }
+ let radio = prefsDocument.getElementById(state.id);
+ radio.scrollIntoView(false);
+ EventUtils.synthesizeMouseAtCenter(radio, {}, prefsWindow);
+ testUIState(state);
+ }
+ // Go back to the initial value.
+ let initialRadio = prefsDocument.getElementById(initialState.id);
+ initialRadio.scrollIntoView(false);
+ EventUtils.synthesizeMouseAtCenter(initialRadio, {}, prefsWindow);
+ testUIState(initialState);
+
+ await closePrefsTab();
+ }
+ }
+}
diff --git a/comm/mail/components/prompts/PromptCollection.jsm b/comm/mail/components/prompts/PromptCollection.jsm
new file mode 100644
index 0000000000..ddb413de6f
--- /dev/null
+++ b/comm/mail/components/prompts/PromptCollection.jsm
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["PromptCollection"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+/**
+ * Implements nsIPromptCollection
+ *
+ * @class PromptCollection
+ */
+class PromptCollection {
+ asyncBeforeUnloadCheck(browsingContext) {
+ let title;
+ let message;
+ let leaveLabel;
+ let stayLabel;
+
+ try {
+ title = this.domBundle.GetStringFromName("OnBeforeUnloadTitle");
+ message = this.domBundle.GetStringFromName("OnBeforeUnloadMessage2");
+ leaveLabel = this.domBundle.GetStringFromName(
+ "OnBeforeUnloadLeaveButton"
+ );
+ stayLabel = this.domBundle.GetStringFromName("OnBeforeUnloadStayButton");
+ } catch (exception) {
+ console.error("Failed to get strings from dom.properties");
+ return false;
+ }
+
+ let contentViewer = browsingContext?.docShell?.contentViewer;
+
+ // TODO: Do we really want to allow modal dialogs from inactive
+ // content viewers at all, particularly for permit unload prompts?
+ let modalAllowed = contentViewer
+ ? contentViewer.isTabModalPromptAllowed
+ : browsingContext.ancestorsAreCurrent;
+
+ let modalType =
+ Ci.nsIPromptService[
+ modalAllowed ? "MODAL_TYPE_CONTENT" : "MODAL_TYPE_WINDOW"
+ ];
+
+ let buttonFlags =
+ Ci.nsIPromptService.BUTTON_POS_0_DEFAULT |
+ (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING *
+ Ci.nsIPromptService.BUTTON_POS_0) |
+ (Ci.nsIPromptService.BUTTON_TITLE_IS_STRING *
+ Ci.nsIPromptService.BUTTON_POS_1);
+
+ return Services.prompt
+ .asyncConfirmEx(
+ browsingContext,
+ modalType,
+ title,
+ message,
+ buttonFlags,
+ leaveLabel,
+ stayLabel,
+ null,
+ null,
+ false,
+ // Tell the prompt service that this is a permit unload prompt
+ // so that it can set the appropriate flag on the detail object
+ // of the events it dispatches.
+ { inPermitUnload: true }
+ )
+ .then(
+ result =>
+ result.QueryInterface(Ci.nsIPropertyBag2).get("buttonNumClicked") == 0
+ );
+ }
+}
+
+XPCOMUtils.defineLazyGetter(
+ PromptCollection.prototype,
+ "domBundle",
+ function () {
+ let bundle = Services.strings.createBundle(
+ "chrome://global/locale/dom/dom.properties"
+ );
+ if (!bundle) {
+ throw new Error("String bundle for dom not present!");
+ }
+ return bundle;
+ }
+);
+
+PromptCollection.prototype.classID = Components.ID(
+ "{7913837c-9623-11ea-bb37-0242ac130002}"
+);
+PromptCollection.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIPromptCollection",
+]);
diff --git a/comm/mail/components/prompts/components.conf b/comm/mail/components/prompts/components.conf
new file mode 100644
index 0000000000..0c00e72d67
--- /dev/null
+++ b/comm/mail/components/prompts/components.conf
@@ -0,0 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{7913837c-9623-11ea-bb37-0242ac130002}',
+ 'contract_ids': ['@mozilla.org/embedcomp/prompt-collection;1'],
+ 'jsm': 'resource:///modules/PromptCollection.jsm',
+ 'constructor': 'PromptCollection',
+ },
+]
diff --git a/comm/mail/components/prompts/moz.build b/comm/mail/components/prompts/moz.build
new file mode 100644
index 0000000000..143c3dcd8d
--- /dev/null
+++ b/comm/mail/components/prompts/moz.build
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "PromptCollection.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/search/SearchIntegration.jsm b/comm/mail/components/search/SearchIntegration.jsm
new file mode 100644
index 0000000000..4bedb50f58
--- /dev/null
+++ b/comm/mail/components/search/SearchIntegration.jsm
@@ -0,0 +1,871 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Common, useful functions for desktop search integration components.
+ *
+ * The following symbols have to be defined for each component that includes this:
+ * - gHdrIndexedProperty: the property in the database that indicates whether a message
+ * has been indexed
+ * - gFileExt: the file extension to be used for support files
+ * - gPrefBase: the base for preferences that are stored
+ * - gStreamListener: an nsIStreamListener to read message text
+ */
+
+/* exported SearchSupport */
+
+var EXPORTED_SYMBOLS = ["SearchIntegration"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var PERM_DIRECTORY = 0o755;
+var PERM_FILE = 0o644;
+
+var SearchIntegration = null;
+
+var SearchSupport = {
+ /**
+ * URI of last folder indexed. Kept in sync with the pref
+ */
+ __lastFolderIndexedUri: null,
+ set _lastFolderIndexedUri(uri) {
+ this._prefBranch.setStringPref("lastFolderIndexedUri", uri);
+ this.__lastFolderIndexedUri = uri;
+ },
+ get _lastFolderIndexedUri() {
+ // If we don't know about it, get it from the pref branch
+ if (this.__lastFolderIndexedUri === null) {
+ this.__lastFolderIndexedUri = this._prefBranch.getStringPref(
+ "lastFolderIndexedUri",
+ ""
+ );
+ }
+ return this.__lastFolderIndexedUri;
+ },
+
+ /**
+ * Queue of message headers to index, along with reindex times for each header
+ */
+ _msgHdrsToIndex: [],
+
+ /**
+ * Messenger object, used primarily to get message URIs
+ */
+ __messenger: null,
+ get _messenger() {
+ if (!this.__messenger) {
+ this.__messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ }
+ return this.__messenger;
+ },
+
+ // The preferences branch to use
+ __prefBranch: null,
+ get _prefBranch() {
+ if (!this.__prefBranch) {
+ this.__prefBranch = Services.prefs.getBranch(this._prefBase);
+ }
+ return this.__prefBranch;
+ },
+
+ /**
+ * If this is true, we won't show any UI because the OS doesn't have the
+ * support we need
+ */
+ osVersionTooLow: false,
+
+ /**
+ * If this is true, we'll show disabled UI, because while the OS does have
+ * the support we need, not all the OS components we need are running
+ */
+ osComponentsNotRunning: false,
+
+ /**
+ * Whether the preference is enabled. The module might be in a state where
+ * the preference is on but "enabled" is false, so take care of that.
+ */
+ get prefEnabled() {
+ // Don't cache the value
+ return this._prefBranch.getBoolPref("enable");
+ },
+ set prefEnabled(aEnabled) {
+ if (this.prefEnabled != aEnabled) {
+ this._prefBranch.setBoolPref("enable", aEnabled);
+ }
+ },
+
+ /**
+ * Whether the first run has occurred. This will be used to determine if
+ * a dialog box needs to be displayed.
+ */
+ get firstRunDone() {
+ // Don't cache this value either
+ return this._prefBranch.getBoolPref("firstRunDone");
+ },
+ set firstRunDone(aAlwaysTrue) {
+ this._prefBranch.setBoolPref("firstRunDone", true);
+ },
+
+ /**
+ * Last global reindex time, used to check if reindexing is required.
+ * Kept in sync with the pref
+ */
+ _globalReindexTime: null,
+ set globalReindexTime(aTime) {
+ this._globalReindexTime = aTime;
+ // Set the pref as well
+ this._prefBranch.setCharPref("global_reindex_time", "" + aTime);
+ },
+ get globalReindexTime() {
+ if (!this._globalReindexTime) {
+ // Try getting the time from the preferences
+ try {
+ this._globalReindexTime = parseInt(
+ this._prefBranch.getCharPref("global_reindex_time")
+ );
+ } catch (e) {
+ // We don't have it defined, so set it (Unix time, in seconds)
+ this._globalReindexTime = parseInt(Date.now() / 1000);
+ this._prefBranch.setCharPref(
+ "global_reindex_time",
+ "" + this._globalReindexTime
+ );
+ }
+ }
+ return this._globalReindexTime;
+ },
+
+ /**
+ * Amount of time the user is idle before we (re)start an indexing sweep
+ */
+ _idleThresholdSecs: 30,
+
+ /**
+ * Reference to timer object
+ */
+ __timer: null,
+ get _timer() {
+ if (!this.__timer) {
+ this.__timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+ return this.__timer;
+ },
+
+ _cancelTimer() {
+ try {
+ this._timer.cancel();
+ } catch (ex) {}
+ },
+
+ /**
+ * Enabled status.
+ *
+ * When we're enabled, then we get notifications about every message or folder
+ * operation, including "message displayed" operations which we bump up in
+ * priority. We also have a background sweep which we do on idle.
+ *
+ * We aren't fully disabled when we're "disabled", though. We still observe
+ * message and folder moves and deletes, as we don't want to have support
+ * files for non-existent messages.
+ */
+ _enabled: null,
+ set enabled(aEnable) {
+ // Nothing to do if there's no change in state
+ if (this._enabled == aEnable) {
+ return;
+ }
+
+ this._log.info(
+ "Enabled status changing from " + this._enabled + " to " + aEnable
+ );
+
+ this._removeObservers();
+
+ if (aEnable) {
+ // This stuff we always need to do.
+ // This code pre-dates msgsClassified.
+ // Some events intentionally omitted.
+ MailServices.mfn.addListener(
+ this._msgFolderListener,
+ MailServices.mfn.msgAdded |
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed
+ );
+ Services.obs.addObserver(this, "MsgMsgDisplayed");
+ let idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(
+ Ci.nsIUserIdleService
+ );
+ idleService.addIdleObserver(this, this._idleThresholdSecs);
+ } else {
+ // We want to observe moves, deletes and renames in case we're disabled
+ // If we don't, we'll have no idea the support files exist later
+ MailServices.mfn.addListener(
+ this._msgFolderListener,
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.msgsDeleted |
+ // folderAdded intentionally omitted
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed
+ );
+ }
+
+ this._enabled = aEnable;
+ },
+ get enabled() {
+ return this._enabled;
+ },
+
+ /**
+ * Remove whatever observers are present. This is done while switching states
+ */
+ _removeObservers() {
+ if (this.enabled === null) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this._msgFolderListener);
+
+ if (this.enabled) {
+ Services.obs.removeObserver(this, "MsgMsgDisplayed");
+ let idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(
+ Ci.nsIUserIdleService
+ );
+ idleService.removeIdleObserver(this, this._idleThresholdSecs);
+
+ // in case there's a background sweep going on
+ this._cancelTimer();
+ }
+ // We don't need to do anything extra if we're disabled
+ },
+
+ /**
+ * Init function -- this should be called from the component's init function
+ */
+ _initSupport(enabled) {
+ this._log.info(
+ "Search integration running in " +
+ (enabled ? "active" : "backoff") +
+ " mode"
+ );
+ this.enabled = enabled;
+
+ // Set up a pref observer
+ this._prefBranch.addObserver("enable", this);
+ },
+
+ /**
+ * Current folder being indexed
+ */
+ _currentFolderToIndex: null,
+
+ /**
+ * For the current folder being indexed, an enumerator for all the headers in
+ * the folder
+ */
+ _headerEnumerator: null,
+
+ /*
+ * These functions are to index already existing messages
+ */
+
+ /**
+ * Generator to look for the next folder to index, and return it
+ *
+ * This first looks for folders that have their corresponding search results
+ * folders missing. If it finds such a folder first, it'll yield return that
+ * folder.
+ *
+ * Next, it looks for the next folder after the lastFolderIndexedUri. If it is
+ * in such a folder, it'll yield return that folder, then set the
+ * lastFolderIndexedUrl to the URI of that folder.
+ *
+ * It resets lastFolderIndexedUri to an empty string, then yield returns null
+ * once iteration across all folders is complete.
+ */
+ *_foldersToIndexGenerator() {
+ // Stores whether we're after the last folder indexed or before that --
+ // if the last folder indexed is empty, this needs to be true initially
+ let afterLastFolderIndexed = this._lastFolderIndexedUri.length == 0;
+
+ for (let server of MailServices.accounts.allServers) {
+ this._log.debug(
+ "in find next folder, lastFolderIndexedUri = " +
+ this._lastFolderIndexedUri
+ );
+
+ for (var folder of server.rootFolder.descendants) {
+ let searchPath = this._getSearchPathForFolder(folder);
+ searchPath.leafName = searchPath.leafName + ".mozmsgs";
+ // If after the last folder indexed, definitely index this
+ if (afterLastFolderIndexed) {
+ // Create the folder if it doesn't exist, so that we don't hit the
+ // condition below later
+ if (!searchPath.exists()) {
+ searchPath.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ }
+
+ yield folder;
+ // We're back after yielding -- set the last folder indexed
+ this._lastFolderIndexedUri = folder.URI;
+ } else {
+ // If a folder's entire corresponding search results folder is
+ // missing, we need to index it, and force a reindex of all the
+ // messages in it
+ if (!searchPath.exists()) {
+ this._log.debug(
+ "using folder " +
+ folder.URI +
+ " because " +
+ "corresponding search folder does not exist"
+ );
+ // Create the folder, so that next time we're checking we don't hit
+ // this
+ searchPath.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ folder.setStringProperty(
+ this._hdrIndexedProperty,
+ "" + Date.now() / 1000
+ );
+ yield folder;
+ } else if (this._pathNeedsReindexing(searchPath)) {
+ // folder may need reindexing for other reasons
+ folder.setStringProperty(
+ this._hdrIndexedProperty,
+ "" + Date.now() / 1000
+ );
+ yield folder;
+ }
+
+ // Even if we yielded above, check if this is the last folder
+ // indexed
+ if (this._lastFolderIndexedUri == folder.URI) {
+ afterLastFolderIndexed = true;
+ }
+ }
+ }
+ }
+ // We're done with one iteration of all the folders; time to reset the
+ // lastFolderIndexedUri
+ this._lastFolderIndexedUri = "";
+ yield null;
+ },
+
+ __foldersToIndex: null,
+ get _foldersToIndex() {
+ if (!this.__foldersToIndex) {
+ this.__foldersToIndex = this._foldersToIndexGenerator();
+ }
+ return this.__foldersToIndex;
+ },
+
+ _findNextHdrToIndex() {
+ try {
+ let reindexTime = this._getLastReindexTime(this._currentFolderToIndex);
+ this._log.debug("Reindex time for this folder is " + reindexTime);
+ if (!this._headerEnumerator) {
+ // we need to create search terms for messages to index
+ let searchSession = Cc[
+ "@mozilla.org/messenger/searchSession;1"
+ ].createInstance(Ci.nsIMsgSearchSession);
+ let searchTerms = [];
+
+ searchSession.addScopeTerm(
+ Ci.nsMsgSearchScope.offlineMail,
+ this._currentFolderToIndex
+ );
+ let nsMsgSearchAttrib = Ci.nsMsgSearchAttrib;
+ let nsMsgSearchOp = Ci.nsMsgSearchOp;
+ // first term: (_hdrIndexProperty < reindexTime)
+ let searchTerm = searchSession.createTerm();
+ searchTerm.booleanAnd = false; // actually don't care here
+ searchTerm.attrib = nsMsgSearchAttrib.Uint32HdrProperty;
+ searchTerm.op = nsMsgSearchOp.IsLessThan;
+ let value = searchTerm.value;
+ value.attrib = searchTerm.attrib;
+ searchTerm.hdrProperty = this._hdrIndexedProperty;
+ value.status = reindexTime;
+ searchTerm.value = value;
+ searchTerms.push(searchTerm);
+ this._headerEnumerator =
+ this._currentFolderToIndex.msgDatabase.getFilterEnumerator(
+ searchTerms
+ );
+ }
+
+ // iterate over the folder finding the next message to index
+ for (let msgHdr of this._headerEnumerator) {
+ // Check if the file exists. If it does, then assume indexing to be
+ // complete for this file
+ if (this._getSupportFile(msgHdr).exists()) {
+ this._log.debug(
+ "Message time not set but file exists; setting " +
+ "time to " +
+ reindexTime
+ );
+ msgHdr.setUint32Property(this._hdrIndexedProperty, reindexTime);
+ } else {
+ return [msgHdr, reindexTime];
+ }
+ }
+ } catch (ex) {
+ this._log.debug("Error while finding next header: " + ex);
+ }
+
+ // If we couldn't find any headers to index, null out the enumerator
+ this._headerEnumerator = null;
+ if (!(this._currentFolderToIndex.flags & Ci.nsMsgFolderFlags.Inbox)) {
+ this._currentFolderToIndex.msgDatabase = null;
+ }
+ return null;
+ },
+
+ /**
+ * Get the last reindex time for this folder. This will be whichever's
+ * greater, the global reindex time or the folder reindex time
+ */
+ _getLastReindexTime(aFolder) {
+ let reindexTime = this.globalReindexTime;
+
+ // Check if this folder has a separate string property set
+ let folderReindexTime;
+ try {
+ folderReindexTime = this._currentFolderToIndex.getStringProperty(
+ this._hdrIndexedProperty
+ );
+ } catch (e) {
+ folderReindexTime = "";
+ }
+
+ if (folderReindexTime.length > 0) {
+ let folderReindexTimeInt = parseInt(folderReindexTime);
+ if (folderReindexTimeInt > reindexTime) {
+ reindexTime = folderReindexTimeInt;
+ }
+ }
+ return reindexTime;
+ },
+
+ /**
+ * Whether background indexing has been completed
+ */
+ __backgroundIndexingDone: false,
+
+ /**
+ * The main background sweeping function. It first looks for a folder to
+ * start or continue indexing in, then for a header. If it can't find anything
+ * to index, it resets the last folder indexed URI so that the sweep can
+ * be restarted
+ */
+ _continueSweep() {
+ let msgHdrAndReindexTime = null;
+
+ if (this.__backgroundIndexingDone) {
+ return;
+ }
+
+ // find the current folder we're working on
+ if (!this._currentFolderToIndex) {
+ this._currentFolderToIndex = this._foldersToIndex.next().value;
+ }
+
+ // we'd like to index more than one message on each timer fire,
+ // but since streaming is async, it's hard to know how long
+ // it's going to take to stream any particular message.
+ if (this._currentFolderToIndex) {
+ msgHdrAndReindexTime = this._findNextHdrToIndex();
+ } else {
+ // We've cycled through all the folders. We should take a break
+ // from indexing of existing messages.
+ this.__backgroundIndexingDone = true;
+ }
+
+ if (!msgHdrAndReindexTime) {
+ this._log.debug("reached end of folder");
+ if (this._currentFolderToIndex) {
+ this._currentFolderToIndex = null;
+ }
+ } else {
+ this._queueMessage(msgHdrAndReindexTime[0], msgHdrAndReindexTime[1]);
+ }
+
+ // Restart the timer, and call ourselves
+ this._cancelTimer();
+ this._timer.initWithCallback(
+ this._wrapContinueSweep,
+ this._msgHdrsToIndex.length > 1 ? 5000 : 1000,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * A simple wrapper to make "this" be right for _continueSweep
+ */
+ _wrapContinueSweep() {
+ SearchIntegration._continueSweep();
+ },
+
+ /**
+ * Observer implementation. Consists of
+ * - idle observer; starts running through folders when it receives an "idle"
+ * notification, and cancels any timers when it receives a "back" notification
+ * - msg displayed observer, queues the message if necessary
+ * - pref observer, to see if the preference has been poked
+ */
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "idle") {
+ this._log.debug("Idle detected, continuing sweep");
+ this._continueSweep();
+ } else if (aTopic == "back") {
+ this._log.debug("Non-idle, so suspending sweep");
+ this._cancelTimer();
+ } else if (aTopic == "MsgMsgDisplayed") {
+ this._log.debug("topic = " + aTopic + " uri = " + aData);
+ let msgHdr = this._messenger.msgHdrFromURI(aData);
+ let reindexTime = this._getLastReindexTime(msgHdr.folder);
+ this._log.debug("Reindex time for this folder is " + reindexTime);
+ if (msgHdr.getUint32Property(this._hdrIndexedProperty) < reindexTime) {
+ // Check if the file exists. If it does, then assume indexing to be
+ // complete for this file
+ if (this._getSupportFile(msgHdr).exists()) {
+ this._log.debug(
+ "Message time not set but file exists; setting " +
+ " time to " +
+ reindexTime
+ );
+ msgHdr.setUint32Property(this._hdrIndexedProperty, reindexTime);
+ } else {
+ this._queueMessage(msgHdr, reindexTime);
+ }
+ }
+ } else if (aTopic == "nsPref:changed" && aData == "enable") {
+ let prefEnabled = this.prefEnabled;
+ // Search integration turned on
+ if (prefEnabled && this.register()) {
+ this.enabled = true;
+ } else if (!prefEnabled && this.deregister()) {
+ // Search integration turned off
+ this.enabled = false;
+ } else {
+ // The call to register or deregister has failed.
+ // This is a hack to handle this case
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ function () {
+ SearchIntegration._handleRegisterFailure(!prefEnabled);
+ },
+ 200,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }
+ }
+ },
+
+ // Handle failure to register or deregister
+ _handleRegisterFailure(enabled) {
+ // Remove ourselves from the observer list, flip the pref,
+ // and add ourselves back
+ this._prefBranch.removeObserver("enable", this);
+ this.prefEnabled = enabled;
+ this._prefBranch.addObserver("enable", this);
+ },
+
+ /**
+ * This object gets notifications for new/moved/copied/deleted messages/folders
+ */
+ _msgFolderListener: {
+ msgAdded(aMsg) {
+ SearchIntegration._log.info("in msgAdded");
+ // The message already being there is an expected case
+ let file = SearchIntegration._getSupportFile(aMsg);
+ if (!file.exists()) {
+ SearchIntegration._queueMessage(
+ aMsg,
+ SearchIntegration._getLastReindexTime(aMsg.folder)
+ );
+ }
+ },
+
+ msgsDeleted(aMsgs) {
+ SearchIntegration._log.info("in msgsDeleted");
+ for (let msgHdr of aMsgs) {
+ let file = SearchIntegration._getSupportFile(msgHdr);
+ if (file.exists()) {
+ file.remove(false);
+ }
+ }
+ },
+
+ msgsMoveCopyCompleted(aMove, aSrcMsgs, aDestFolder) {
+ SearchIntegration._log.info("in msgsMoveCopyCompleted, aMove = " + aMove);
+ // Forget about copies if disabled
+ if (!aMove && !this.enabled) {
+ return;
+ }
+
+ let count = aSrcMsgs.length;
+ for (let i = 0; i < count; i++) {
+ let srcFile = SearchIntegration._getSupportFile(aSrcMsgs[i]);
+ if (srcFile && srcFile.exists()) {
+ let destFile = SearchIntegration._getSearchPathForFolder(aDestFolder);
+ destFile.leafName = destFile.leafName + ".mozmsgs";
+ if (!destFile.exists()) {
+ try {
+ // create the directory, if it doesn't exist
+ destFile.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ } catch (ex) {
+ SearchIntegration._log.warn(ex);
+ }
+ }
+ SearchIntegration._log.debug("dst file path = " + destFile.path);
+ SearchIntegration._log.debug("src file path = " + srcFile.path);
+ // We're not going to copy in case we're not in active mode
+ if (destFile.exists()) {
+ if (aMove) {
+ srcFile.moveTo(destFile, "");
+ } else {
+ srcFile.copyTo(destFile, "");
+ }
+ }
+ }
+ }
+ },
+
+ folderDeleted(aFolder) {
+ SearchIntegration._log.info(
+ "in folderDeleted, folder name = " + aFolder.prettyName
+ );
+ let srcFile = SearchIntegration._getSearchPathForFolder(aFolder);
+ srcFile.leafName = srcFile.leafName + ".mozmsgs";
+ if (srcFile.exists()) {
+ srcFile.remove(true);
+ }
+ },
+
+ folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) {
+ SearchIntegration._log.info(
+ "in folderMoveCopyCompleted, aMove = " + aMove
+ );
+
+ // Forget about copies if disabled
+ if (!aMove && !this.enabled) {
+ return;
+ }
+
+ let srcFile = SearchIntegration._getSearchPathForFolder(aSrcFolder);
+ let destFile = SearchIntegration._getSearchPathForFolder(aDestFolder);
+ srcFile.leafName = srcFile.leafName + ".mozmsgs";
+ destFile.leafName += ".sbd";
+ SearchIntegration._log.debug("src file path = " + srcFile.path);
+ SearchIntegration._log.debug("dst file path = " + destFile.path);
+ if (srcFile.exists()) {
+ // We're not going to copy if we aren't in active mode
+ if (aMove) {
+ srcFile.moveTo(destFile, "");
+ } else {
+ srcFile.copyTo(destFile, "");
+ }
+ }
+ },
+
+ folderRenamed(aOrigFolder, aNewFolder) {
+ SearchIntegration._log.info(
+ "in folderRenamed, aOrigFolder = " +
+ aOrigFolder.prettyName +
+ ", aNewFolder = " +
+ aNewFolder.prettyName
+ );
+ let srcFile = SearchIntegration._getSearchPathForFolder(aOrigFolder);
+ srcFile.leafName = srcFile.leafName + ".mozmsgs";
+ let destName = aNewFolder.name + ".mozmsgs";
+ SearchIntegration._log.debug("src file path = " + srcFile.path);
+ SearchIntegration._log.debug("dst name = " + destName);
+ if (srcFile.exists()) {
+ srcFile.moveTo(null, destName);
+ }
+ },
+ },
+
+ /*
+ * Support functions to queue/generate files
+ */
+ _queueMessage(msgHdr, reindexTime) {
+ if (this._msgHdrsToIndex.push([msgHdr, reindexTime]) == 1) {
+ this._log.info("generating support file for id = " + msgHdr.messageId);
+ this._streamListener.startStreaming(msgHdr, reindexTime);
+ } else {
+ this._log.info(
+ "queueing support file generation for id = " + msgHdr.messageId
+ );
+ }
+ },
+
+ /**
+ * Handle results from the command line. This method is the inverse of the
+ * _getSupportFile method below.
+ *
+ * @param aFile the file passed in by the command line
+ * @returns the nsIMsgDBHdr corresponding to the file passed in
+ */
+ handleResult(aFile) {
+ // The file path has two components -- the search path, which needs to be
+ // converted into a folder, and the message ID.
+ let searchPath = aFile.parent;
+ // Strip off ".mozmsgs" from the end (8 characters)
+ searchPath.leafName = searchPath.leafName.slice(0, -8);
+
+ let folder = this._getFolderForSearchPath(searchPath);
+
+ // Get rid of the file extension at the end (7 characters), and unescape
+ let messageID = decodeURIComponent(aFile.leafName.slice(0, -7));
+
+ // Look for the message ID in the folder
+ return folder.msgDatabase.getMsgHdrForMessageID(messageID);
+ },
+
+ _getSupportFile(msgHdr) {
+ let folder = msgHdr.folder;
+ if (folder) {
+ let messageId = encodeURIComponent(msgHdr.messageId);
+ this._log.debug("encoded message id = " + messageId);
+ let file = this._getSearchPathForFolder(folder);
+ file.leafName = file.leafName + ".mozmsgs";
+ file.appendRelativePath(messageId + this._fileExt);
+ this._log.debug("getting support file path = " + file.path);
+ return file;
+ }
+ return null;
+ },
+
+ /**
+ * Base to use for stream listeners, extended by the respective
+ * implementations
+ */
+ _streamListenerBase: {
+ // Output file
+ _outputFile: null,
+
+ // Stream to use to write to the output file
+ __outputStream: null,
+ set _outputStream(stream) {
+ if (this.__outputStream) {
+ this.__outputStream.close();
+ }
+ this.__outputStream = stream;
+ },
+ get _outputStream() {
+ return this.__outputStream;
+ },
+
+ // Reference to message header
+ _msgHdr: null,
+
+ // Reindex time for this message header
+ _reindexTime: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ // "Finish" function, cleans up behind itself if unsuccessful
+ _onDoneStreaming(successful) {
+ this._outputStream = null;
+ if (!successful && this._msgHdr) {
+ let file = SearchIntegration._getSupportFile(this._msgHdr);
+ if (file && file.exists()) {
+ file.remove(false);
+ }
+ }
+ // should we try to delete the file on disk in case not successful?
+ SearchIntegration._msgHdrsToIndex.shift();
+
+ if (SearchIntegration._msgHdrsToIndex.length > 0) {
+ let [msgHdr, reindexTime] = SearchIntegration._msgHdrsToIndex[0];
+ this.startStreaming(msgHdr, reindexTime);
+ }
+ },
+
+ // "Start" function
+ startStreaming(msgHdr, reindexTime) {
+ try {
+ let folder = msgHdr.folder;
+ if (folder) {
+ let messageId = encodeURIComponent(msgHdr.messageId);
+ SearchIntegration._log.info(
+ "generating support file, id = " + messageId
+ );
+ let file = SearchIntegration._getSearchPathForFolder(folder);
+
+ file.leafName = file.leafName + ".mozmsgs";
+ SearchIntegration._log.debug("file leafname = " + file.leafName);
+ if (!file.exists()) {
+ try {
+ // create the directory, if it doesn't exist
+ file.create(Ci.nsIFile.DIRECTORY_TYPE, PERM_DIRECTORY);
+ } catch (ex) {
+ this._log.error(ex);
+ }
+ }
+
+ file.appendRelativePath(messageId + SearchIntegration._fileExt);
+ SearchIntegration._log.debug("file path = " + file.path);
+ file.create(0, PERM_FILE);
+ let uri = folder.getUriForMsg(msgHdr);
+ let msgService = MailServices.messageServiceFromURI(uri);
+ this._msgHdr = msgHdr;
+ this._outputFile = file;
+ this._reindexTime = reindexTime;
+ try {
+ // XXX For now, try getting the messages from the server. This has
+ // to be improved so that we don't generate any excess network
+ // traffic
+ msgService.streamMessage(uri, this, null, null, false, "", false);
+ } catch (ex) {
+ // This is an expected case, in case we're offline
+ SearchIntegration._log.warn(
+ "StreamMessage unsuccessful for id = " + messageId
+ );
+ this._onDoneStreaming(false);
+ }
+ }
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ }
+ },
+ },
+
+ /**
+ * Logging functionality, shamelessly ripped from gloda
+ * If enabled, warnings and above are logged to the error console, while dump
+ * gets everything
+ */
+ _log: null,
+ _initLogging() {
+ this._log = console.createInstance({
+ prefix: this._prefBase.slice(0, -1),
+ maxLogLevel: "Warn",
+ maxLogLevelPref: `${this._prefBase}loglevel`,
+ });
+ this._log.info("Logging initialized");
+ },
+};
+
+if (AppConstants.platform == "win") {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/WinSearchIntegration.js"
+ );
+} else if (AppConstants.platform == "macosx") {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/SpotlightIntegration.js"
+ );
+}
diff --git a/comm/mail/components/search/components.conf b/comm/mail/components/search/components.conf
new file mode 100644
index 0000000000..f34fbdc04d
--- /dev/null
+++ b/comm/mail/components/search/components.conf
@@ -0,0 +1,16 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = []
+
+if buildconfig.substs["OS_ARCH"] == "WINNT":
+ Classes += [
+ {
+ "cid": "{5dd31c99-08c7-4a3b-aeb3-d2e60665a31a}",
+ "contract_ids": ["@mozilla.org/mail/windows-search-helper;1"],
+ "type": "nsMailWinSearchHelper",
+ "init_method": "Init",
+ "headers": ["/comm/mail/components/search/nsMailWinSearchHelper.h"],
+ },
+ ]
diff --git a/comm/mail/components/search/content/SpotlightIntegration.js b/comm/mail/components/search/content/SpotlightIntegration.js
new file mode 100644
index 0000000000..0757800ee6
--- /dev/null
+++ b/comm/mail/components/search/content/SpotlightIntegration.js
@@ -0,0 +1,240 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// SearchIntegration.jsm
+/* globals SearchIntegration, SearchSupport, Services */
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var MSG_DB_LARGE_COMMIT = 1;
+var gFileHeader =
+ '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.\ncom/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>';
+
+// eslint-disable-next-line no-global-assign
+SearchIntegration = {
+ __proto__: SearchSupport,
+
+ // The property of the header and (sometimes) folders that's used to check
+ // if a message is indexed
+ _hdrIndexedProperty: "spotlight_reindex_time",
+
+ // The file extension that is used for support files of this component
+ _fileExt: ".mozeml",
+
+ // The Spotlight pref base
+ _prefBase: "mail.spotlight.",
+
+ // The user's profile dir, which we'll cache and use a lot for path clean-up
+ get _profileDir() {
+ delete this._profileDir;
+ return (this._profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile));
+ },
+
+ get _metadataDir() {
+ delete this._metadataDir;
+ let metadataDir = Services.dirsvc.get("Home", Ci.nsIFile);
+ metadataDir.append("Library");
+ metadataDir.append("Caches");
+ metadataDir.append("Metadata");
+ metadataDir.append("Thunderbird");
+ return (this._metadataDir = metadataDir);
+ },
+
+ // Spotlight won't index files in the profile dir, but will use ~/Library/Caches/Metadata
+ _getSearchPathForFolder(aFolder) {
+ // Swap the metadata dir for the profile dir prefix in the folder's path
+ let folderPath = aFolder.filePath.path;
+ let fixedPath = folderPath.replace(
+ this._profileDir.path,
+ this._metadataDir.path
+ );
+ let searchPath = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ searchPath.initWithPath(fixedPath);
+ return searchPath;
+ },
+
+ // Replace ~/Library/Caches/Metadata with the profile directory, then convert
+ _getFolderForSearchPath(aPath) {
+ let folderPath = aPath.path.replace(
+ this._metadataDir.path,
+ this._profileDir.path
+ );
+ let folderFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ folderFile.initWithPath(folderPath);
+ return MailUtils.getFolderForFileInProfile(folderFile);
+ },
+
+ _pathNeedsReindexing(aPath) {
+ // We used to set permissions incorrectly (see bug 670566).
+ const PERM_DIRECTORY = parseInt("0755", 8);
+ if (aPath.permissions != PERM_DIRECTORY) {
+ aPath.permissions = PERM_DIRECTORY;
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * These two functions won't do anything, as Spotlight integration is handled
+ * using Info.plist files
+ */
+ register() {
+ return true;
+ },
+
+ deregister() {
+ return true;
+ },
+
+ _init() {
+ this._initLogging();
+
+ let enabled = this._prefBranch.getBoolPref("enable", false);
+ if (enabled) {
+ this._log.info("Initializing Spotlight integration");
+ }
+ this._initSupport(enabled);
+ },
+
+ // The stream listener to read messages
+ _streamListener: {
+ __proto__: SearchSupport._streamListenerBase,
+
+ // Buffer to store the message
+ _message: null,
+
+ // Encodes reserved XML characters
+ _xmlEscapeString(s) {
+ return s.replace(/[<>&]/g, function (s) {
+ switch (s) {
+ case "<":
+ return "&lt;";
+ case ">":
+ return "&gt;";
+ case "&":
+ return "&amp;";
+ default:
+ throw new Error("Unexpected match");
+ }
+ });
+ },
+
+ onStartRequest(request) {
+ try {
+ let outputFileStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputFileStream.init(this._outputFile, -1, -1, 0);
+ this._outputStream = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ this._outputStream.init(outputFileStream, "UTF-8");
+
+ this._outputStream.writeString(gFileHeader);
+ this._outputStream.writeString(
+ "<key>kMDItemLastUsedDate</key><string>"
+ );
+ // need to write the date as a string
+ let curTimeStr = new Date().toLocaleString();
+ this._outputStream.writeString(curTimeStr);
+
+ // need to write the subject in utf8 as the title
+ this._outputStream.writeString(
+ "</string>\n<key>kMDItemTitle</key>\n<string>"
+ );
+
+ let escapedSubject = this._xmlEscapeString(
+ this._msgHdr.mime2DecodedSubject
+ );
+ this._outputStream.writeString(escapedSubject);
+
+ this._outputStream.writeString(
+ "</string>\n<key>kMDItemDisplayName</key>\n<string>"
+ );
+ this._outputStream.writeString(escapedSubject);
+
+ this._outputStream.writeString(
+ "</string>\n<key>kMDItemTextContent</key>\n<string>"
+ );
+ this._outputStream.writeString(
+ this._xmlEscapeString(this._msgHdr.mime2DecodedAuthor)
+ );
+ this._outputStream.writeString(
+ this._xmlEscapeString(this._msgHdr.mime2DecodedRecipients)
+ );
+
+ this._outputStream.writeString(escapedSubject);
+ this._outputStream.writeString(" ");
+ } catch (ex) {
+ this._onDoneStreaming(false);
+ }
+ },
+
+ onStopRequest(request, status) {
+ try {
+ // we want to write out the from, to, cc, and subject headers into the
+ // Text Content value, so they'll be indexed.
+ let stringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stringStream.setData(this._message, this._message.length);
+ let folder = this._msgHdr.folder;
+ let text = folder.getMsgTextFromStream(
+ stringStream,
+ this._msgHdr.charset,
+ 20000,
+ 20000,
+ false,
+ true,
+ {}
+ );
+ text = this._xmlEscapeString(text);
+ SearchIntegration._log.debug(
+ "escaped text = *****************\n" + text
+ );
+ this._outputStream.writeString(text);
+ // close out the content, dict, and plist
+ this._outputStream.writeString("</string>\n</dict>\n</plist>\n");
+
+ this._msgHdr.setUint32Property(
+ SearchIntegration._hdrIndexedProperty,
+ this._reindexTime
+ );
+ folder.msgDatabase.commit(MSG_DB_LARGE_COMMIT);
+
+ this._message = "";
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ return;
+ }
+ this._onDoneStreaming(true);
+ },
+
+ onDataAvailable(request, inputStream, offset, count) {
+ try {
+ let inStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ inStream.init(inputStream);
+
+ // It is necessary to read in data from the input stream
+ let inData = inStream.read(count);
+
+ // ignore stuff after the first 20K or so
+ if (this._message && this._message.length > 20000) {
+ return;
+ }
+
+ this._message += inData;
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ }
+ },
+ },
+};
+
+SearchIntegration._init();
diff --git a/comm/mail/components/search/content/WinSearchIntegration.js b/comm/mail/components/search/content/WinSearchIntegration.js
new file mode 100644
index 0000000000..2f4c51b0e3
--- /dev/null
+++ b/comm/mail/components/search/content/WinSearchIntegration.js
@@ -0,0 +1,346 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// SearchIntegration.jsm
+/* globals SearchIntegration, SearchSupport, Services */
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+var MSG_DB_LARGE_COMMIT = 1;
+var CRLF = "\r\n";
+
+/**
+ * Required to access the 64-bit registry, even though we're probably a 32-bit
+ * program
+ */
+var ACCESS_WOW64_64KEY = 0x0100;
+
+/**
+ * The contract ID for the helper service.
+ */
+var WINSEARCHHELPER_CONTRACTID = "@mozilla.org/mail/windows-search-helper;1";
+
+/**
+ * All the registry keys required for integration
+ */
+var gRegKeys = [
+ // This is the property handler
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ key: "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\PropertySystem\\PropertyHandlers\\.wdseml",
+ name: "",
+ value: "{5FA29220-36A1-40f9-89C6-F4B384B7642E}",
+ },
+ // These two are the association with the MIME IFilter
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ key: ".wdseml",
+ name: "Content Type",
+ value: "message/rfc822",
+ },
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ key: ".wdseml\\PersistentHandler",
+ name: "",
+ value: "{5645c8c4-e277-11cf-8fda-00aa00a14f93}",
+ },
+ // This is the association with the Windows mail preview handler
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT,
+ key: ".wdseml\\shellex\\{8895B1C6-B41F-4C1C-A562-0D564250836F}",
+ name: "",
+ value: "{b9815375-5d7f-4ce2-9245-c9d4da436930}",
+ },
+ // This is the association made to display results under email
+ {
+ root: Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ key: "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\explorer\\KindMap",
+ name: ".wdseml",
+ value: "email;communication",
+ },
+];
+
+/**
+ * @namespace Windows Search-specific desktop search integration functionality
+ */
+// eslint-disable-next-line no-global-assign
+SearchIntegration = {
+ __proto__: SearchSupport,
+
+ // The property of the header and (sometimes) folders that's used to check
+ // if a message is indexed
+ _hdrIndexedProperty: "winsearch_reindex_time",
+
+ // The file extension that is used for support files of this component
+ _fileExt: ".wdseml",
+
+ // The Windows Search pref base
+ _prefBase: "mail.winsearch.",
+
+ // Helper (native) component
+ __winSearchHelper: null,
+ get _winSearchHelper() {
+ if (!this.__winSearchHelper) {
+ this.__winSearchHelper = Cc[WINSEARCHHELPER_CONTRACTID].getService(
+ Ci.nsIMailWinSearchHelper
+ );
+ }
+ return this.__winSearchHelper;
+ },
+
+ // Whether the folders are already in the crawl scope
+ get _foldersInCrawlScope() {
+ return this._winSearchHelper.foldersInCrawlScope;
+ },
+
+ /**
+ * Whether all the required registry keys are present
+ * We'll be optimistic here and assume that once the registry keys have been
+ * added, they won't be removed, at least while Thunderbird is open
+ */
+ __regKeysPresent: false,
+ get _regKeysPresent() {
+ if (!this.__regKeysPresent) {
+ for (let i = 0; i < gRegKeys.length; i++) {
+ let regKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ regKey.open(
+ gRegKeys[i].root,
+ gRegKeys[i].key,
+ regKey.ACCESS_READ | ACCESS_WOW64_64KEY
+ );
+ } catch (e) {
+ return false;
+ }
+ let valuePresent =
+ regKey.hasValue(gRegKeys[i].name) &&
+ regKey.readStringValue(gRegKeys[i].name) == gRegKeys[i].value;
+ regKey.close();
+ if (!valuePresent) {
+ return false;
+ }
+ }
+ this.__regKeysPresent = true;
+ }
+ return true;
+ },
+
+ // Use the folder's path (i.e., in profile dir) as is
+ _getSearchPathForFolder(aFolder) {
+ return aFolder.filePath;
+ },
+
+ // Use the search path as is
+ _getFolderForSearchPath(aDir) {
+ return MailUtils.getFolderForFileInProfile(aDir);
+ },
+
+ _pathNeedsReindexing(aPath) {
+ // only needed on MacOSX (see bug 670566).
+ return false;
+ },
+
+ _init() {
+ this._initLogging();
+ // If the helper service isn't present, we weren't compiled with the needed
+ // support. Mark ourselves null and return
+ if (!(WINSEARCHHELPER_CONTRACTID in Cc)) {
+ SearchIntegration = null; // eslint-disable-line no-global-assign
+ return;
+ }
+
+ // The search module is currently only enabled on Vista and above,
+ // and the app can only be installed on Windows 7 and above.
+ this.osVersionTooLow = false;
+
+ let serviceRunning = false;
+ try {
+ serviceRunning = this._winSearchHelper.serviceRunning;
+ } catch (e) {}
+ // If the service isn't running, then we should stay in backoff mode
+ if (!serviceRunning) {
+ this._log.info("Windows Search service not running");
+ this.osComponentsNotRunning = true;
+ this._initSupport(false);
+ return;
+ }
+
+ let enabled = this.prefEnabled;
+
+ if (enabled) {
+ this._log.info("Initializing Windows Search integration");
+ }
+ this._initSupport(enabled);
+ },
+
+ /**
+ * Add necessary hooks to Windows
+ *
+ * @returns false if registration did not succeed, because the elevation
+ * request was denied
+ */
+ register() {
+ // If any of the two are not present, we need to elevate.
+ if (!this._foldersInCrawlScope || !this._regKeysPresent) {
+ try {
+ this._winSearchHelper.runSetup(true);
+ } catch (e) {
+ return false;
+ }
+ }
+
+ if (!this._winSearchHelper.isFileAssociationSet) {
+ try {
+ this._winSearchHelper.setFileAssociation();
+ } catch (e) {
+ this._log.warn("File association not set");
+ }
+ }
+ // Also set the FANCI bit to 0 for the profile directory
+ let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ this._winSearchHelper.setFANCIBit(profD, false, true);
+
+ return true;
+ },
+
+ /**
+ * Remove integration from Windows. The only thing removed is the directory
+ * from the index list. This will ask for elevation.
+ *
+ * @returns false if deregistration did not succeed, because the elevation
+ * request was denied
+ */
+ deregister() {
+ try {
+ this._winSearchHelper.runSetup(false);
+ } catch (e) {
+ return false;
+ }
+
+ return true;
+ },
+
+ // The stream listener to read messages
+ _streamListener: {
+ __proto__: SearchSupport._streamListenerBase,
+
+ // Buffer to store the message
+ _message: "",
+
+ onStartRequest(request) {
+ try {
+ let outputFileStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputFileStream.init(this._outputFile, -1, -1, 0);
+ this._outputStream = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ this._outputStream.init(outputFileStream, "UTF-8");
+ } catch (ex) {
+ this._onDoneStreaming(false);
+ }
+ },
+
+ onStopRequest(request, status) {
+ try {
+ // XXX Once the JS emitter gets checked in, this code should probably be
+ // switched over to use that
+ // Decode using getMsgTextFromStream
+ let stringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stringStream.setData(this._message, this._message.length);
+ let contentType = {};
+ let folder = this._msgHdr.folder;
+ let text = folder.getMsgTextFromStream(
+ stringStream,
+ this._msgHdr.charset,
+ 65536,
+ 50000,
+ false,
+ false,
+ contentType
+ );
+
+ // To get the Received header, we need to parse the message headers.
+ // We only need the first header, which contains the latest received
+ // date
+ let headers = this._message.split(/\r\n\r\n|\r\r|\n\n/, 1)[0];
+ let mimeHeaders = Cc[
+ "@mozilla.org/messenger/mimeheaders;1"
+ ].createInstance(Ci.nsIMimeHeaders);
+ mimeHeaders.initialize(headers);
+ let receivedHeader = mimeHeaders.extractHeader("Received", false);
+
+ this._outputStream.writeString("From: " + this._msgHdr.author + CRLF);
+ // If we're a newsgroup, then add the name of the folder as the
+ // newsgroups header
+ if (folder instanceof Ci.nsIMsgNewsFolder) {
+ this._outputStream.writeString("Newsgroups: " + folder.name + CRLF);
+ } else {
+ this._outputStream.writeString(
+ "To: " + this._msgHdr.recipients + CRLF
+ );
+ }
+ this._outputStream.writeString("CC: " + this._msgHdr.ccList + CRLF);
+ this._outputStream.writeString(
+ "Subject: " + this._msgHdr.subject + CRLF
+ );
+ if (receivedHeader) {
+ this._outputStream.writeString("Received: " + receivedHeader + CRLF);
+ }
+ this._outputStream.writeString(
+ "Date: " + new Date(this._msgHdr.date / 1000).toUTCString() + CRLF
+ );
+ this._outputStream.writeString(
+ "Content-Type: " + contentType.value + "; charset=utf-8" + CRLF + CRLF
+ );
+
+ this._outputStream.writeString(text + CRLF + CRLF);
+
+ this._msgHdr.setUint32Property(
+ SearchIntegration._hdrIndexedProperty,
+ this._reindexTime
+ );
+ folder.msgDatabase.commit(MSG_DB_LARGE_COMMIT);
+
+ this._message = "";
+ SearchIntegration._log.info("Successfully written file");
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ return;
+ }
+ this._onDoneStreaming(true);
+ },
+
+ onDataAvailable(request, inputStream, offset, count) {
+ try {
+ let inStream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ inStream.init(inputStream);
+
+ // It is necessary to read in data from the input stream
+ let inData = inStream.read(count);
+
+ // Ignore stuff after the first 50K or so
+ if (this._message && this._message.length > 50000) {
+ return;
+ }
+
+ this._message += inData;
+ } catch (ex) {
+ SearchIntegration._log.error(ex);
+ this._onDoneStreaming(false);
+ }
+ },
+ },
+};
+
+SearchIntegration._init();
diff --git a/comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.ico b/comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.ico
new file mode 100644
index 0000000000..3917eaec10
--- /dev/null
+++ b/comm/mail/components/search/extensions/allaannonser-sv-SE/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json b/comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json
new file mode 100644
index 0000000000..a5b5914634
--- /dev/null
+++ b/comm/mail/components/search/extensions/allaannonser-sv-SE/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Allaannonser",
+ "description": "Allaannonser",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "allaannonser-sv-SE@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Allaannonser",
+ "encoding": "ISO-8859-1",
+ "search_url": "https://www.allaannonser.se/hitlist.php",
+ "search_form": "https://www.allaannonser.se",
+ "search_url_get_params": "sourceid=Mozilla-search&keyword={searchTerms}&order=date&desc=1"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/allegro-pl/favicon.ico b/comm/mail/components/search/extensions/allegro-pl/favicon.ico
new file mode 100644
index 0000000000..42b4f90149
--- /dev/null
+++ b/comm/mail/components/search/extensions/allegro-pl/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/allegro-pl/manifest.json b/comm/mail/components/search/extensions/allegro-pl/manifest.json
new file mode 100644
index 0000000000..ad38f187dc
--- /dev/null
+++ b/comm/mail/components/search/extensions/allegro-pl/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Allegro",
+ "description": "Wyszukiwanie w aukcjach Allegro",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "allegro-pl@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Allegro",
+ "search_url": "https://allegro.pl/listing/listing.php",
+ "search_form": "https://allegro.pl",
+ "search_url_get_params": "string={searchTerms}&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/au/messages.json b/comm/mail/components/search/extensions/amazon/_locales/au/messages.json
new file mode 100644
index 0000000000..c8fbcbcb69
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/au/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.com.au"
+ },
+ "extensionDescription": {
+ "message": "Amazon.com.au Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.com.au/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.com.au/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/ca/messages.json b/comm/mail/components/search/extensions/amazon/_locales/ca/messages.json
new file mode 100644
index 0000000000..cb54e55658
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/ca/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.ca"
+ },
+ "extensionDescription": {
+ "message": "Amazon.ca Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.ca/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.ca/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/de/messages.json b/comm/mail/components/search/extensions/amazon/_locales/de/messages.json
new file mode 100644
index 0000000000..e9eebaf229
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/de/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.de"
+ },
+ "extensionDescription": {
+ "message": "Amazon.de Suche"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.de/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.de/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json b/comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json
new file mode 100644
index 0000000000..596283dd0d
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/en-GB/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.co.uk"
+ },
+ "extensionDescription": {
+ "message": "Amazon.co.uk Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.co.uk/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.co.uk/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/france/messages.json b/comm/mail/components/search/extensions/amazon/_locales/france/messages.json
new file mode 100644
index 0000000000..77730b1a40
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/france/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.fr"
+ },
+ "extensionDescription": {
+ "message": "Recherche Amazon.fr"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.fr/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.fr/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/in/messages.json b/comm/mail/components/search/extensions/amazon/_locales/in/messages.json
new file mode 100644
index 0000000000..d4f912cc96
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/in/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.in"
+ },
+ "extensionDescription": {
+ "message": "Amazon.in Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.in/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.in/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/it/messages.json b/comm/mail/components/search/extensions/amazon/_locales/it/messages.json
new file mode 100644
index 0000000000..07382eec95
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/it/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.it"
+ },
+ "extensionDescription": {
+ "message": "Ricerca Amazon.it"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.it/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.it/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/jp/messages.json b/comm/mail/components/search/extensions/amazon/_locales/jp/messages.json
new file mode 100644
index 0000000000..b3bebb43c2
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/jp/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.co.jp"
+ },
+ "extensionDescription": {
+ "message": "Amazon.co.jp Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.co.jp/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.co.jp/"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/mx/messages.json b/comm/mail/components/search/extensions/amazon/_locales/mx/messages.json
new file mode 100644
index 0000000000..a70ed76634
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/mx/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.com.mx"
+ },
+ "extensionDescription": {
+ "message": "Amazon.com.mx Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.com.mx/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.com.mx/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/_locales/nl/messages.json b/comm/mail/components/search/extensions/amazon/_locales/nl/messages.json
new file mode 100644
index 0000000000..5d5e62e637
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/_locales/nl/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Amazon.nl"
+ },
+ "extensionDescription": {
+ "message": "Amazon.nl Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.nl/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.nl/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazon/favicon.ico b/comm/mail/components/search/extensions/amazon/favicon.ico
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/amazon/manifest.json b/comm/mail/components/search/extensions/amazon/manifest.json
new file mode 100644
index 0000000000..fd44cf29cb
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazon/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.1",
+ "applications": {
+ "gecko": {
+ "id": "amazon@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "au",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazondotcn/favicon.ico b/comm/mail/components/search/extensions/amazondotcn/favicon.ico
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcn/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/amazondotcn/manifest.json b/comm/mail/components/search/extensions/amazondotcn/manifest.json
new file mode 100644
index 0000000000..e6215d9660
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcn/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "亚马逊",
+ "description": "亚马逊搜索",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "amazondotcn@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "亚马逊",
+ "search_url": "https://www.amazon.cn/mn/searchApp",
+ "search_form": "https://www.amazon.cn/",
+ "search_url_get_params": "keywords={searchTerms}&ix=sunray&pageletid=headsearch&searchType=&Go.x=0&Go.y=0&bestSaleNum=0"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json b/comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json
new file mode 100644
index 0000000000..e1f3405dab
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcom/_locales/en/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Amazon.com"
+ },
+ "extensionDescription": {
+ "message": "Amazon.com Search"
+ },
+ "searchUrl": {
+ "message": "https://www.amazon.com/exec/obidos/external-search/"
+ },
+ "searchForm": {
+ "message": "https://www.amazon.com/exec/obidos/external-search/?field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://completion.amazon.com/search/complete?q={searchTerms}&search-alias=aps&mkt=1"
+ },
+ "searchUrlGetParams": {
+ "message": "field-keywords={searchTerms}&ie={inputEncoding}&mode=blended&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/amazondotcom/favicon.ico b/comm/mail/components/search/extensions/amazondotcom/favicon.ico
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcom/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/amazondotcom/manifest.json b/comm/mail/components/search/extensions/amazondotcom/manifest.json
new file mode 100644
index 0000000000..5def9f413d
--- /dev/null
+++ b/comm/mail/components/search/extensions/amazondotcom/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.1",
+ "applications": {
+ "gecko": {
+ "id": "amazondotcom@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "en",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/atlas-sk/favicon.ico b/comm/mail/components/search/extensions/atlas-sk/favicon.ico
new file mode 100644
index 0000000000..eb4d3ec31a
--- /dev/null
+++ b/comm/mail/components/search/extensions/atlas-sk/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/atlas-sk/manifest.json b/comm/mail/components/search/extensions/atlas-sk/manifest.json
new file mode 100644
index 0000000000..774c021f91
--- /dev/null
+++ b/comm/mail/components/search/extensions/atlas-sk/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Atlas",
+ "description": "Internetovy portal - Atlas.sk",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "atlas-sk@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Atlas",
+ "search_url": "https://www.atlas.sk/search.php",
+ "search_form": "https://www.atlas.sk/",
+ "search_url_get_params": "phrase={searchTerms}&sourceid=firefox"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/azerdict/favicon.ico b/comm/mail/components/search/extensions/azerdict/favicon.ico
new file mode 100644
index 0000000000..ba687ca8e7
--- /dev/null
+++ b/comm/mail/components/search/extensions/azerdict/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/azerdict/manifest.json b/comm/mail/components/search/extensions/azerdict/manifest.json
new file mode 100644
index 0000000000..9454d14600
--- /dev/null
+++ b/comm/mail/components/search/extensions/azerdict/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Azerdict",
+ "description": "Azərbaycanın Online Lüğəti",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "azerdict@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Azerdict",
+ "search_url": "https://azerdict.com/english/",
+ "search_form": "https://azerdict.com/",
+ "search_url_get_params": "word={searchTerms}",
+ "suggest_url": "https://api.azerdict.com/english/autocomplete",
+ "suggest_url_get_params": "action=opensearch&query={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/azet-sk/favicon.ico b/comm/mail/components/search/extensions/azet-sk/favicon.ico
new file mode 100644
index 0000000000..39ab78bbd9
--- /dev/null
+++ b/comm/mail/components/search/extensions/azet-sk/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/azet-sk/manifest.json b/comm/mail/components/search/extensions/azet-sk/manifest.json
new file mode 100644
index 0000000000..84b6faa38d
--- /dev/null
+++ b/comm/mail/components/search/extensions/azet-sk/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Azet",
+ "description": "Azet - portal, kde je vzdy najviac ludi",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "azet-sk@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Azet",
+ "search_url": "https://www.azet.sk/katalog/vyhladavanie/firmy/",
+ "search_form": "https://www.azet.sk/katalog/",
+ "search_url_get_params": "q={searchTerms}&k="
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/baidu/favicon.ico b/comm/mail/components/search/extensions/baidu/favicon.ico
new file mode 100644
index 0000000000..6c27b018c8
--- /dev/null
+++ b/comm/mail/components/search/extensions/baidu/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/baidu/manifest.json b/comm/mail/components/search/extensions/baidu/manifest.json
new file mode 100644
index 0000000000..a53f905716
--- /dev/null
+++ b/comm/mail/components/search/extensions/baidu/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "百度",
+ "description": "百度网页搜索",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "baidu@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "百度",
+ "search_url": "https://www.baidu.com/baidu",
+ "search_form": "https://www.baidu.com/",
+ "search_url_get_params": "wd={searchTerms}&ie=utf-8",
+ "suggest_url": "https://www.baidu.com/su",
+ "suggest_url_get_params": "wd={searchTerms}&ie=utf-8&action=opensearch"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bbc-alba/favicon.ico b/comm/mail/components/search/extensions/bbc-alba/favicon.ico
new file mode 100644
index 0000000000..8f62b07af8
--- /dev/null
+++ b/comm/mail/components/search/extensions/bbc-alba/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/bbc-alba/manifest.json b/comm/mail/components/search/extensions/bbc-alba/manifest.json
new file mode 100644
index 0000000000..6de91c53c9
--- /dev/null
+++ b/comm/mail/components/search/extensions/bbc-alba/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "BBC ┐ BBC Alba",
+ "description": "Lorg BBC ┐ BBC Alba",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bbc-alba@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "BBC ┐ BBC Alba",
+ "search_url": "https://search.bbc.co.uk/search",
+ "search_form": "https://www.bbc.co.uk/alba/",
+ "search_url_get_params": "opensearch=all-1&q={searchTerms}",
+ "suggest_url": "https://search.bbc.co.uk/suggest",
+ "suggest_url_get_params": "format=opensearch&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bing/favicon.ico b/comm/mail/components/search/extensions/bing/favicon.ico
new file mode 100644
index 0000000000..1e90a10d6e
--- /dev/null
+++ b/comm/mail/components/search/extensions/bing/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/bing/manifest.json b/comm/mail/components/search/extensions/bing/manifest.json
new file mode 100644
index 0000000000..bc0a1060ab
--- /dev/null
+++ b/comm/mail/components/search/extensions/bing/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Bing",
+ "description": "Bing. Search by Microsoft.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bing@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Bing",
+ "search_url": "https://www.bing.com/search",
+ "search_form": "https://www.bing.com/search?q={searchTerms}",
+ "search_url_get_params": "q={searchTerms}",
+ "suggest_url": "https://www.bing.com/osjson.aspx",
+ "suggest_url_get_params": "query={searchTerms}&form=OSDJAS&language={moz:locale}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bok-NO/favicon.png b/comm/mail/components/search/extensions/bok-NO/favicon.png
new file mode 100644
index 0000000000..c2d46117ef
--- /dev/null
+++ b/comm/mail/components/search/extensions/bok-NO/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/bok-NO/manifest.json b/comm/mail/components/search/extensions/bok-NO/manifest.json
new file mode 100644
index 0000000000..fb0138103b
--- /dev/null
+++ b/comm/mail/components/search/extensions/bok-NO/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Ordbok",
+ "description": "Norske ordbøker",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bok-NO@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Ordbok",
+ "search_url": "https://ordbok.uib.no/perl/ordbok.cgi",
+ "search_form": "https://ordbok.uib.no/",
+ "search_url_get_params": "OPP={searchTerms}&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json b/comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json
new file mode 100644
index 0000000000..252afad896
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/_locales/fy-NL/messages.json
@@ -0,0 +1,14 @@
+{
+ "extensionName": {
+ "message": "bol.com"
+ },
+ "extensionDescription": {
+ "message": "Sykje by bol.com"
+ },
+ "searchUrl": {
+ "message": "https://www.bol.com/nl/s/algemeen/zoekresultaten/Ntt/{searchTerms}/Ntk/media_all/Nty/1/suggestedFor/{searchTerms}/N/0/Ne/0/search/true/searchType/qck/index.html"
+ },
+ "searchForm": {
+ "message": "https://www.bol.com/"
+ }
+}
diff --git a/comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json b/comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json
new file mode 100644
index 0000000000..7e9baa8b3b
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/_locales/nl/messages.json
@@ -0,0 +1,14 @@
+{
+ "extensionName": {
+ "message": "bol.com"
+ },
+ "extensionDescription": {
+ "message": "Zoeken bij bol.com"
+ },
+ "searchUrl": {
+ "message": "https://www.bol.com/nl/s/algemeen/zoekresultaten/Ntt/{searchTerms}/Ntk/media_all/Nty/1/suggestedFor/{searchTerms}/N/0/Ne/0/search/true/searchType/qck/index.html"
+ },
+ "searchForm": {
+ "message": "https://www.bol.com/"
+ }
+}
diff --git a/comm/mail/components/search/extensions/bolcom/favicon.ico b/comm/mail/components/search/extensions/bolcom/favicon.ico
new file mode 100644
index 0000000000..0f0db9b990
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/bolcom/manifest.json b/comm/mail/components/search/extensions/bolcom/manifest.json
new file mode 100644
index 0000000000..25a08232e7
--- /dev/null
+++ b/comm/mail/components/search/extensions/bolcom/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "bolcom@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "fy-NL",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ceneji/favicon.png b/comm/mail/components/search/extensions/ceneji/favicon.png
new file mode 100644
index 0000000000..3c77b64d3c
--- /dev/null
+++ b/comm/mail/components/search/extensions/ceneji/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/ceneji/manifest.json b/comm/mail/components/search/extensions/ceneji/manifest.json
new file mode 100644
index 0000000000..3c915c8b15
--- /dev/null
+++ b/comm/mail/components/search/extensions/ceneji/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Ceneje.si",
+ "description": "Iskalnik Ceneje.si",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "ceneji@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Ceneje.si",
+ "search_url": "https://www.ceneje.si/search_new.aspx",
+ "search_form": "https://www.ceneje.si",
+ "search_url_get_params": "q={searchTerms}&FF-SearchBox=1"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/chambers-en-GB/favicon.ico b/comm/mail/components/search/extensions/chambers-en-GB/favicon.ico
new file mode 100644
index 0000000000..ecea4aac74
--- /dev/null
+++ b/comm/mail/components/search/extensions/chambers-en-GB/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/chambers-en-GB/manifest.json b/comm/mail/components/search/extensions/chambers-en-GB/manifest.json
new file mode 100644
index 0000000000..17ce83d616
--- /dev/null
+++ b/comm/mail/components/search/extensions/chambers-en-GB/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Chambers (UK)",
+ "description": "Chambers 21st Century Dictionary Search",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "chambers-en-GB@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Chambers (UK)",
+ "search_url": "https://chambers.co.uk/search/",
+ "search_form": "https://chambers.co.uk/search/?query={searchTerms}&title=21st&sourceid=Mozilla-search",
+ "search_url_get_params": "query={searchTerms}&title=21st&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/coccoc/favicon.ico b/comm/mail/components/search/extensions/coccoc/favicon.ico
new file mode 100644
index 0000000000..e6e82d938e
--- /dev/null
+++ b/comm/mail/components/search/extensions/coccoc/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/coccoc/manifest.json b/comm/mail/components/search/extensions/coccoc/manifest.json
new file mode 100644
index 0000000000..790d56badd
--- /dev/null
+++ b/comm/mail/components/search/extensions/coccoc/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Cốc Cốc",
+ "description": "Use Cốc Cốc to search on coccoc.com",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "coccoc@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Cốc Cốc",
+ "search_url": "https://coccoc.com/search",
+ "search_url_get_params": "query={searchTerms}&s=ff&utm_source=firefox",
+ "suggest_url": "https://coccoc.com/composer/autocomplete",
+ "suggest_url_get_params": "of=b&q={searchTerms}&s=ff"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/daum-kr/favicon.ico b/comm/mail/components/search/extensions/daum-kr/favicon.ico
new file mode 100644
index 0000000000..ed803f50e2
--- /dev/null
+++ b/comm/mail/components/search/extensions/daum-kr/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/daum-kr/manifest.json b/comm/mail/components/search/extensions/daum-kr/manifest.json
new file mode 100644
index 0000000000..1e0946be7a
--- /dev/null
+++ b/comm/mail/components/search/extensions/daum-kr/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "다음",
+ "description": "다음 검색",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "daum-kr@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "다음",
+ "search_url": "https://search.daum.net/search",
+ "search_form": "https://search.daum.net",
+ "search_url_get_params": "q={searchTerms}&w=tot&nil_ch=ffsr",
+ "suggest_url": "https://sug.search.daum.net/search_nsuggest",
+ "suggest_url_get_params": "mod=fxjson&code=utf_in_out&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ddg/favicon.ico b/comm/mail/components/search/extensions/ddg/favicon.ico
new file mode 100644
index 0000000000..dda80dfd88
--- /dev/null
+++ b/comm/mail/components/search/extensions/ddg/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/ddg/manifest.json b/comm/mail/components/search/extensions/ddg/manifest.json
new file mode 100644
index 0000000000..402a305684
--- /dev/null
+++ b/comm/mail/components/search/extensions/ddg/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "DuckDuckGo",
+ "description": "Search DuckDuckGo",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "ddg@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "DuckDuckGo",
+ "search_url": "https://duckduckgo.com/",
+ "search_form": "https://duckduckgo.com/?q={searchTerms}",
+ "search_url_get_params": "q={searchTerms}",
+ "suggest_url": "https://ac.duckduckgo.com/ac/",
+ "suggest_url_get_params": "q={searchTerms}&type=list"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/diec2/favicon.png b/comm/mail/components/search/extensions/diec2/favicon.png
new file mode 100644
index 0000000000..fa0fb8f1ff
--- /dev/null
+++ b/comm/mail/components/search/extensions/diec2/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/diec2/manifest.json b/comm/mail/components/search/extensions/diec2/manifest.json
new file mode 100644
index 0000000000..c4a11a0c36
--- /dev/null
+++ b/comm/mail/components/search/extensions/diec2/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "DIEC2",
+ "description": "Diccionari de l'Institut d'Estudis Catalans",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "diec2@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "DIEC2",
+ "encoding": "ISO-8859-1",
+ "search_url": "https://dlc.iec.cat/results.asp",
+ "search_form": "https://dlc.iec.cat",
+ "search_url_get_params": "txtEntrada={searchTerms}&OperEntrada=0"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/drae/favicon.ico b/comm/mail/components/search/extensions/drae/favicon.ico
new file mode 100644
index 0000000000..6b3a278678
--- /dev/null
+++ b/comm/mail/components/search/extensions/drae/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/drae/manifest.json b/comm/mail/components/search/extensions/drae/manifest.json
new file mode 100644
index 0000000000..e181545da1
--- /dev/null
+++ b/comm/mail/components/search/extensions/drae/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Diccionario RAE",
+ "description": "Real Academia Española. Diccionario Usual.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "drae@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Diccionario RAE",
+ "search_url": "https://dle.rae.es/",
+ "search_form": "https://dle.rae.es/?w={searchTerms}",
+ "search_url_get_params": "w={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ecosia/favicon.ico b/comm/mail/components/search/extensions/ecosia/favicon.ico
new file mode 100644
index 0000000000..cc72d09d6d
--- /dev/null
+++ b/comm/mail/components/search/extensions/ecosia/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/ecosia/manifest.json b/comm/mail/components/search/extensions/ecosia/manifest.json
new file mode 100644
index 0000000000..a1dc0cf385
--- /dev/null
+++ b/comm/mail/components/search/extensions/ecosia/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Ecosia",
+ "description": "Search Ecosia",
+ "manifest_version": 2,
+ "version": "1.1",
+ "applications": {
+ "gecko": {
+ "id": "ecosia@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Ecosia",
+ "search_url": "https://www.ecosia.org/search",
+ "search_form": "https://www.ecosia.org/",
+ "search_url_get_params": "tt=mzl&q={searchTerms}",
+ "suggest_url": "https://ac.ecosia.org/autocomplete",
+ "suggest_url_get_params": "type=list&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/eki-ee/favicon.ico b/comm/mail/components/search/extensions/eki-ee/favicon.ico
new file mode 100644
index 0000000000..537829c30f
--- /dev/null
+++ b/comm/mail/components/search/extensions/eki-ee/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/eki-ee/manifest.json b/comm/mail/components/search/extensions/eki-ee/manifest.json
new file mode 100644
index 0000000000..c0c9ee7175
--- /dev/null
+++ b/comm/mail/components/search/extensions/eki-ee/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Õigekeelsussõnaraamat",
+ "description": "EKI.ee Eesti õigekeelsussõnaraamat ÕS 2013",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "eki-ee@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Õigekeelsussõnaraamat",
+ "search_url": "https://www.eki.ee/dict/qs/index.cgi",
+ "search_form": "https://www.eki.ee/dict/qs/",
+ "search_url_get_params": "F=M&Q={searchTerms}",
+ "suggest_url": "https://www.eki.ee/dict/soovita.cgi",
+ "suggest_url_get_params": "D=qs&Q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/eudict/favicon.ico b/comm/mail/components/search/extensions/eudict/favicon.ico
new file mode 100644
index 0000000000..20750d0c19
--- /dev/null
+++ b/comm/mail/components/search/extensions/eudict/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/eudict/manifest.json b/comm/mail/components/search/extensions/eudict/manifest.json
new file mode 100644
index 0000000000..3b0e881291
--- /dev/null
+++ b/comm/mail/components/search/extensions/eudict/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "EUdict Eng->Cro",
+ "description": "EUdict - englesko-hrvatski rječnik",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "eudict@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "EUdict Eng->Cro",
+ "search_url": "https://eudict.com",
+ "search_form": "https://eudict.com?lang=engcro&word={searchTerms}",
+ "search_url_get_params": "lang=engcro&word={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/faclair-beag/favicon.ico b/comm/mail/components/search/extensions/faclair-beag/favicon.ico
new file mode 100644
index 0000000000..990cf93298
--- /dev/null
+++ b/comm/mail/components/search/extensions/faclair-beag/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/faclair-beag/manifest.json b/comm/mail/components/search/extensions/faclair-beag/manifest.json
new file mode 100644
index 0000000000..d183697b78
--- /dev/null
+++ b/comm/mail/components/search/extensions/faclair-beag/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Am Faclair Beag",
+ "description": "Lorg Am Faclair Beag",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "faclair-beag@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Am Faclair Beag",
+ "search_url": "https://www.faclair.com/",
+ "search_url_get_params": "txtSearch={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/flip/favicon.png b/comm/mail/components/search/extensions/flip/favicon.png
new file mode 100644
index 0000000000..96fc159dbf
--- /dev/null
+++ b/comm/mail/components/search/extensions/flip/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/flip/manifest.json b/comm/mail/components/search/extensions/flip/manifest.json
new file mode 100644
index 0000000000..75a2d0abaa
--- /dev/null
+++ b/comm/mail/components/search/extensions/flip/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "flip.kz",
+ "description": "Қазақстандық интернет-дүкенде іздеу",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "flip@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "flip.kz",
+ "search_url": "https://www.flip.kz/search",
+ "search_form": "https://flip.kz/",
+ "search_url_get_params": "search={searchTerms}",
+ "suggest_url": "https://www.flip.kz/ajax/search_keyword.php",
+ "suggest_url_get_params": "q={searchTerms}&type=os"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/freelang/favicon.ico b/comm/mail/components/search/extensions/freelang/favicon.ico
new file mode 100644
index 0000000000..510a0379f8
--- /dev/null
+++ b/comm/mail/components/search/extensions/freelang/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/freelang/manifest.json b/comm/mail/components/search/extensions/freelang/manifest.json
new file mode 100644
index 0000000000..3f49d83fbc
--- /dev/null
+++ b/comm/mail/components/search/extensions/freelang/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Freelang (br)",
+ "description": "Geriadur Freelang",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "freelang@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Freelang (br)",
+ "search_url": "https://www.freelang.com/enligne/breton.php",
+ "search_form": "https://www.freelang.com/enligne/breton.php",
+ "search_url_post_params": "dico=fr_bre_fra&lg=fr&mot1={searchTerms}&mot2=&entier=on"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/google/favicon.ico b/comm/mail/components/search/extensions/google/favicon.ico
new file mode 100644
index 0000000000..82339b3b1d
--- /dev/null
+++ b/comm/mail/components/search/extensions/google/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/google/manifest.json b/comm/mail/components/search/extensions/google/manifest.json
new file mode 100644
index 0000000000..98d177e465
--- /dev/null
+++ b/comm/mail/components/search/extensions/google/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Google",
+ "description": "Google Search",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "google@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Google",
+ "search_url": "https://www.google.com/search",
+ "search_form": "https://www.google.com/search?q={searchTerms}",
+ "search_url_get_params": "q={searchTerms}",
+ "suggest_url": "https://www.google.com/complete/search?client=firefox&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/gulesider-NO/favicon.ico b/comm/mail/components/search/extensions/gulesider-NO/favicon.ico
new file mode 100644
index 0000000000..e35572a557
--- /dev/null
+++ b/comm/mail/components/search/extensions/gulesider-NO/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/gulesider-NO/manifest.json b/comm/mail/components/search/extensions/gulesider-NO/manifest.json
new file mode 100644
index 0000000000..37e7461e22
--- /dev/null
+++ b/comm/mail/components/search/extensions/gulesider-NO/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Gule sider",
+ "description": "Gule sider person og firmasøk",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "gulesider-NO@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Gule sider",
+ "search_url": "https://www.gulesider.no/query",
+ "search_form": "https://www.gulesider.no/",
+ "search_url_get_params": "what=all&search_word={searchTerms}&cmpid=fre_partner_fire_gssbtop"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/heureka-cz/favicon.ico b/comm/mail/components/search/extensions/heureka-cz/favicon.ico
new file mode 100644
index 0000000000..95ceff009d
--- /dev/null
+++ b/comm/mail/components/search/extensions/heureka-cz/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/heureka-cz/manifest.json b/comm/mail/components/search/extensions/heureka-cz/manifest.json
new file mode 100644
index 0000000000..023d3455d9
--- /dev/null
+++ b/comm/mail/components/search/extensions/heureka-cz/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Heureka",
+ "description": "Vyhledávání na Heureka.cz",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "heureka-cz@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Heureka",
+ "search_url": "https://www.heureka.cz/",
+ "search_form": "https://www.heureka.cz/",
+ "search_url_get_params": "h[fraze]={searchTerms}&utm_source=firefox-search",
+ "suggest_url": "https://www.heureka.cz/direct/firefox/autocompleter.php",
+ "suggest_url_get_params": "query={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/hotline-ua/favicon.ico b/comm/mail/components/search/extensions/hotline-ua/favicon.ico
new file mode 100644
index 0000000000..53d8fc3bac
--- /dev/null
+++ b/comm/mail/components/search/extensions/hotline-ua/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/hotline-ua/manifest.json b/comm/mail/components/search/extensions/hotline-ua/manifest.json
new file mode 100644
index 0000000000..9f642d18a5
--- /dev/null
+++ b/comm/mail/components/search/extensions/hotline-ua/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Hotline",
+ "description": "Hotline - порівняти ціни в інтернет-магазинах України",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "hotline-ua@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Hotline",
+ "search_url": "https://hotline.ua/sr/",
+ "search_form": "https://hotline.ua/",
+ "search_url_get_params": "q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/kannadastore/favicon.png b/comm/mail/components/search/extensions/kannadastore/favicon.png
new file mode 100644
index 0000000000..8c96fe851f
--- /dev/null
+++ b/comm/mail/components/search/extensions/kannadastore/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/kannadastore/manifest.json b/comm/mail/components/search/extensions/kannadastore/manifest.json
new file mode 100644
index 0000000000..e87aeea130
--- /dev/null
+++ b/comm/mail/components/search/extensions/kannadastore/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Kannada Store",
+ "description": "Kanada Store, Online store",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "kannadastore@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Kannada Store",
+ "encoding": "ISO-8859-1",
+ "search_url": "https://www.kannadastore.com/advanced_search_result.php",
+ "search_form": "https://www.kannadastore.com/advanced_search_result.php",
+ "search_url_get_params": "keywords={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/leo_ende_de/favicon.png b/comm/mail/components/search/extensions/leo_ende_de/favicon.png
new file mode 100644
index 0000000000..04e5e344ef
--- /dev/null
+++ b/comm/mail/components/search/extensions/leo_ende_de/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/leo_ende_de/manifest.json b/comm/mail/components/search/extensions/leo_ende_de/manifest.json
new file mode 100644
index 0000000000..6f1b77a581
--- /dev/null
+++ b/comm/mail/components/search/extensions/leo_ende_de/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "LEO Eng-Deu",
+ "description": "Deutsch-Englisch Wörterbuch von LEO",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "leo_ende_de@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "LEO Eng-Deu",
+ "search_url": "https://dict.leo.org/englisch-deutsch/{searchTerms}",
+ "search_form": "https://dict.leo.org",
+ "suggest_url": "https://dict.leo.org/dictQuery/m-query/conf/ende/query.conf/strlist.json",
+ "suggest_url_get_params": "q={searchTerms}&sort=PLa&shortQuery=undefined&noDescription=undefined&noQueryURLs=undefined"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/list-am/favicon.gif b/comm/mail/components/search/extensions/list-am/favicon.gif
new file mode 100644
index 0000000000..031afb5dfc
--- /dev/null
+++ b/comm/mail/components/search/extensions/list-am/favicon.gif
Binary files differ
diff --git a/comm/mail/components/search/extensions/list-am/manifest.json b/comm/mail/components/search/extensions/list-am/manifest.json
new file mode 100644
index 0000000000..e2dd32304b
--- /dev/null
+++ b/comm/mail/components/search/extensions/list-am/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "List.am",
+ "description": "Հանրային զերծ ads մասին: Վաճառք եւ գնման բնակարաններ, կենցաղային իրեր, որոնել աշխատանքի.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "list-am@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.gif"
+ },
+ "web_accessible_resources": ["favicon.gif"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "List.am",
+ "search_url": "https://www.list.am/category",
+ "search_form": "https://www.list.am/category?q=",
+ "search_url_get_params": "q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/list.json b/comm/mail/components/search/extensions/list.json
new file mode 100644
index 0000000000..b3b440481c
--- /dev/null
+++ b/comm/mail/components/search/extensions/list.json
@@ -0,0 +1,1223 @@
+{
+ "default": {
+ "searchDefault": "Google",
+ "searchOrder": ["Google", "Bing"],
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "wikipedia"
+ ]
+ },
+ "regionOverrides": {
+ "CA": {
+ "amazondotcom": "amazon-ca",
+ "amazon-france": "amazon-ca"
+ },
+ "AU": {
+ "amazondotcom": "amazon-au",
+ "amazon-en-GB": "amazon-au"
+ },
+ "FR": {
+ "amazondotcom": "amazon-france"
+ },
+ "GB": {
+ "amazondotcom": "amazon-en-GB"
+ }
+ },
+ "locales": {
+ "en-US": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "wikipedia"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": [
+ "amazon-ca",
+ "amazon-au",
+ "yandex-en",
+ "google"
+ ]
+ }
+ },
+ "ach": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia"
+ ]
+ }
+ },
+ "af": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-af"
+ ]
+ }
+ },
+ "an": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "wikipedia-an", "ddg"]
+ }
+ },
+ "ar": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ar"
+ ]
+ }
+ },
+ "as": {
+ "default": {
+ "visibleDefaultEngines": ["google", "amazon-in", "ddg", "wikipedia-as"]
+ }
+ },
+ "ast": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-ast"]
+ }
+ },
+ "az": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "azerdict",
+ "bing",
+ "ddg",
+ "wikipedia-az",
+ "yandex-az"
+ ]
+ }
+ },
+ "be": {
+ "default": {
+ "visibleDefaultEngines": [
+ "yandex-by",
+ "google",
+ "ddg",
+ "wikipedia-be",
+ "wikipedia-be-tarask"
+ ]
+ },
+ "BY": {
+ "searchDefault": "Яндекс"
+ },
+ "KZ": {
+ "searchDefault": "Яндекс"
+ },
+ "RU": {
+ "searchDefault": "Яндекс"
+ },
+ "TR": {
+ "searchDefault": "Яндекс"
+ }
+ },
+ "bg": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "ddg",
+ "pazaruvaj",
+ "wikipedia-bg"
+ ]
+ }
+ },
+ "bn": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-in",
+ "bing",
+ "ddg",
+ "wikipedia-bn"
+ ]
+ }
+ },
+ "bn-BD": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-bn"]
+ }
+ },
+ "bn-IN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-in",
+ "bing",
+ "ddg",
+ "wikipedia-bn"
+ ]
+ }
+ },
+ "br": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-france",
+ "ddg",
+ "freelang",
+ "wikipedia-br"
+ ]
+ }
+ },
+ "bs": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "olx", "wikipedia-bs"]
+ }
+ },
+ "ca": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "diec2",
+ "ddg",
+ "wikipedia-ca"
+ ]
+ }
+ },
+ "cak": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "crh": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "wikipedia-crh"]
+ }
+ },
+ "cs": {
+ "default": {
+ "searchOrder": ["Google", "Seznam"],
+ "visibleDefaultEngines": [
+ "google",
+ "seznam-cz",
+ "ddg",
+ "heureka-cz",
+ "mapy-cz",
+ "wikipedia-cz"
+ ]
+ }
+ },
+ "cy": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "palasprint",
+ "wikipedia-cy"
+ ]
+ }
+ },
+ "da": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-da"
+ ]
+ }
+ },
+ "de": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-de",
+ "bing",
+ "ddg",
+ "ecosia",
+ "leo_ende_de",
+ "wikipedia-de"
+ ]
+ }
+ },
+ "dsb": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-de",
+ "ddg",
+ "leo_ende_de",
+ "wikipedia-dsb"
+ ]
+ }
+ },
+ "el": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "wikipedia-el"
+ ]
+ }
+ },
+ "en-CA": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-ca",
+ "bing",
+ "ddg",
+ "wikipedia"
+ ]
+ }
+ },
+ "en-GB": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "chambers-en-GB",
+ "ddg",
+ "wikipedia"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["yandex-en"]
+ }
+ },
+ "en-ZA": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia"
+ ]
+ }
+ },
+ "eo": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-eo"
+ ]
+ }
+ },
+ "es-AR": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "drae",
+ "ddg",
+ "mercadolibre-ar",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "es-CL": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "drae",
+ "ddg",
+ "mercadolibre-cl",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "es-ES": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "drae",
+ "ddg",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "es-MX": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "mercadolibre-mx",
+ "wikipedia-es"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-mx"]
+ }
+ },
+ "et": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "neti-ee",
+ "ddg",
+ "osta-ee",
+ "wikipedia-et",
+ "eki-ee"
+ ]
+ }
+ },
+ "eu": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-eu"
+ ]
+ }
+ },
+ "fa": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "wikipedia-fa"
+ ]
+ }
+ },
+ "ff": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "wikipedia-fr"
+ ]
+ }
+ },
+ "fi": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-fi"]
+ }
+ },
+ "fr": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "qwant",
+ "wikipedia-fr"
+ ]
+ }
+ },
+ "fy-NL": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "bolcom-fy-NL",
+ "ddg",
+ "marktplaats-fy-NL",
+ "wikipedia-fy-NL"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-nl"]
+ }
+ },
+ "ga-IE": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "tearma",
+ "wikipedia-ga-IE"
+ ]
+ }
+ },
+ "gd": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bbc-alba",
+ "ddg",
+ "faclair-beag",
+ "wikipedia-gd"
+ ]
+ }
+ },
+ "gl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-gl"
+ ]
+ }
+ },
+ "gn": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-gn"
+ ]
+ }
+ },
+ "gu-IN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-gu"
+ ]
+ }
+ },
+ "he": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "wikipedia-he", "morfix-dic"]
+ }
+ },
+ "hi-IN": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-hi"]
+ }
+ },
+ "hr": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "eudict",
+ "wikipedia-hr"
+ ]
+ }
+ },
+ "hsb": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-de",
+ "ddg",
+ "leo_ende_de",
+ "wikipedia-hsb"
+ ]
+ }
+ },
+ "hu": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "vatera", "wikipedia-hu"]
+ }
+ },
+ "hy-AM": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "ddg",
+ "list-am",
+ "wikipedia-hy"
+ ]
+ }
+ },
+ "ia": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ia"
+ ]
+ }
+ },
+ "id": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "wikipedia-id"]
+ }
+ },
+ "is": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-is"
+ ]
+ }
+ },
+ "it": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-it",
+ "ddg",
+ "wikipedia-it"
+ ]
+ }
+ },
+ "ja-JP-macos": {
+ "default": {
+ "searchOrder": [
+ "Google",
+ "Yahoo! JAPAN",
+ "Bing",
+ "Amazon.co.jp",
+ "楽天市場",
+ "ヤフオク!",
+ "教えて!goo",
+ "Wikipedia (ja)"
+ ],
+ "visibleDefaultEngines": [
+ "google",
+ "yahoo-jp",
+ "bing",
+ "amazon-jp",
+ "rakuten",
+ "yahoo-jp-auctions",
+ "oshiete-goo",
+ "wikipedia-ja",
+ "ddg"
+ ]
+ }
+ },
+ "ja": {
+ "default": {
+ "searchOrder": [
+ "Google",
+ "Yahoo! JAPAN",
+ "Bing",
+ "Amazon.co.jp",
+ "楽天市場",
+ "ヤフオク!",
+ "教えて!goo",
+ "Wikipedia (ja)"
+ ],
+ "visibleDefaultEngines": [
+ "google",
+ "yahoo-jp",
+ "bing",
+ "amazon-jp",
+ "rakuten",
+ "yahoo-jp-auctions",
+ "oshiete-goo",
+ "wikipedia-ja",
+ "ddg"
+ ]
+ }
+ },
+ "ka": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ka"
+ ]
+ }
+ },
+ "kab": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-kab"]
+ }
+ },
+ "kk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "yandex-kk",
+ "google",
+ "ddg",
+ "flip",
+ "wikipedia-kk"
+ ]
+ },
+ "KZ": {
+ "searchDefault": "Яндекс"
+ },
+ "BY": {
+ "searchDefault": "Яндекс"
+ },
+ "RU": {
+ "searchDefault": "Яндекс"
+ },
+ "TR": {
+ "searchDefault": "Яндекс"
+ }
+ },
+ "km": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-km"
+ ]
+ }
+ },
+ "kn": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "kannadastore",
+ "wikipedia-kn"
+ ]
+ }
+ },
+ "ko": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ddg",
+ "naver-kr",
+ "daum-kr",
+ "wikipedia-kr"
+ ]
+ }
+ },
+ "lij": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-it",
+ "ddg",
+ "wikipedia-lij"
+ ]
+ }
+ },
+ "lo": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-lo"]
+ }
+ },
+ "lt": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "wikipedia-lt",
+ "bing",
+ "amazondotcom",
+ "ddg"
+ ]
+ }
+ },
+ "ltg": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ddg",
+ "salidzinilv",
+ "sslv",
+ "wikipedia-ltg"
+ ]
+ }
+ },
+ "lv": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ddg",
+ "salidzinilv",
+ "sslv",
+ "wikipedia-lv"
+ ]
+ }
+ },
+ "mai": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-hi"
+ ]
+ }
+ },
+ "mk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-mk"
+ ]
+ }
+ },
+ "ml": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia",
+ "wikipedia-ml"
+ ]
+ }
+ },
+ "mr": {
+ "default": {
+ "visibleDefaultEngines": ["google", "amazon-in", "ddg", "wikipedia-mr"]
+ }
+ },
+ "ms": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ms"
+ ]
+ }
+ },
+ "my": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-my"
+ ]
+ }
+ },
+ "nb-NO": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "gulesider-NO",
+ "bok-NO",
+ "qxl-NO",
+ "wikipedia-NO"
+ ]
+ }
+ },
+ "ne-NP": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia-ne"]
+ }
+ },
+ "nl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "bolcom-nl",
+ "ddg",
+ "marktplaats-nl",
+ "wikipedia-nl"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-nl"]
+ }
+ },
+ "nn-NO": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "gulesider-NO",
+ "bok-NO",
+ "qxl-NO",
+ "wikipedia-NN"
+ ]
+ }
+ },
+ "oc": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "wikipedia-oc",
+ "wiktionary-oc"
+ ]
+ }
+ },
+ "or": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-or"
+ ]
+ }
+ },
+ "pa-IN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-pa"
+ ]
+ }
+ },
+ "pl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "allegro-pl",
+ "ddg",
+ "pwn-pl",
+ "wikipedia-pl",
+ "wolnelektury-pl"
+ ]
+ }
+ },
+ "pt-BR": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "mercadolivre",
+ "wikipedia-pt"
+ ]
+ },
+ "experimental-hidden": {
+ "visibleDefaultEngines": ["amazon-br"]
+ }
+ },
+ "pt-PT": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "ddg",
+ "priberam",
+ "wikipedia-pt"
+ ]
+ }
+ },
+ "rm": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "leo_ende_de",
+ "wikipedia-rm"
+ ]
+ }
+ },
+ "ro": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-ro"
+ ]
+ }
+ },
+ "ru": {
+ "default": {
+ "visibleDefaultEngines": [
+ "yandex-ru",
+ "google",
+ "ddg",
+ "ozonru",
+ "priceru",
+ "wikipedia-ru",
+ "mailru"
+ ]
+ },
+ "RU": {
+ "searchDefault": "Яндекс"
+ },
+ "BY": {
+ "searchDefault": "Яндекс"
+ },
+ "KZ": {
+ "searchDefault": "Яндекс"
+ },
+ "TR": {
+ "searchDefault": "Яндекс"
+ }
+ },
+ "si": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-si"
+ ]
+ }
+ },
+ "sk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "azet-sk",
+ "atlas-sk",
+ "ddg",
+ "wikipedia-sk",
+ "zoznam-sk"
+ ]
+ }
+ },
+ "sl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "ceneji",
+ "ddg",
+ "najdi-si",
+ "odpiralni",
+ "wikipedia-sl"
+ ]
+ }
+ },
+ "son": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "wikipedia-fr"
+ ]
+ }
+ },
+ "sq": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-en-GB",
+ "ddg",
+ "wikipedia-sq"
+ ]
+ }
+ },
+ "sr": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-en-GB",
+ "bing",
+ "ddg",
+ "wikipedia-sr",
+ "pogodak"
+ ]
+ }
+ },
+ "sv-SE": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "allaannonser-sv-SE",
+ "ddg",
+ "prisjakt-sv-SE",
+ "tyda-sv-SE",
+ "wikipedia-sv-SE"
+ ]
+ }
+ },
+ "ta": {
+ "default": {
+ "visibleDefaultEngines": ["google", "amazon-in", "ddg", "wikipedia-ta"]
+ }
+ },
+ "te": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazon-in",
+ "ddg",
+ "wikipedia-te",
+ "wiktionary-te"
+ ]
+ }
+ },
+ "th": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "amazondotcom",
+ "bing",
+ "ddg",
+ "longdo",
+ "wikipedia-th"
+ ]
+ }
+ },
+ "tl": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-tl"
+ ]
+ }
+ },
+ "tr": {
+ "default": {
+ "visibleDefaultEngines": ["yandex-tr", "google", "ddg", "wikipedia-tr"]
+ },
+ "TR": {
+ "searchDefault": "Yandex"
+ },
+ "BY": {
+ "searchDefault": "Yandex"
+ },
+ "KZ": {
+ "searchDefault": "Yandex"
+ },
+ "RU": {
+ "searchDefault": "Yandex"
+ }
+ },
+ "trs": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-es"
+ ]
+ }
+ },
+ "uk": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "ddg",
+ "wikipedia-uk",
+ "hotline-ua"
+ ]
+ }
+ },
+ "ur": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-in",
+ "ddg",
+ "wikipedia-ur"
+ ]
+ }
+ },
+ "uz": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazondotcom",
+ "ddg",
+ "wikipedia-uz"
+ ]
+ }
+ },
+ "vi": {
+ "default": {
+ "visibleDefaultEngines": ["google", "coccoc", "ddg", "wikipedia-vi"]
+ }
+ },
+ "wo": {
+ "default": {
+ "visibleDefaultEngines": [
+ "google",
+ "bing",
+ "amazon-france",
+ "ddg",
+ "wikipedia-wo"
+ ]
+ }
+ },
+ "xh": {
+ "default": {
+ "visibleDefaultEngines": ["google", "bing", "ddg", "wikipedia"]
+ }
+ },
+ "zh-CN": {
+ "default": {
+ "visibleDefaultEngines": [
+ "baidu",
+ "google",
+ "bing",
+ "ddg",
+ "wikipedia-zh-CN",
+ "amazondotcn"
+ ]
+ },
+ "CN": {
+ "searchDefault": "百度"
+ }
+ },
+ "zh-TW": {
+ "default": {
+ "visibleDefaultEngines": ["google", "ddg", "readmoo", "wikipedia-zh-TW"]
+ }
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/longdo/favicon.ico b/comm/mail/components/search/extensions/longdo/favicon.ico
new file mode 100644
index 0000000000..aa42cda97f
--- /dev/null
+++ b/comm/mail/components/search/extensions/longdo/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/longdo/manifest.json b/comm/mail/components/search/extensions/longdo/manifest.json
new file mode 100644
index 0000000000..1b86c951d5
--- /dev/null
+++ b/comm/mail/components/search/extensions/longdo/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "พจนานุกรม ลองดู",
+ "description": "พจนานุกรม ลองดู",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "longdo@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "พจนานุกรม ลองดู",
+ "search_url": "https://dict.longdo.org/",
+ "search_form": "https://dict.longdo.org/",
+ "search_url_get_params": "search={searchTerms}&src=moz",
+ "suggest_url": "https://search.longdo.com/Suggest/HeadSearch",
+ "suggest_url_get_params": "ds=head&fxjson=1&key={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mailru/favicon.ico b/comm/mail/components/search/extensions/mailru/favicon.ico
new file mode 100644
index 0000000000..a2d3a48883
--- /dev/null
+++ b/comm/mail/components/search/extensions/mailru/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mailru/manifest.json b/comm/mail/components/search/extensions/mailru/manifest.json
new file mode 100644
index 0000000000..9d5e799296
--- /dev/null
+++ b/comm/mail/components/search/extensions/mailru/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Поиск Mail.Ru",
+ "description": "Search with Поиск Mail.Ru",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mailru@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Поиск Mail.Ru",
+ "search_url": "https://go.mail.ru/search",
+ "search_form": "https://go.mail.ru/?gp=900200",
+ "search_url_get_params": "q={searchTerms}&fr=osmi&gp=900200&frc=900200",
+ "suggest_url": "https://suggests.go.mail.ru/ff3",
+ "suggest_url_get_params": "q={searchTerms}&gp=900200"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mapy-cz/favicon.ico b/comm/mail/components/search/extensions/mapy-cz/favicon.ico
new file mode 100644
index 0000000000..051204c35c
--- /dev/null
+++ b/comm/mail/components/search/extensions/mapy-cz/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mapy-cz/manifest.json b/comm/mail/components/search/extensions/mapy-cz/manifest.json
new file mode 100644
index 0000000000..2f8fdc32f1
--- /dev/null
+++ b/comm/mail/components/search/extensions/mapy-cz/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Mapy.cz",
+ "description": "Vyhledávání na Mapy.cz",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mapy-cz@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Mapy.cz",
+ "search_url": "https://www.mapy.cz/",
+ "search_form": "https://www.mapy.cz/",
+ "search_url_get_params": "query={searchTerms}&sourceid=Searchmodule_3"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json b/comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json
new file mode 100644
index 0000000000..4d7f884b17
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/_locales/fy-NL/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Marktplaats.nl"
+ },
+ "extensionDescription": {
+ "message": "Sykje yn alle kategoryen op Marktplaats.nl"
+ },
+ "searchUrl": {
+ "message": "https://www.marktplaats.nl/z.html"
+ },
+ "searchForm": {
+ "message": "https://www.marktplaats.nl"
+ },
+ "searchUrlGetParams": {
+ "message": "query={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json b/comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json
new file mode 100644
index 0000000000..c44a0a25cc
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/_locales/nl/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "Marktplaats.nl"
+ },
+ "extensionDescription": {
+ "message": "Zoeken in alle categorieën op Marktplaats.nl"
+ },
+ "searchUrl": {
+ "message": "https://www.marktplaats.nl/z.html"
+ },
+ "searchForm": {
+ "message": "https://www.marktplaats.nl"
+ },
+ "searchUrlGetParams": {
+ "message": "query={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/marktplaats/favicon.ico b/comm/mail/components/search/extensions/marktplaats/favicon.ico
new file mode 100644
index 0000000000..ed0ff305a6
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/marktplaats/manifest.json b/comm/mail/components/search/extensions/marktplaats/manifest.json
new file mode 100644
index 0000000000..b1a41bbd02
--- /dev/null
+++ b/comm/mail/components/search/extensions/marktplaats/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "marktplaats@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "fy-NL",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json b/comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json
new file mode 100644
index 0000000000..b83f37c6fc
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/_locales/ar/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "MercadoLibre Argentina"
+ },
+ "extensionDescription": {
+ "message": "MercadoLibre Argentina"
+ },
+ "searchUrl": {
+ "message": "https://www.mercadolibre.com.ar/jm/search"
+ },
+ "searchForm": {
+ "message": "https://www.mercadolibre.com.ar/"
+ },
+ "searchUrlGetParams": {
+ "message": "as_word={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json b/comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json
new file mode 100644
index 0000000000..3c37756464
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/_locales/cl/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "MercadoLibre Chile"
+ },
+ "extensionDescription": {
+ "message": "MercadoLibre Chile"
+ },
+ "searchUrl": {
+ "message": "https://www.mercadolibre.cl/jm/search"
+ },
+ "searchForm": {
+ "message": "https://www.mercadolibre.cl/"
+ },
+ "searchUrlGetParams": {
+ "message": "as_word={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json b/comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json
new file mode 100644
index 0000000000..cb4d2b4b79
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/_locales/mx/messages.json
@@ -0,0 +1,17 @@
+{
+ "extensionName": {
+ "message": "MercadoLibre Mexico"
+ },
+ "extensionDescription": {
+ "message": "MercadoLibre Mexico"
+ },
+ "searchUrl": {
+ "message": "https://www.mercadolibre.com.mx/jm/search"
+ },
+ "searchForm": {
+ "message": "https://www.mercadolibre.com.mx/"
+ },
+ "searchUrlGetParams": {
+ "message": "as_word={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolibre/favicon.ico b/comm/mail/components/search/extensions/mercadolibre/favicon.ico
new file mode 100644
index 0000000000..dc9ad5b2a9
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mercadolibre/manifest.json b/comm/mail/components/search/extensions/mercadolibre/manifest.json
new file mode 100644
index 0000000000..7af5ecc3cf
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolibre/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mercadolibre@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "ar",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/mercadolivre/favicon.ico b/comm/mail/components/search/extensions/mercadolivre/favicon.ico
new file mode 100644
index 0000000000..dc9ad5b2a9
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolivre/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/mercadolivre/manifest.json b/comm/mail/components/search/extensions/mercadolivre/manifest.json
new file mode 100644
index 0000000000..d70f94573f
--- /dev/null
+++ b/comm/mail/components/search/extensions/mercadolivre/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "MercadoLivre",
+ "description": "Onde comprar e vender de Tudo.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "mercadolivre@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "MercadoLivre",
+ "search_url": "https://www.mercadolivre.com.br/jm/search",
+ "search_form": "https://www.mercadolivre.com.br/",
+ "search_url_get_params": "as_word={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/morfix-dic/favicon.ico b/comm/mail/components/search/extensions/morfix-dic/favicon.ico
new file mode 100644
index 0000000000..6a3231b172
--- /dev/null
+++ b/comm/mail/components/search/extensions/morfix-dic/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/morfix-dic/manifest.json b/comm/mail/components/search/extensions/morfix-dic/manifest.json
new file mode 100644
index 0000000000..cacaad9ccf
--- /dev/null
+++ b/comm/mail/components/search/extensions/morfix-dic/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "מילון מורפיקס",
+ "description": "",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "morfix-dic@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "מילון מורפיקס",
+ "search_url": "https://milon.morfix.co.il/default.aspx",
+ "search_form": "https://milon.morfix.co.il/",
+ "search_url_get_params": "q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/najdi-si/favicon.png b/comm/mail/components/search/extensions/najdi-si/favicon.png
new file mode 100644
index 0000000000..b470991648
--- /dev/null
+++ b/comm/mail/components/search/extensions/najdi-si/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/najdi-si/manifest.json b/comm/mail/components/search/extensions/najdi-si/manifest.json
new file mode 100644
index 0000000000..2d7e0caaad
--- /dev/null
+++ b/comm/mail/components/search/extensions/najdi-si/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Najdi.si",
+ "description": "Iskalnik Najdi.si",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "najdi-si@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Najdi.si",
+ "search_url": "https://www.najdi.si/search.jsp",
+ "search_form": "https://www.najdi.si/",
+ "search_url_get_params": "q={searchTerms}&o=0&foxsbar=ff"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/naver-kr/favicon.ico b/comm/mail/components/search/extensions/naver-kr/favicon.ico
new file mode 100644
index 0000000000..eed93a92cb
--- /dev/null
+++ b/comm/mail/components/search/extensions/naver-kr/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/naver-kr/manifest.json b/comm/mail/components/search/extensions/naver-kr/manifest.json
new file mode 100644
index 0000000000..240fc2e0d3
--- /dev/null
+++ b/comm/mail/components/search/extensions/naver-kr/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "네이버",
+ "description": "네이버 검색",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "naver-kr@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "네이버",
+ "search_url": "https://search.naver.com/search.naver",
+ "search_form": "https://search.naver.com",
+ "search_url_get_params": "where=nexearch&frm=ff&sm=oss&ie=utf8&query={searchTerms}",
+ "suggest_url": "https://ac.search.naver.com/nx/ac",
+ "suggest_url_get_params": "of=os&ie=utf-8&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/neti-ee/favicon.ico b/comm/mail/components/search/extensions/neti-ee/favicon.ico
new file mode 100644
index 0000000000..1bc10ea7fb
--- /dev/null
+++ b/comm/mail/components/search/extensions/neti-ee/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/neti-ee/manifest.json b/comm/mail/components/search/extensions/neti-ee/manifest.json
new file mode 100644
index 0000000000..900789fceb
--- /dev/null
+++ b/comm/mail/components/search/extensions/neti-ee/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Neti",
+ "description": "Neti",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "neti-ee@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Neti",
+ "search_url": "https://www.neti.ee/cgi-bin/otsing",
+ "search_form": "https://www.neti.ee/",
+ "search_url_get_params": "query={searchTerms}&src=web"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/odpiralni/favicon.png b/comm/mail/components/search/extensions/odpiralni/favicon.png
new file mode 100644
index 0000000000..044d4f13d4
--- /dev/null
+++ b/comm/mail/components/search/extensions/odpiralni/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/odpiralni/manifest.json b/comm/mail/components/search/extensions/odpiralni/manifest.json
new file mode 100644
index 0000000000..cee70b5cb6
--- /dev/null
+++ b/comm/mail/components/search/extensions/odpiralni/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Odpiralni Časi",
+ "description": "Odpiralni Časi v Sloveniji",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "odpiralni@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Odpiralni Časi",
+ "search_url": "https://www.odpiralnicasi.com/spots",
+ "search_url_get_params": "q={searchTerms}&source=1"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/olx/favicon.ico b/comm/mail/components/search/extensions/olx/favicon.ico
new file mode 100644
index 0000000000..22e472190b
--- /dev/null
+++ b/comm/mail/components/search/extensions/olx/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/olx/manifest.json b/comm/mail/components/search/extensions/olx/manifest.json
new file mode 100644
index 0000000000..da80367b2f
--- /dev/null
+++ b/comm/mail/components/search/extensions/olx/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "OLX.ba",
+ "description": "OLX.ba pretraživač",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "olx@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "OLX.ba",
+ "search_url": "https://www.olx.ba/pretraga",
+ "search_form": "https://www.olx.ba/",
+ "search_url_get_params": "trazilica={searchTerms}",
+ "suggest_url": "https://www.olx.ba/sugestije/firefox_pojmovi",
+ "suggest_url_get_params": "sta={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/oshiete-goo/favicon.ico b/comm/mail/components/search/extensions/oshiete-goo/favicon.ico
new file mode 100644
index 0000000000..ee454036dd
--- /dev/null
+++ b/comm/mail/components/search/extensions/oshiete-goo/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/oshiete-goo/manifest.json b/comm/mail/components/search/extensions/oshiete-goo/manifest.json
new file mode 100644
index 0000000000..be6d6fb8e5
--- /dev/null
+++ b/comm/mail/components/search/extensions/oshiete-goo/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "教えて!goo",
+ "description": "教えて!goo",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "oshiete-goo@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "教えて!goo",
+ "search_url": "https://oshiete.goo.ne.jp/search_goo/result/",
+ "search_url_get_params": "MT={searchTerms}&from=Firefox30&PT=Firefox30"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/osta-ee/favicon.png b/comm/mail/components/search/extensions/osta-ee/favicon.png
new file mode 100644
index 0000000000..e67b9c3abf
--- /dev/null
+++ b/comm/mail/components/search/extensions/osta-ee/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/osta-ee/manifest.json b/comm/mail/components/search/extensions/osta-ee/manifest.json
new file mode 100644
index 0000000000..7940471355
--- /dev/null
+++ b/comm/mail/components/search/extensions/osta-ee/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Osta",
+ "description": "Osta",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "osta-ee@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Osta",
+ "search_url": "https://www.osta.ee/firefox/",
+ "search_form": "https://www.osta.ee/",
+ "search_url_get_params": "keyword={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/ozonru/favicon.ico b/comm/mail/components/search/extensions/ozonru/favicon.ico
new file mode 100644
index 0000000000..eecb97a330
--- /dev/null
+++ b/comm/mail/components/search/extensions/ozonru/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/ozonru/manifest.json b/comm/mail/components/search/extensions/ozonru/manifest.json
new file mode 100644
index 0000000000..35d0d3f1a5
--- /dev/null
+++ b/comm/mail/components/search/extensions/ozonru/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "OZON.ru",
+ "description": "OZON.ru provider",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "ozonru@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "OZON.ru",
+ "encoding": "WINDOWS-1251",
+ "search_url": "https://www.ozon.ru/?",
+ "search_form": "https://www.ozon.ru/",
+ "search_url_get_params": "context=search&text={searchTerms}&from=firefox",
+ "suggest_url": "https://www.ozon.ru/JSONSuggestionHandler.ashx",
+ "suggest_url_get_params": "text={searchTerms}&from=firefox"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/palasprint/favicon.ico b/comm/mail/components/search/extensions/palasprint/favicon.ico
new file mode 100644
index 0000000000..afa4eef392
--- /dev/null
+++ b/comm/mail/components/search/extensions/palasprint/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/palasprint/manifest.json b/comm/mail/components/search/extensions/palasprint/manifest.json
new file mode 100644
index 0000000000..9786e0679c
--- /dev/null
+++ b/comm/mail/components/search/extensions/palasprint/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Palas Print",
+ "description": "Palas Print - Heb Ffiniau",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "palasprint@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Palas Print",
+ "search_url": "https://palasprint.com/siopa/search_all.php",
+ "search_form": "https://palasprint.com/siopa/advanced_search.php",
+ "search_url_get_params": "keywords={searchTerms}&source=mozilla"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/pazaruvaj/favicon.ico b/comm/mail/components/search/extensions/pazaruvaj/favicon.ico
new file mode 100644
index 0000000000..36f0cff233
--- /dev/null
+++ b/comm/mail/components/search/extensions/pazaruvaj/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/pazaruvaj/manifest.json b/comm/mail/components/search/extensions/pazaruvaj/manifest.json
new file mode 100644
index 0000000000..b94dce2668
--- /dev/null
+++ b/comm/mail/components/search/extensions/pazaruvaj/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Pazaruvaj",
+ "description": "Надежден помощник за покупки, сравнение на цени, онлайн магазини, описания, мнения, видеоклипове",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "pazaruvaj@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Pazaruvaj",
+ "search_url": "https://www.pazaruvaj.com/CategorySearch.php",
+ "search_form": "https://www.pazaruvaj.com/",
+ "search_url_get_params": "st={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/pogodak/favicon.ico b/comm/mail/components/search/extensions/pogodak/favicon.ico
new file mode 100644
index 0000000000..1bae4f838d
--- /dev/null
+++ b/comm/mail/components/search/extensions/pogodak/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/pogodak/manifest.json b/comm/mail/components/search/extensions/pogodak/manifest.json
new file mode 100644
index 0000000000..e17e175620
--- /dev/null
+++ b/comm/mail/components/search/extensions/pogodak/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Погодак",
+ "description": "Погодак: претраживач Интернета",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "pogodak@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Погодак",
+ "search_url": "https://www.pogodak.rs/pretraga",
+ "search_form": "https://www.pogodak.rs",
+ "search_url_get_params": "q={searchTerms}&foxsbar=ff"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/priberam/favicon.png b/comm/mail/components/search/extensions/priberam/favicon.png
new file mode 100644
index 0000000000..98924439d5
--- /dev/null
+++ b/comm/mail/components/search/extensions/priberam/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/priberam/manifest.json b/comm/mail/components/search/extensions/priberam/manifest.json
new file mode 100644
index 0000000000..f344d4def4
--- /dev/null
+++ b/comm/mail/components/search/extensions/priberam/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Priberam",
+ "description": "Dicionário Priberam",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "priberam@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Priberam",
+ "encoding": "ISO-8859-15",
+ "search_url": "https://www.priberam.pt/dlpo/firefox.aspx",
+ "search_form": "https://www.priberam.pt/dlpo/",
+ "search_url_get_params": "pal={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/priceru/favicon.ico b/comm/mail/components/search/extensions/priceru/favicon.ico
new file mode 100644
index 0000000000..ee4ca656ca
--- /dev/null
+++ b/comm/mail/components/search/extensions/priceru/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/priceru/manifest.json b/comm/mail/components/search/extensions/priceru/manifest.json
new file mode 100644
index 0000000000..1eff4b2e47
--- /dev/null
+++ b/comm/mail/components/search/extensions/priceru/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Price.ru",
+ "description": "Поиск предложений и цен",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "priceru@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Price.ru",
+ "search_url": "https://price.ru/search",
+ "search_form": "https://price.ru/index.html",
+ "search_url_get_params": "query={searchTerms}&from=fx3"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.ico b/comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.ico
new file mode 100644
index 0000000000..feac665f71
--- /dev/null
+++ b/comm/mail/components/search/extensions/prisjakt-sv-SE/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json b/comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json
new file mode 100644
index 0000000000..442cb0414e
--- /dev/null
+++ b/comm/mail/components/search/extensions/prisjakt-sv-SE/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Prisjakt",
+ "description": "Prisjakt - jämför priser och produkter",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "prisjakt-sv-SE@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Prisjakt",
+ "search_url": "https://www.prisjakt.nu/#rparams=ss={searchTerms}",
+ "search_form": "https://www.prisjakt.nu/#rparams=ss={searchTerms}",
+ "suggest_url": "https://www.prisjakt.nu/plugins/opensearch/suggestions.php",
+ "suggest_url_get_params": "search={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/pwn-pl/favicon.png b/comm/mail/components/search/extensions/pwn-pl/favicon.png
new file mode 100644
index 0000000000..3cbae12d48
--- /dev/null
+++ b/comm/mail/components/search/extensions/pwn-pl/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/pwn-pl/manifest.json b/comm/mail/components/search/extensions/pwn-pl/manifest.json
new file mode 100644
index 0000000000..46375e9983
--- /dev/null
+++ b/comm/mail/components/search/extensions/pwn-pl/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Encyklopedia PWN",
+ "description": "Wyszukiwanie w Encyklopedii PWN",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "pwn-pl@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Encyklopedia PWN",
+ "search_url": "https://encyklopedia.pwn.pl/szukaj/{searchTerms}",
+ "search_form": "https://encyklopedia.pwn.pl/szukaj/"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/qwant/favicon.ico b/comm/mail/components/search/extensions/qwant/favicon.ico
new file mode 100644
index 0000000000..d43d1d5aa6
--- /dev/null
+++ b/comm/mail/components/search/extensions/qwant/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/qwant/manifest.json b/comm/mail/components/search/extensions/qwant/manifest.json
new file mode 100644
index 0000000000..07fbf85cce
--- /dev/null
+++ b/comm/mail/components/search/extensions/qwant/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Qwant",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "qwant@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Qwant",
+ "search_url": "https://www.qwant.com/",
+ "search_url_get_params": "client=brz-moz&q={searchTerms}",
+ "suggest_url": "https://api.qwant.com/api/suggest/",
+ "suggest_url_get_params": "client=opensearch&q={searchTerms}",
+ "search_form": "https://www.qwant.com/"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/qxl-NO/favicon.ico b/comm/mail/components/search/extensions/qxl-NO/favicon.ico
new file mode 100644
index 0000000000..02ee1fc283
--- /dev/null
+++ b/comm/mail/components/search/extensions/qxl-NO/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/qxl-NO/manifest.json b/comm/mail/components/search/extensions/qxl-NO/manifest.json
new file mode 100644
index 0000000000..8f98d766ce
--- /dev/null
+++ b/comm/mail/components/search/extensions/qxl-NO/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "QXL",
+ "description": "QXL søk",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "qxl-NO@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "QXL",
+ "search_url": "https://www.qxl.no/search/search.asp",
+ "search_form": "https://www.qxl.no/",
+ "search_url_get_params": "txtSearch={searchTerms}&InTitleAndDesc=1&sourceid=Mozilla-search"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/rakuten/favicon.ico b/comm/mail/components/search/extensions/rakuten/favicon.ico
new file mode 100644
index 0000000000..66afe98469
--- /dev/null
+++ b/comm/mail/components/search/extensions/rakuten/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/rakuten/manifest.json b/comm/mail/components/search/extensions/rakuten/manifest.json
new file mode 100644
index 0000000000..ba590dba76
--- /dev/null
+++ b/comm/mail/components/search/extensions/rakuten/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "楽天市場",
+ "description": "楽天市場 商品検索",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "rakuten@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "楽天市場",
+ "encoding": "EUC-JP",
+ "search_url": "https://pt.afl.rakuten.co.jp/c/013ca98b.cd7c5f0c/",
+ "search_form": "https://www.rakuten.co.jp/",
+ "search_url_get_params": "sitem={searchTerms}&sv=2&p=0"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/readmoo/favicon.ico b/comm/mail/components/search/extensions/readmoo/favicon.ico
new file mode 100644
index 0000000000..75396dc9ca
--- /dev/null
+++ b/comm/mail/components/search/extensions/readmoo/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/readmoo/manifest.json b/comm/mail/components/search/extensions/readmoo/manifest.json
new file mode 100644
index 0000000000..f9d8a24f3c
--- /dev/null
+++ b/comm/mail/components/search/extensions/readmoo/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Readmoo 讀墨電子書",
+ "description": "Readmoo 讀墨電子書",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "readmoo@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Readmoo 讀墨電子書",
+ "search_url": "https://readmoo.com/search/keyword",
+ "search_form": "https://readmoo.com/search/keyword?pi=0&q={searchTerms}&st=true",
+ "search_url_get_params": "pi=0&q={searchTerms}&st=true"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/salidzinilv/favicon.ico b/comm/mail/components/search/extensions/salidzinilv/favicon.ico
new file mode 100644
index 0000000000..0a7d01cae8
--- /dev/null
+++ b/comm/mail/components/search/extensions/salidzinilv/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/salidzinilv/manifest.json b/comm/mail/components/search/extensions/salidzinilv/manifest.json
new file mode 100644
index 0000000000..322a378eae
--- /dev/null
+++ b/comm/mail/components/search/extensions/salidzinilv/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Salidzini.lv",
+ "description": "Salidzini.lv - Latvijas interneta veikalu mekletajs",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "salidzinilv@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Salidzini.lv",
+ "search_url": "https://www.salidzini.lv/search.php",
+ "search_form": "https://salidzini.lv",
+ "search_url_get_params": "q={searchTerms}&utm_source=firefox-plugin",
+ "suggest_url": "https://www.salidzini.lv/suggested_search.php",
+ "suggest_url_get_params": "q={searchTerms}&utm_source=firefox-plugin"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/seznam-cz/favicon.ico b/comm/mail/components/search/extensions/seznam-cz/favicon.ico
new file mode 100644
index 0000000000..f3e078a107
--- /dev/null
+++ b/comm/mail/components/search/extensions/seznam-cz/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/seznam-cz/manifest.json b/comm/mail/components/search/extensions/seznam-cz/manifest.json
new file mode 100644
index 0000000000..838cbb497f
--- /dev/null
+++ b/comm/mail/components/search/extensions/seznam-cz/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Seznam",
+ "description": "Vyhledávání na Seznam.cz",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "seznam-cz@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Seznam",
+ "search_url": "https://search.seznam.cz/",
+ "search_form": "https://search.seznam.cz/?q={searchTerms}&sourceid=firefox",
+ "search_url_get_params": "q={searchTerms}&sourceid=firefox",
+ "suggest_url": "https://suggest.seznam.cz/fulltext_ff",
+ "suggest_url_get_params": "phrase={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/sslv/favicon.ico b/comm/mail/components/search/extensions/sslv/favicon.ico
new file mode 100644
index 0000000000..c6869229a2
--- /dev/null
+++ b/comm/mail/components/search/extensions/sslv/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/sslv/manifest.json b/comm/mail/components/search/extensions/sslv/manifest.json
new file mode 100644
index 0000000000..3de8483fae
--- /dev/null
+++ b/comm/mail/components/search/extensions/sslv/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "SS.lv",
+ "description": "SS.lv - Lielākais sludinājumu serviss Latvijā",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "sslv@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "SS.lv",
+ "search_url": "https://www.ss.lv/lv/search_result/index.html",
+ "search_form": "https://www.ss.lv",
+ "search_url_post_params": "txt={searchTerms}&from=firefox-plugin"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/tearma/favicon.ico b/comm/mail/components/search/extensions/tearma/favicon.ico
new file mode 100644
index 0000000000..23866521ea
--- /dev/null
+++ b/comm/mail/components/search/extensions/tearma/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/tearma/manifest.json b/comm/mail/components/search/extensions/tearma/manifest.json
new file mode 100644
index 0000000000..ae081f5927
--- /dev/null
+++ b/comm/mail/components/search/extensions/tearma/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "tearma.ie",
+ "description": "tearma.ie: Cuardach Comhtháite",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "tearma@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "tearma.ie",
+ "search_url": "https://www.tearma.ie/Search.aspx",
+ "search_form": "https://www.tearma.ie/Home.aspx",
+ "search_url_get_params": "term={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/tyda-sv-SE/favicon.ico b/comm/mail/components/search/extensions/tyda-sv-SE/favicon.ico
new file mode 100644
index 0000000000..7415cbb160
--- /dev/null
+++ b/comm/mail/components/search/extensions/tyda-sv-SE/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/tyda-sv-SE/manifest.json b/comm/mail/components/search/extensions/tyda-sv-SE/manifest.json
new file mode 100644
index 0000000000..2e13ba2a52
--- /dev/null
+++ b/comm/mail/components/search/extensions/tyda-sv-SE/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Tyda.se",
+ "description": "Tyda.se, lexikon, ordlista och översättning.",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "tyda-sv-SE@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Tyda.se",
+ "search_url": "https://tyda.se",
+ "search_form": "https://tyda.se",
+ "search_url_get_params": "w={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/vatera/favicon.ico b/comm/mail/components/search/extensions/vatera/favicon.ico
new file mode 100644
index 0000000000..5b02f16cb9
--- /dev/null
+++ b/comm/mail/components/search/extensions/vatera/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/vatera/manifest.json b/comm/mail/components/search/extensions/vatera/manifest.json
new file mode 100644
index 0000000000..af690f27e0
--- /dev/null
+++ b/comm/mail/components/search/extensions/vatera/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Vatera.hu",
+ "description": "Keresés a Vatera.hu piacterén",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "vatera@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Vatera.hu",
+ "encoding": "ISO-8859-2",
+ "search_url": "https://www.vatera.hu/listings/index.php",
+ "search_form": "https://www.vatera.hu/",
+ "search_url_get_params": "q={searchTerms}&c=0&td=on"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json
new file mode 100644
index 0000000000..04f669eed9
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/NN/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (nn)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, det frie oppslagsverket"
+ },
+ "searchUrl": {
+ "message": "https://nn.wikipedia.org/wiki/Spesial:Søk"
+ },
+ "searchForm": {
+ "message": "https://nn.wikipedia.org/wiki/Spesial:Søk?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://nn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json
new file mode 100644
index 0000000000..243806e727
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/NO/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (no)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, den frie encyklopedi"
+ },
+ "searchUrl": {
+ "message": "https://no.wikipedia.org/wiki/Spesial:Søk"
+ },
+ "searchForm": {
+ "message": "https://no.wikipedia.org/wiki/Spesial:Søk?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://no.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json
new file mode 100644
index 0000000000..9314533645
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/af/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (af)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, die vrye ensiklopedie"
+ },
+ "searchUrl": {
+ "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek"
+ },
+ "searchForm": {
+ "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://af.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json
new file mode 100644
index 0000000000..ec6fdbf01e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/an/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Biquipedia (an)"
+ },
+ "extensionDescription": {
+ "message": "A enciclopedia Libre"
+ },
+ "searchUrl": {
+ "message": "https://an.wikipedia.org/wiki/Especial:Mirar"
+ },
+ "searchForm": {
+ "message": "https://an.wikipedia.org/wiki/Especial:Mirar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://an.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json
new file mode 100644
index 0000000000..7b5b0a2cab
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ar/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ويكيبيديا (ar)"
+ },
+ "extensionDescription": {
+ "message": "ويكيبيديا (ar)"
+ },
+ "searchUrl": {
+ "message": "https://ar.wikipedia.org/wiki/خاص:بحث"
+ },
+ "searchForm": {
+ "message": "https://ar.wikipedia.org/wiki/خاص:بحث?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ar.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json
new file mode 100644
index 0000000000..ecdffd517e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/as/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (as)"
+ },
+ "extensionDescription": {
+ "message": "ৱিকিপিডিয়া, এখন মুক্ত বিশ্বকোষ"
+ },
+ "searchUrl": {
+ "message": "https://as.wikipedia.org/wiki/বিশেষ:সন্ধান"
+ },
+ "searchForm": {
+ "message": "https://as.wikipedia.org/wiki/বিশেষ:সন্ধান?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://as.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json
new file mode 100644
index 0000000000..c6b9c18c88
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ast/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Uiquipedia (ast)"
+ },
+ "extensionDescription": {
+ "message": "La enciclopedia llibre"
+ },
+ "searchUrl": {
+ "message": "https://ast.wikipedia.org/wiki/Especial:Gueta"
+ },
+ "searchForm": {
+ "message": "https://ast.wikipedia.org/wiki/Especial:Gueta?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ast.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json
new file mode 100644
index 0000000000..638af3d874
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/az/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipediya (az)"
+ },
+ "extensionDescription": {
+ "message": "Vikipediya, açıq ensiklopediya"
+ },
+ "searchUrl": {
+ "message": "https://az.wikipedia.org/wiki/Xüsusi:Axtar"
+ },
+ "searchForm": {
+ "message": "https://az.wikipedia.org/wiki/Xüsusi:Axtar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://az.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json
new file mode 100644
index 0000000000..6a399c8268
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/be-tarask/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Вікіпэдыя (be-tarask)"
+ },
+ "extensionDescription": {
+ "message": "Вікіпэдыя, вольная энцыкляпэдыя"
+ },
+ "searchUrl": {
+ "message": "https://be-tarask.wikipedia.org/wiki/Спэцыяльныя:Пошук"
+ },
+ "searchForm": {
+ "message": "https://be-tarask.wikipedia.org/wiki/Спэцыяльныя:Пошук?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://be-tarask.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json
new file mode 100644
index 0000000000..651ecfa2aa
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/be/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Вікіпедыя (be)"
+ },
+ "extensionDescription": {
+ "message": "Вікіпедыя, свабодная энцыклапедыя"
+ },
+ "searchUrl": {
+ "message": "https://be.wikipedia.org/wiki/Адмысловае:Search"
+ },
+ "searchForm": {
+ "message": "https://be.wikipedia.org/wiki/Адмысловае:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://be.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json
new file mode 100644
index 0000000000..4061aa8e94
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/bg/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Уикипедия (bg)"
+ },
+ "extensionDescription": {
+ "message": "Уикипедия, свободната енциклоподия"
+ },
+ "searchUrl": {
+ "message": "https://bg.wikipedia.org/wiki/Специални:Търсене"
+ },
+ "searchForm": {
+ "message": "https://bg.wikipedia.org/wiki/Специални:Търсене?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://bg.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json
new file mode 100644
index 0000000000..c41a811f81
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/bn/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "উইকিপিডিয়া (bn)"
+ },
+ "extensionDescription": {
+ "message": "উইকিপিডিয়া, মুক্ত বিশ্বকোষ"
+ },
+ "searchUrl": {
+ "message": "https://bn.wikipedia.org/wiki/বিশেষ:Search"
+ },
+ "searchForm": {
+ "message": "https://bn.wikipedia.org/wiki/বিশেষ:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://bn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json
new file mode 100644
index 0000000000..7f59a6dd26
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/br/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (br)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, an holloueziadur digor"
+ },
+ "searchUrl": {
+ "message": "https://br.wikipedia.org/wiki/Dibar:Klask"
+ },
+ "searchForm": {
+ "message": "https://br.wikipedia.org/wiki/Dibar:Klask?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://br.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json
new file mode 100644
index 0000000000..06e27829f3
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/bs/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (bs)"
+ },
+ "extensionDescription": {
+ "message": "Slobodna enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://bs.wikipedia.org/wiki/Posebno:Pretraga"
+ },
+ "searchForm": {
+ "message": "https://bs.wikipedia.org/wiki/Posebno:Pretraga?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://bs.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json
new file mode 100644
index 0000000000..22ab489096
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ca/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Viquipèdia (ca)"
+ },
+ "extensionDescription": {
+ "message": "L'enciclopèdia lliure"
+ },
+ "searchUrl": {
+ "message": "https://ca.wikipedia.org/wiki/Especial:Cerca"
+ },
+ "searchForm": {
+ "message": "https://ca.wikipedia.org/wiki/Especial:Cerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ca.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json
new file mode 100644
index 0000000000..1e1a76452c
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/crh/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipediya (crh)"
+ },
+ "extensionDescription": {
+ "message": "Vikipediya, Azat Entsiklopediya"
+ },
+ "searchUrl": {
+ "message": "https://crh.wikipedia.org/wiki/Mahsus:Search"
+ },
+ "searchForm": {
+ "message": "https://crh.wikipedia.org/wiki/Mahsus:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://crh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json
new file mode 100644
index 0000000000..62915e52a5
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/cy/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wicipedia (cy)"
+ },
+ "extensionDescription": {
+ "message": "Wicipedia, Y Gwyddioniadur Rhydd"
+ },
+ "searchUrl": {
+ "message": "https://cy.wikipedia.org/wiki/Arbennig:Search"
+ },
+ "searchForm": {
+ "message": "https://cy.wikipedia.org/wiki/Arbennig:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://cy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json
new file mode 100644
index 0000000000..53b17f885a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/cz/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedie (cs)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, svobodná encyclopedie"
+ },
+ "searchUrl": {
+ "message": "https://cs.wikipedia.org/wiki/Speciální:Hledání"
+ },
+ "searchForm": {
+ "message": "https://cs.wikipedia.org/wiki/Speciální:Hledání?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://cs.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json
new file mode 100644
index 0000000000..5deb79d125
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/da/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (da)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, den frie encyklopædi"
+ },
+ "searchUrl": {
+ "message": "https://da.wikipedia.org/wiki/Speciel:Søgning"
+ },
+ "searchForm": {
+ "message": "https://da.wikipedia.org/wiki/Speciel:Søgning?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://da.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json
new file mode 100644
index 0000000000..c4d2dd558c
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/de/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (de)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, die freie Enzyklopädie"
+ },
+ "searchUrl": {
+ "message": "https://de.wikipedia.org/wiki/Spezial:Suche"
+ },
+ "searchForm": {
+ "message": "https://de.wikipedia.org/wiki/Spezial:Suche?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://de.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json
new file mode 100644
index 0000000000..e8cb687084
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/dsb/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (dsb)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, lichotna encyklopedija"
+ },
+ "searchUrl": {
+ "message": "https://dsb.wikipedia.org/wiki/Specialne:Pytaś"
+ },
+ "searchForm": {
+ "message": "https://dsb.wikipedia.org/wiki/Specialne:Pytaś?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://dsb.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json
new file mode 100644
index 0000000000..5b8c494f98
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/el/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (el)"
+ },
+ "extensionDescription": {
+ "message": "Βικιπαίδεια, η ελεύθερη εγκυκλοπαίδεια"
+ },
+ "searchUrl": {
+ "message": "https://el.wikipedia.org/wiki/Ειδικό:Αναζήτηση"
+ },
+ "searchForm": {
+ "message": "https://el.wikipedia.org/wiki/Ειδικό:Αναζήτηση?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://el.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json
new file mode 100644
index 0000000000..56c69e5c32
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/en/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (en)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the Free Encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://en.wikipedia.org/wiki/Special:Search"
+ },
+ "searchForm": {
+ "message": "https://en.wikipedia.org/wiki/Special:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://en.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json
new file mode 100644
index 0000000000..66b3212e5b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/eo/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipedio (eo)"
+ },
+ "extensionDescription": {
+ "message": "Vikipedio, la libera enciklopedio"
+ },
+ "searchUrl": {
+ "message": "https://eo.wikipedia.org/wiki/Specialaĵo:Serĉi"
+ },
+ "searchForm": {
+ "message": "https://eo.wikipedia.org/wiki/Specialaĵo:Serĉi?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://eo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json
new file mode 100644
index 0000000000..ff797d0834
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/es/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (es)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, la enciclopedia libre"
+ },
+ "searchUrl": {
+ "message": "https://es.wikipedia.org/wiki/Especial:Buscar"
+ },
+ "searchForm": {
+ "message": "https://es.wikipedia.org/wiki/Especial:Buscar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://es.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json
new file mode 100644
index 0000000000..25cf22893b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/et/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipeedia (et)"
+ },
+ "extensionDescription": {
+ "message": "Vikipeedia, vaba entsüklopeedia"
+ },
+ "searchUrl": {
+ "message": "https://et.wikipedia.org/wiki/Eri:Otsimine"
+ },
+ "searchForm": {
+ "message": "https://et.wikipedia.org/wiki/Eri:Otsimine?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://et.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json
new file mode 100644
index 0000000000..eea1c365d8
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/eu/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (eu)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, entziklopedia askea"
+ },
+ "searchUrl": {
+ "message": "https://eu.wikipedia.org/wiki/Berezi:Bilatu"
+ },
+ "searchForm": {
+ "message": "https://eu.wikipedia.org/wiki/Berezi:Bilatu?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://eu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json
new file mode 100644
index 0000000000..a48eca478f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fa/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ویکی‌پدیا (fa)"
+ },
+ "extensionDescription": {
+ "message": "ویکی‌پدیا، دانشنامهٔ آزاد"
+ },
+ "searchUrl": {
+ "message": "https://fa.wikipedia.org/wiki/ویژه:جستجو"
+ },
+ "searchForm": {
+ "message": "https://fa.wikipedia.org/wiki/ویژه:جستجو?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fa.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json
new file mode 100644
index 0000000000..696a953ca1
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fi/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (fi)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia (fi), vapaa tietosanakirja"
+ },
+ "searchUrl": {
+ "message": "https://fi.wikipedia.org/wiki/Toiminnot:Haku"
+ },
+ "searchForm": {
+ "message": "https://fi.wikipedia.org/wiki/Toiminnot:Haku?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json
new file mode 100644
index 0000000000..80612181ad
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipédia (fr)"
+ },
+ "extensionDescription": {
+ "message": "Wikipédia, l'encyclopédie libre"
+ },
+ "searchUrl": {
+ "message": "https://fr.wikipedia.org/wiki/Spécial:Recherche"
+ },
+ "searchForm": {
+ "message": "https://fr.wikipedia.org/wiki/Spécial:Recherche?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json
new file mode 100644
index 0000000000..4767aad436
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/fy-NL/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedy (fy)"
+ },
+ "extensionDescription": {
+ "message": "De fergese ensyklopedy"
+ },
+ "searchUrl": {
+ "message": "https://fy.wikipedia.org/wiki/Wiki:Sykje"
+ },
+ "searchForm": {
+ "message": "https://fy.wikipedia.org/wiki/Wiki:Sykje?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://fy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json
new file mode 100644
index 0000000000..a9330bb066
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ga-IE/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vicipéid (ga)"
+ },
+ "extensionDescription": {
+ "message": "Vicipéid, an Chiclipéid Shaor"
+ },
+ "searchUrl": {
+ "message": "https://ga.wikipedia.org/wiki/Speisialta:Search"
+ },
+ "searchForm": {
+ "message": "https://ga.wikipedia.org/wiki/Speisialta:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ga.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json
new file mode 100644
index 0000000000..3b389e12c8
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gd/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Uicipeid (gd)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, An leabhar mòr-eòlais"
+ },
+ "searchUrl": {
+ "message": "https://gd.wikipedia.org/wiki/Sònraichte:Search"
+ },
+ "searchForm": {
+ "message": "https://gd.wikipedia.org/wiki/Sònraichte:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gd.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json
new file mode 100644
index 0000000000..18cc3d73f6
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (gl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, a enciclopedia libre"
+ },
+ "searchUrl": {
+ "message": "https://gl.wikipedia.org/wiki/Especial:Procurar"
+ },
+ "searchForm": {
+ "message": "https://gl.wikipedia.org/wiki/Especial:Procurar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json
new file mode 100644
index 0000000000..e914aa66bb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gn/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipetã (gn)"
+ },
+ "extensionDescription": {
+ "message": "Vikipetã, opaite tembikuaa hekosãsóva renda"
+ },
+ "searchUrl": {
+ "message": "https://gn.wikipedia.org/wiki/Mba'echĩchĩ:Buscar"
+ },
+ "searchForm": {
+ "message": "https://gn.wikipedia.org/wiki/Mba'echĩchĩ:Buscar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json
new file mode 100644
index 0000000000..f268ccb6af
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/gu/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "વિકિપીડિયા (gu)"
+ },
+ "extensionDescription": {
+ "message": "વીકીપીડિયા, મુક્ત એનસાયક્લોપીડિયા"
+ },
+ "searchUrl": {
+ "message": "https://gu.wikipedia.org/wiki/વિશેષ:શોધ"
+ },
+ "searchForm": {
+ "message": "https://gu.wikipedia.org/wiki/વિશેષ:શોધ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://gu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json
new file mode 100644
index 0000000000..f8a548f72c
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/he/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ויקיפדיה"
+ },
+ "extensionDescription": {
+ "message": "ויקיפדיה"
+ },
+ "searchUrl": {
+ "message": "https://he.wikipedia.org/wiki/מיוחד:חיפוש"
+ },
+ "searchForm": {
+ "message": "https://he.wikipedia.org/wiki/מיוחד:חיפוש?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://he.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json
new file mode 100644
index 0000000000..7db4ed195f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hi/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "विकिपीडिया (hi)"
+ },
+ "extensionDescription": {
+ "message": "विकिपीडिया (हिन्दी)"
+ },
+ "searchUrl": {
+ "message": "https://hi.wikipedia.org/wiki/विशेष:खोज"
+ },
+ "searchForm": {
+ "message": "https://hi.wikipedia.org/wiki/विशेष:खोज?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json
new file mode 100644
index 0000000000..f02f694c9a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (hr)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, slobodna enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://hr.wikipedia.org/wiki/Posebno:Traži"
+ },
+ "searchForm": {
+ "message": "https://hr.wikipedia.org/wiki/Posebno:Traži?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json
new file mode 100644
index 0000000000..a459ffbe46
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hsb/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (hsb)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, swobodna encyklopedija"
+ },
+ "searchUrl": {
+ "message": "https://hsb.wikipedia.org/wiki/Specialnje:Pytać"
+ },
+ "searchForm": {
+ "message": "https://hsb.wikipedia.org/wiki/Specialnje:Pytać?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hsb.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json
new file mode 100644
index 0000000000..37e725d687
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hu/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipédia (hu)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, a szabad enciklopédia"
+ },
+ "searchUrl": {
+ "message": "https://hu.wikipedia.org/wiki/Speciális:Keresés"
+ },
+ "searchForm": {
+ "message": "https://hu.wikipedia.org/wiki/Speciális:Keresés?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json
new file mode 100644
index 0000000000..a6d527cc4e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/hy/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (hy)"
+ },
+ "extensionDescription": {
+ "message": "Վիքիփեդիա՝ ազատ հանրագիտարան"
+ },
+ "searchUrl": {
+ "message": "https://hy.wikipedia.org/wiki/Սպասարկող:Որոնել"
+ },
+ "searchForm": {
+ "message": "https://hy.wikipedia.org/wiki/Սպասարկող:Որոնել?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://hy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json
new file mode 100644
index 0000000000..fbca585c30
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ia/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ia)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, le encyclopedia libere"
+ },
+ "searchUrl": {
+ "message": "https://ia.wikipedia.org/wiki/Special:Recerca"
+ },
+ "searchForm": {
+ "message": "https://ia.wikipedia.org/wiki/Special:Recerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ia.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json
new file mode 100644
index 0000000000..608dad05b7
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/id/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (id)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, ensiklopedia bebas"
+ },
+ "searchUrl": {
+ "message": "https://id.wikipedia.org/wiki/Istimewa:Pencarian"
+ },
+ "searchForm": {
+ "message": "https://id.wikipedia.org/wiki/Istimewa:Pencarian?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://id.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json
new file mode 100644
index 0000000000..f33e0dd015
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/is/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (is)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://is.wikipedia.org/wiki/Kerfissíða:Leit"
+ },
+ "searchForm": {
+ "message": "https://is.wikipedia.org/wiki/Kerfissíða:Leit?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://is.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json
new file mode 100644
index 0000000000..27cff3c07b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/it/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (it)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, l'enciclopedia libera"
+ },
+ "searchUrl": {
+ "message": "https://it.wikipedia.org/wiki/Speciale:Ricerca"
+ },
+ "searchForm": {
+ "message": "https://it.wikipedia.org/wiki/Speciale:Ricerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://it.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json
new file mode 100644
index 0000000000..84df085d54
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ja/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ja)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia - フリー百科事典"
+ },
+ "searchUrl": {
+ "message": "https://ja.wikipedia.org/wiki/特別:検索"
+ },
+ "searchForm": {
+ "message": "https://ja.wikipedia.org/wiki/特別:検索?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ja.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json
new file mode 100644
index 0000000000..efe876cded
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ka/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ვიკიპედია (ka)"
+ },
+ "extensionDescription": {
+ "message": "ვიკიპედია, თავისუფალი ენციკლოპედია"
+ },
+ "searchUrl": {
+ "message": "https://ka.wikipedia.org/wiki/სპეციალური:ძიება"
+ },
+ "searchForm": {
+ "message": "https://ka.wikipedia.org/wiki/სპეციალური:ძიება?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ka.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json
new file mode 100644
index 0000000000..375b69d098
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kab/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (kab)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, tasanayt tilellit"
+ },
+ "searchUrl": {
+ "message": "https://kab.wikipedia.org/wiki/Uslig:Search"
+ },
+ "searchForm": {
+ "message": "https://kab.wikipedia.org/wiki/Uslig:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://kab.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json
new file mode 100644
index 0000000000..ae3ce7cc1a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Уикипедия (kk)"
+ },
+ "extensionDescription": {
+ "message": "Уикипедия (kk)"
+ },
+ "searchUrl": {
+ "message": "https://kk.wikipedia.org/wiki/Арнайы:Іздеу"
+ },
+ "searchForm": {
+ "message": "https://kk.wikipedia.org/wiki/Арнайы:Іздеу?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://kk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json
new file mode 100644
index 0000000000..ca51223b15
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/km/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "វីគីភីឌា (km)"
+ },
+ "extensionDescription": {
+ "message": "វីគីភីឌា សព្វ​វចនា​ធិប្បាយ​សេរី"
+ },
+ "searchUrl": {
+ "message": "https://km.wikipedia.org/wiki/ពិសេស:ស្វែងរក"
+ },
+ "searchForm": {
+ "message": "https://km.wikipedia.org/wiki/ពិសេស:ស្វែងរក?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://km.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json
new file mode 100644
index 0000000000..20958768cb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kn/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (kn)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://kn.wikipedia.org/wiki/ವಿಶೇಷ:Search"
+ },
+ "searchForm": {
+ "message": "https://kn.wikipedia.org/wiki/ವಿಶೇಷ:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://kn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json
new file mode 100644
index 0000000000..9504c3ffb9
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/kr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "위키백과 (ko)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://ko.wikipedia.org/wiki/특수기능:찾기"
+ },
+ "searchForm": {
+ "message": "https://ko.wikipedia.org/wiki/특수기능:찾기?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ko.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json
new file mode 100644
index 0000000000..83a20b3776
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lij/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (lij)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, l'enciclopedia libera"
+ },
+ "searchUrl": {
+ "message": "https://lij.wikipedia.org/wiki/Speçiale:Riçerca"
+ },
+ "searchForm": {
+ "message": "https://lij.wikipedia.org/wiki/Speçiale:Riçerca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lij.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json
new file mode 100644
index 0000000000..30f9b0ff26
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lo/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ວິກິພີເດຍ (lo)"
+ },
+ "extensionDescription": {
+ "message": "ວິກິພີເດຍ, ສາລານຸກົມເສລີ"
+ },
+ "searchUrl": {
+ "message": "https://lo.wikipedia.org/wiki/ພິເສດ:ຊອກຫາ"
+ },
+ "searchForm": {
+ "message": "https://lo.wikipedia.org/wiki/ພິເສດ:ຊອກຫາ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json
new file mode 100644
index 0000000000..93ce604144
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lt/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (lt)"
+ },
+ "extensionDescription": {
+ "message": "Vikipedija, laisvoji enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://lt.wikipedia.org/wiki/Specialus:Paieška"
+ },
+ "searchForm": {
+ "message": "https://lt.wikipedia.org/wiki/Specialus:Paieška?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lt.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json
new file mode 100644
index 0000000000..0456f31dba
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ltg/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipedeja (ltg)"
+ },
+ "extensionDescription": {
+ "message": "Vikipēdija, breivuo eņciklopedeja"
+ },
+ "searchUrl": {
+ "message": "https://ltg.wikipedia.org/wiki/Seviškuo:Search"
+ },
+ "searchForm": {
+ "message": "https://ltg.wikipedia.org/wiki/Seviškuo:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ltg.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json
new file mode 100644
index 0000000000..90b542f4b4
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/lv/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipēdija"
+ },
+ "extensionDescription": {
+ "message": "Vikipēdija, brīvā enciklopēdija"
+ },
+ "searchUrl": {
+ "message": "https://lv.wikipedia.org/wiki/Special:Search"
+ },
+ "searchForm": {
+ "message": "https://lv.wikipedia.org/wiki/Special:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://lv.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json
new file mode 100644
index 0000000000..ae826197ff
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/mk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Википедија (mk)"
+ },
+ "extensionDescription": {
+ "message": "Википедија, слободната енциклопедија"
+ },
+ "searchUrl": {
+ "message": "https://mk.wikipedia.org/wiki/Специјална:Барај"
+ },
+ "searchForm": {
+ "message": "https://mk.wikipedia.org/wiki/Специјална:Барај?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://mk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json
new file mode 100644
index 0000000000..9f4b397904
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ml/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "വിക്കിപീഡിയ (ml)"
+ },
+ "extensionDescription": {
+ "message": "വിക്കിപീഡിയ, സ്വതന്ത്ര സര്‍വ്വവിജ്ഞാനകോശം "
+ },
+ "searchUrl": {
+ "message": "https://ml.wikipedia.org/wiki/പ്രത്യേകം:അന്വേഷണം"
+ },
+ "searchForm": {
+ "message": "https://ml.wikipedia.org/wiki/പ്രത്യേകം:അന്വേഷണം?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ml.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json
new file mode 100644
index 0000000000..42bd8e2426
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/mr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "विकिपीडिया (mr)"
+ },
+ "extensionDescription": {
+ "message": "विकिपीडिया, मोफत माहितीकोष"
+ },
+ "searchUrl": {
+ "message": "https://mr.wikipedia.org/wiki/विशेष:शोधा"
+ },
+ "searchForm": {
+ "message": "https://mr.wikipedia.org/wiki/विशेष:शोधा?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://mr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json
new file mode 100644
index 0000000000..e804b1796a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ms/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ms)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, ensiklopedia bebas"
+ },
+ "searchUrl": {
+ "message": "https://ms.wikipedia.org/wiki/Khas:Gelintar"
+ },
+ "searchForm": {
+ "message": "https://ms.wikipedia.org/wiki/Khas:Gelintar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ms.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json
new file mode 100644
index 0000000000..e0fda1a466
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/my/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (my)"
+ },
+ "extensionDescription": {
+ "message": "အခမဲ့လွတ်လပ်စွယ်စုံကျမ်း"
+ },
+ "searchUrl": {
+ "message": "https://my.wikipedia.org/wiki/Special:Search"
+ },
+ "searchForm": {
+ "message": "https://my.wikipedia.org/wiki/Special:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://my.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json
new file mode 100644
index 0000000000..1d549a943e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ne/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "विकिपीडिया (ne)"
+ },
+ "extensionDescription": {
+ "message": "विकिपिडिया एक स्वतन्त्र विश्वकोष"
+ },
+ "searchUrl": {
+ "message": "https://ne.wikipedia.org/wiki/विशेष:Search"
+ },
+ "searchForm": {
+ "message": "https://ne.wikipedia.org/wiki/विशेष:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ne.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json
new file mode 100644
index 0000000000..2dda1f6deb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/nl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (nl)"
+ },
+ "extensionDescription": {
+ "message": "De vrije encyclopedie"
+ },
+ "searchUrl": {
+ "message": "https://nl.wikipedia.org/wiki/Speciaal:Zoeken"
+ },
+ "searchForm": {
+ "message": "https://nl.wikipedia.org/wiki/Speciaal:Zoeken?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://nl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json
new file mode 100644
index 0000000000..b341752c63
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/oc/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipèdia (oc)"
+ },
+ "extensionDescription": {
+ "message": "Wikipèdia, l'enciclopèdia liura"
+ },
+ "searchUrl": {
+ "message": "https://oc.wikipedia.org/wiki/Especial:Recèrca"
+ },
+ "searchForm": {
+ "message": "https://oc.wikipedia.org/wiki/Especial:Recèrca?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://oc.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json
new file mode 100644
index 0000000000..b07957a4b7
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/or/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (or)"
+ },
+ "extensionDescription": {
+ "message": "ୱିକିପିଡ଼ିଆ (ଓଡ଼ିଆ)"
+ },
+ "searchUrl": {
+ "message": "https://or.wikipedia.org/wiki/ବିଶେଷ:ଖୋଜନ୍ତୁ"
+ },
+ "searchForm": {
+ "message": "https://or.wikipedia.org/wiki/ବିଶେଷ:ଖୋଜନ୍ତୁ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://or.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json
new file mode 100644
index 0000000000..07804ca265
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/pa/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (pa)"
+ },
+ "extensionDescription": {
+ "message": "ਵਿਕਿਪੀਡਿਆ, ਮੁਫ਼ਤ/ਮੁਕਤ ਸ਼ਬਦਕੋਸ਼"
+ },
+ "searchUrl": {
+ "message": "https://pa.wikipedia.org/wiki/ਖ਼ਾਸ:ਖੋਜੋ"
+ },
+ "searchForm": {
+ "message": "https://pa.wikipedia.org/wiki/ਖ਼ਾਸ:ਖੋਜੋ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://pa.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json
new file mode 100644
index 0000000000..3e7704b21a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/pl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (pl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, wolna encyklopedia"
+ },
+ "searchUrl": {
+ "message": "https://pl.wikipedia.org/wiki/Specjalna:Szukaj"
+ },
+ "searchForm": {
+ "message": "https://pl.wikipedia.org/wiki/Specjalna:Szukaj?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://pl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json
new file mode 100644
index 0000000000..8e9bc20c77
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/pt/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (pt)"
+ },
+ "extensionDescription": {
+ "message": "Wikipédia, a enciclopédia livre"
+ },
+ "searchUrl": {
+ "message": "https://pt.wikipedia.org/wiki/Especial:Pesquisar"
+ },
+ "searchForm": {
+ "message": "https://pt.wikipedia.org/wiki/Especial:Pesquisar?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://pt.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json
new file mode 100644
index 0000000000..889657ad7e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/rm/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (rm)"
+ },
+ "extensionDescription": {
+ "message": "Vichipedia, l'enciclopedia libra"
+ },
+ "searchUrl": {
+ "message": "https://rm.wikipedia.org/wiki/Spezial:Search"
+ },
+ "searchForm": {
+ "message": "https://rm.wikipedia.org/wiki/Spezial:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://rm.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json
new file mode 100644
index 0000000000..8aac1e2244
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ro/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (ro)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, enciclopedia liberă"
+ },
+ "searchUrl": {
+ "message": "https://ro.wikipedia.org/wiki/Special:Căutare"
+ },
+ "searchForm": {
+ "message": "https://ro.wikipedia.org/wiki/Special:Căutare?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ro.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json
new file mode 100644
index 0000000000..49243790a6
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ru/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Википедия (ru)"
+ },
+ "extensionDescription": {
+ "message": "Википедия, свободная энциклопедия"
+ },
+ "searchUrl": {
+ "message": "https://ru.wikipedia.org/wiki/Служебная:Поиск"
+ },
+ "searchForm": {
+ "message": "https://ru.wikipedia.org/wiki/Служебная:Поиск?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ru.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json
new file mode 100644
index 0000000000..dea75a5308
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/si/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (si)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, the free encyclopedia"
+ },
+ "searchUrl": {
+ "message": "https://si.wikipedia.org/wiki/විශේෂ:ගවේෂණය"
+ },
+ "searchForm": {
+ "message": "https://si.wikipedia.org/wiki/විශේෂ:ගවේෂණය?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://si.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json
new file mode 100644
index 0000000000..3cfa642df7
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipédia (sk)"
+ },
+ "extensionDescription": {
+ "message": "Wikipédia, slobodná a otvorená encyklopédia"
+ },
+ "searchUrl": {
+ "message": "https://sk.wikipedia.org/wiki/Špeciálne:Hľadanie"
+ },
+ "searchForm": {
+ "message": "https://sk.wikipedia.org/wiki/Špeciálne:Hľadanie?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json
new file mode 100644
index 0000000000..0e9b8433a4
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedija (sl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedija, prosta enciklopedija"
+ },
+ "searchUrl": {
+ "message": "https://sl.wikipedia.org/wiki/Posebno:Iskanje"
+ },
+ "searchForm": {
+ "message": "https://sl.wikipedia.org/wiki/Posebno:Iskanje?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json
new file mode 100644
index 0000000000..6d6240b54f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sq/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (sq)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, enciklopedia e lirë"
+ },
+ "searchUrl": {
+ "message": "https://sq.wikipedia.org/wiki/Speciale:Kërkim"
+ },
+ "searchForm": {
+ "message": "https://sq.wikipedia.org/wiki/Speciale:Kërkim?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sq.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json
new file mode 100644
index 0000000000..cf1558ffe9
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Википедија (sr)"
+ },
+ "extensionDescription": {
+ "message": "Претрага Википедије на српском језику"
+ },
+ "searchUrl": {
+ "message": "https://sr.wikipedia.org/wiki/Посебно:Претражи"
+ },
+ "searchForm": {
+ "message": "https://sr.wikipedia.org/wiki/Посебно:Претражи?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json
new file mode 100644
index 0000000000..4912d25364
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/sv-SE/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (sv)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, den fria encyklopedin"
+ },
+ "searchUrl": {
+ "message": "https://sv.wikipedia.org/wiki/Special:Sök"
+ },
+ "searchForm": {
+ "message": "https://sv.wikipedia.org/wiki/Special:Sök?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://sv.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json
new file mode 100644
index 0000000000..b6de753a1f
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ta/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "விக்கிப்பீடியா (ta)"
+ },
+ "extensionDescription": {
+ "message": "விக்கிப்பீடியா (ta)"
+ },
+ "searchUrl": {
+ "message": "https://ta.wikipedia.org/wiki/சிறப்பு:Search"
+ },
+ "searchForm": {
+ "message": "https://ta.wikipedia.org/wiki/சிறப்பு:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ta.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json
new file mode 100644
index 0000000000..f9352844d1
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/te/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "వికీపీడియా (te)"
+ },
+ "extensionDescription": {
+ "message": "వికీపీడియా (te)"
+ },
+ "searchUrl": {
+ "message": "https://te.wikipedia.org/wiki/ప్రత్యేక:అన్వేషణ"
+ },
+ "searchForm": {
+ "message": "https://te.wikipedia.org/wiki/ప్రత్యేక:అన్వేషణ?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://te.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json
new file mode 100644
index 0000000000..0176b84b7b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/th/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "วิกิพีเดีย"
+ },
+ "extensionDescription": {
+ "message": "วิกิพีเดีย สารานุกรมเสรี"
+ },
+ "searchUrl": {
+ "message": "https://th.wikipedia.org/wiki/พิเศษ:ค้นหา"
+ },
+ "searchForm": {
+ "message": "https://th.wikipedia.org/wiki/พิเศษ:ค้นหา?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://th.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json
new file mode 100644
index 0000000000..a5faae07cd
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/tl/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (tl)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, ang malayang ensiklopedya"
+ },
+ "searchUrl": {
+ "message": "https://tl.wikipedia.org/wiki/Natatangi:Maghanap"
+ },
+ "searchForm": {
+ "message": "https://tl.wikipedia.org/wiki/Natatangi:Maghanap?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://tl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json
new file mode 100644
index 0000000000..7551d615f0
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/tr/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (tr)"
+ },
+ "extensionDescription": {
+ "message": "Vikipedi, özgür ansiklopedi"
+ },
+ "searchUrl": {
+ "message": "https://tr.wikipedia.org/wiki/Özel:Ara"
+ },
+ "searchForm": {
+ "message": "https://tr.wikipedia.org/wiki/Özel:Ara?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://tr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json
new file mode 100644
index 0000000000..c81490394b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/uk/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Вікіпедія (uk)"
+ },
+ "extensionDescription": {
+ "message": "Вікіпедія, вільна енциклопедія"
+ },
+ "searchUrl": {
+ "message": "https://uk.wikipedia.org/wiki/Спеціальна:Пошук"
+ },
+ "searchForm": {
+ "message": "https://uk.wikipedia.org/wiki/Спеціальна:Пошук?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://uk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json
new file mode 100644
index 0000000000..f27436e3a4
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/ur/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "ویکیپیڈیا (ur)"
+ },
+ "extensionDescription": {
+ "message": "ویکیپیڈیا آزاد دائرۃ المعارف"
+ },
+ "searchUrl": {
+ "message": "https://ur.wikipedia.org/wiki/خاص:تلاش"
+ },
+ "searchForm": {
+ "message": "https://ur.wikipedia.org/wiki/خاص:تلاش?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://ur.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json
new file mode 100644
index 0000000000..eff11c31e2
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/uz/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Vikipediya (uz)"
+ },
+ "extensionDescription": {
+ "message": "Vikipediya, ochiq ensiklopediya"
+ },
+ "searchUrl": {
+ "message": "https://uz.wikipedia.org/wiki/Maxsus:Search"
+ },
+ "searchForm": {
+ "message": "https://uz.wikipedia.org/wiki/Maxsus:Search?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://uz.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json
new file mode 100644
index 0000000000..cfab54090e
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/vi/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (vi)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, bách khoa toàn thư mở"
+ },
+ "searchUrl": {
+ "message": "https://vi.wikipedia.org/wiki/Đặc_biệt:Tìm_kiếm"
+ },
+ "searchForm": {
+ "message": "https://vi.wikipedia.org/wiki/Đặc_biệt:Tìm_kiếm?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://vi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json
new file mode 100644
index 0000000000..43f133c54b
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/wo/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (wo)"
+ },
+ "extensionDescription": {
+ "message": "Wikipedia, Jimbulang bu Ubbeeku bi"
+ },
+ "searchUrl": {
+ "message": "https://wo.wikipedia.org/wiki/Jagleel:Ceet"
+ },
+ "searchForm": {
+ "message": "https://wo.wikipedia.org/wiki/Jagleel:Ceet?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://wo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json
new file mode 100644
index 0000000000..840677e3dd
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/zh-CN/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "维基百科"
+ },
+ "extensionDescription": {
+ "message": "维基百科,自由的百科全书"
+ },
+ "searchUrl": {
+ "message": "https://zh.wikipedia.org/wiki/Special:搜索"
+ },
+ "searchForm": {
+ "message": "https://zh.wikipedia.org/wiki/Special:搜索?search={searchTerms}&sourceid=Mozilla-search"
+ },
+ "suggestUrl": {
+ "message": "https://zh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json b/comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json
new file mode 100644
index 0000000000..60151f4265
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/_locales/zh-TW/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikipedia (zh)"
+ },
+ "extensionDescription": {
+ "message": "維基百科,自由的百科全書"
+ },
+ "searchUrl": {
+ "message": "https://zh.wikipedia.org/wiki/Special:搜索"
+ },
+ "searchForm": {
+ "message": "https://zh.wikipedia.org/wiki/Special:搜索?search={searchTerms}&sourceid=Mozilla-search&variant=zh-tw"
+ },
+ "suggestUrl": {
+ "message": "https://zh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}&sourceid=Mozilla-search&variant=zh-tw"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wikipedia/favicon.ico b/comm/mail/components/search/extensions/wikipedia/favicon.ico
new file mode 100644
index 0000000000..4314071e24
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/wikipedia/manifest.json b/comm/mail/components/search/extensions/wikipedia/manifest.json
new file mode 100644
index 0000000000..f26061aadb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wikipedia/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "wikipedia@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "en",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json b/comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json
new file mode 100644
index 0000000000..612ef8c44a
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/_locales/oc/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "Wikiccionari (oc)"
+ },
+ "extensionDescription": {
+ "message": "Wikiccionari, lo diccionari liure"
+ },
+ "searchUrl": {
+ "message": "https://oc.wiktionary.org/wiki/Especial:Recèrca"
+ },
+ "searchForm": {
+ "message": "https://oc.wiktionary.org/wiki/Especial:Recèrca?search={searchTerms}"
+ },
+ "suggestUrl": {
+ "message": "https://oc.wiktionary.org/w/api.php?action=opensearch&search={searchTerms}&namespace=0"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json b/comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json
new file mode 100644
index 0000000000..7bca94e025
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/_locales/te/messages.json
@@ -0,0 +1,20 @@
+{
+ "extensionName": {
+ "message": "విక్షనరీ (te)"
+ },
+ "extensionDescription": {
+ "message": "విక్షనరీ (te)"
+ },
+ "searchUrl": {
+ "message": "https://te.wiktionary.org/wiki/ప్రత్యేక:అన్వేషణ"
+ },
+ "searchForm": {
+ "message": "https://te.wiktionary.org/wiki/ప్రత్యేక:అన్వేషణ?search={searchTerms}"
+ },
+ "suggestUrl": {
+ "message": "https://te.wiktionary.org/w/api.php?action=opensearch&search={searchTerms}&namespace=0"
+ },
+ "searchUrlGetParams": {
+ "message": "search={searchTerms}"
+ }
+}
diff --git a/comm/mail/components/search/extensions/wiktionary/favicon.ico b/comm/mail/components/search/extensions/wiktionary/favicon.ico
new file mode 100644
index 0000000000..31b0e38092
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/wiktionary/manifest.json b/comm/mail/components/search/extensions/wiktionary/manifest.json
new file mode 100644
index 0000000000..2206a2f8bb
--- /dev/null
+++ b/comm/mail/components/search/extensions/wiktionary/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "wiktionary@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "oc",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/wolnelektury-pl/favicon.png b/comm/mail/components/search/extensions/wolnelektury-pl/favicon.png
new file mode 100644
index 0000000000..77f6db5322
--- /dev/null
+++ b/comm/mail/components/search/extensions/wolnelektury-pl/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/wolnelektury-pl/manifest.json b/comm/mail/components/search/extensions/wolnelektury-pl/manifest.json
new file mode 100644
index 0000000000..25d93be370
--- /dev/null
+++ b/comm/mail/components/search/extensions/wolnelektury-pl/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Wolne Lektury",
+ "description": "Biblioteka internetowa WolneLektury.pl",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "wolnelektury-pl@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Wolne Lektury",
+ "search_url": "https://wolnelektury.pl/szukaj/?q={searchTerms}",
+ "search_form": "https://wolnelektury.pl",
+ "suggest_url": "https://wolnelektury.pl/katalog/jtags/?mozhint=1&q={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.ico b/comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.ico
new file mode 100644
index 0000000000..4401c7a40e
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp-auctions/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json b/comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json
new file mode 100644
index 0000000000..62f5fe20b4
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp-auctions/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "ヤフオク!",
+ "description": "ヤフオク! 検索",
+ "manifest_version": 2,
+ "version": "1.3",
+ "applications": {
+ "gecko": {
+ "id": "yahoo-jp-auctions@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "ヤフオク!",
+ "encoding": "EUC-JP",
+ "search_url": "https://auctions.yahoo.co.jp/search/search",
+ "search_form": "https://auctions.yahoo.co.jp/",
+ "search_url_get_params": "p={searchTerms}&ei=EUC-JP"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yahoo-jp/favicon.ico b/comm/mail/components/search/extensions/yahoo-jp/favicon.ico
new file mode 100644
index 0000000000..34a916ccde
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp/favicon.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/yahoo-jp/manifest.json b/comm/mail/components/search/extensions/yahoo-jp/manifest.json
new file mode 100644
index 0000000000..6f0c339fbd
--- /dev/null
+++ b/comm/mail/components/search/extensions/yahoo-jp/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Yahoo! JAPAN",
+ "description": "Yahoo Search",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "yahoo-jp@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "web_accessible_resources": ["favicon.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Yahoo! JAPAN",
+ "search_url": "https://search.yahoo.co.jp/search",
+ "search_form": "https://search.yahoo.co.jp/",
+ "search_url_get_params": "p={searchTerms}&ei=UTF-8"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/az/messages.json b/comm/mail/components/search/extensions/yandex/_locales/az/messages.json
new file mode 100644
index 0000000000..a26b49f041
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/az/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Yandex"
+ },
+ "extensionDescription": {
+ "message": "İnternetdə axtarış üçün Yandexdən istifadə edin."
+ },
+ "searchUrl": {
+ "message": "https://yandex.az/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.az/"
+ },
+ "suggestUrl": {
+ "message": "https://yandex.az/suggest/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/by/messages.json b/comm/mail/components/search/extensions/yandex/_locales/by/messages.json
new file mode 100644
index 0000000000..440c062610
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/by/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Яндекс"
+ },
+ "extensionDescription": {
+ "message": "Пошук з дапамогаю Яндекс"
+ },
+ "searchUrl": {
+ "message": "https://yandex.by/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.by/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.by/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/en/messages.json b/comm/mail/components/search/extensions/yandex/_locales/en/messages.json
new file mode 100644
index 0000000000..593c0c93e7
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/en/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Yandex"
+ },
+ "extensionDescription": {
+ "message": "Use Yandex to search the Internet."
+ },
+ "searchUrl": {
+ "message": "https://www.yandex.com/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.com/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.com/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-en.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/kk/messages.json b/comm/mail/components/search/extensions/yandex/_locales/kk/messages.json
new file mode 100644
index 0000000000..2796cd93ec
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/kk/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Яндекс"
+ },
+ "extensionDescription": {
+ "message": "Воспользуйтесь Яндексом для поиска в Интернете."
+ },
+ "searchUrl": {
+ "message": "https://yandex.kz/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.kz/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.kz/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/ru/messages.json b/comm/mail/components/search/extensions/yandex/_locales/ru/messages.json
new file mode 100644
index 0000000000..cdc5ff13be
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/ru/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Яндекс"
+ },
+ "extensionDescription": {
+ "message": "Воспользуйтесь Яндексом для поиска в Интернете."
+ },
+ "searchUrl": {
+ "message": "https://yandex.ru/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.ru/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.ru/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-ru.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/_locales/tr/messages.json b/comm/mail/components/search/extensions/yandex/_locales/tr/messages.json
new file mode 100644
index 0000000000..55d16d9956
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/_locales/tr/messages.json
@@ -0,0 +1,23 @@
+{
+ "extensionName": {
+ "message": "Yandex"
+ },
+ "extensionDescription": {
+ "message": "Yandex Türkiye arama motoru"
+ },
+ "searchUrl": {
+ "message": "https://yandex.com.tr/search"
+ },
+ "searchForm": {
+ "message": "https://www.yandex.com.tr/"
+ },
+ "suggestUrl": {
+ "message": "https://suggest.yandex.com.tr/suggest-ff.cgi?part={searchTerms}"
+ },
+ "searchUrlGetParams": {
+ "message": "text={searchTerms}"
+ },
+ "extensionIcon": {
+ "message": "yandex-en.ico"
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/manifest.json b/comm/mail/components/search/extensions/yandex/manifest.json
new file mode 100644
index 0000000000..d5885f5c29
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "__MSG_extensionName__",
+ "description": "__MSG_extensionDescription__",
+ "manifest_version": 2,
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "id": "yandex@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "default_locale": "en",
+ "icons": {
+ "16": "__MSG_extensionIcon__"
+ },
+ "web_accessible_resources": ["yandex-en.ico", "yandex-ru.ico"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "__MSG_extensionName__",
+ "search_url": "__MSG_searchUrl__",
+ "search_form": "__MSG_searchForm__",
+ "suggest_url": "__MSG_suggestUrl__",
+ "search_url_get_params": "__MSG_searchUrlGetParams__"
+ }
+ }
+}
diff --git a/comm/mail/components/search/extensions/yandex/yandex-en.ico b/comm/mail/components/search/extensions/yandex/yandex-en.ico
new file mode 100644
index 0000000000..6398f30e9d
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/yandex-en.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/yandex/yandex-ru.ico b/comm/mail/components/search/extensions/yandex/yandex-ru.ico
new file mode 100644
index 0000000000..226fc0a071
--- /dev/null
+++ b/comm/mail/components/search/extensions/yandex/yandex-ru.ico
Binary files differ
diff --git a/comm/mail/components/search/extensions/zoznam-sk/favicon.png b/comm/mail/components/search/extensions/zoznam-sk/favicon.png
new file mode 100644
index 0000000000..5774c6ff52
--- /dev/null
+++ b/comm/mail/components/search/extensions/zoznam-sk/favicon.png
Binary files differ
diff --git a/comm/mail/components/search/extensions/zoznam-sk/manifest.json b/comm/mail/components/search/extensions/zoznam-sk/manifest.json
new file mode 100644
index 0000000000..118cd1ba7f
--- /dev/null
+++ b/comm/mail/components/search/extensions/zoznam-sk/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Zoznam",
+ "description": "Zoznam slovenskeho internetu",
+ "manifest_version": 2,
+ "version": "1.2",
+ "applications": {
+ "gecko": {
+ "id": "zoznam-sk@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "icons": {
+ "16": "favicon.png"
+ },
+ "web_accessible_resources": ["favicon.png"],
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Zoznam",
+ "encoding": "WINDOWS-1250",
+ "search_url": "https://www.zoznam.sk/hladaj.fcgi",
+ "search_form": "https://www.zoznam.sk/",
+ "search_url_get_params": "co=odkazy&s={searchTerms}"
+ }
+ }
+}
diff --git a/comm/mail/components/search/jar.mn b/comm/mail/components/search/jar.mn
new file mode 100644
index 0000000000..b8d36a114b
--- /dev/null
+++ b/comm/mail/components/search/jar.mn
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ search-extensions/ (extensions/**)
+#ifdef XP_MACOSX
+ content/messenger/SpotlightIntegration.js (content/SpotlightIntegration.js)
+#endif
+#ifdef XP_WIN
+ content/messenger/WinSearchIntegration.js (content/WinSearchIntegration.js)
+#endif
+
+% resource search-plugins %searchplugins/ contentaccessible=yes
+% resource search-extensions %search-extensions/ contentaccessible=yes
diff --git a/comm/mail/components/search/mdimporter/English.lproj/InfoPlist.strings b/comm/mail/components/search/mdimporter/English.lproj/InfoPlist.strings
new file mode 100644
index 0000000000..ca96e65b7e
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/English.lproj/InfoPlist.strings
Binary files differ
diff --git a/comm/mail/components/search/mdimporter/English.lproj/schema.strings b/comm/mail/components/search/mdimporter/English.lproj/schema.strings
new file mode 100644
index 0000000000..0f7b8d6297
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/English.lproj/schema.strings
Binary files differ
diff --git a/comm/mail/components/search/mdimporter/GetMetadataForFile.c b/comm/mail/components/search/mdimporter/GetMetadataForFile.c
new file mode 100644
index 0000000000..cafb3f05ee
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/GetMetadataForFile.c
@@ -0,0 +1,76 @@
+/* -*- Mode: C; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include <CoreFoundation/CoreFoundation.h>
+#include <CoreServices/CoreServices.h>
+
+/* -----------------------------------------------------------------------------
+ Get metadata attributes from file
+
+ This function's job is to extract useful information from the .mozeml file
+ and return it as a dictionary
+ -----------------------------------------------------------------------------
+ */
+
+Boolean GetMetadataForFile(void* thisInterface,
+ CFMutableDictionaryRef attributes,
+ CFStringRef contentTypeUTI, CFStringRef pathToFile) {
+ /* Pull any available metadata from the file at the specified path */
+ /* Return the attribute keys and attribute values in the dict */
+ /* Return TRUE if successful, FALSE if there was no data provided */
+ Boolean success;
+ CFURLRef fileURL = CFURLCreateWithFileSystemPath(
+ kCFAllocatorDefault, pathToFile, kCFURLPOSIXPathStyle, false);
+ CFReadStreamRef stream =
+ CFReadStreamCreateWithFile(kCFAllocatorDefault, fileURL);
+ CFReadStreamOpen(stream);
+
+ CFErrorRef err = NULL;
+ CFPropertyListRef ticket = CFPropertyListCreateWithStream(
+ kCFAllocatorDefault, stream,
+ /*streamLength*/ 0, kCFPropertyListImmutable, NULL, &err);
+ if (err != NULL) {
+ CFStringRef errorString = CFErrorCopyDescription(err);
+ if (errorString != NULL) {
+ printf("failed creating property list from stream\n");
+ printf("error = %s\n", (const char*)errorString);
+ }
+ CFRelease(err);
+ success = FALSE;
+ } else {
+ CFTypeRef value;
+ value = CFDictionaryGetValue(ticket, kMDItemTitle);
+ if (value) {
+ CFDictionarySetValue(attributes, kMDItemTitle, value);
+ }
+ value = CFDictionaryGetValue(ticket, kMDItemTextContent);
+ if (value) {
+ CFDictionarySetValue(attributes, kMDItemTextContent, value);
+ }
+ value = CFDictionaryGetValue(ticket, kMDItemDisplayName);
+ if (value) CFDictionarySetValue(attributes, kMDItemDisplayName, value);
+
+ CFDateFormatterRef dateFormatter = CFDateFormatterCreate(
+ NULL, NULL, kCFDateFormatterLongStyle, kCFDateFormatterLongStyle);
+
+ value = CFDictionaryGetValue(ticket, kMDItemLastUsedDate);
+
+ if (value && dateFormatter) {
+ printf("trying to parse date \n");
+ CFDateRef curDate =
+ CFDateFormatterCreateDateFromString(NULL, dateFormatter, value, NULL);
+ printf("got cur date\n");
+ if (curDate)
+ CFDictionarySetValue(attributes, kMDItemLastUsedDate, curDate);
+ }
+ success = TRUE;
+ }
+ // contents are kMDItemTextContent
+
+ CFReadStreamClose(stream);
+ CFRelease(stream);
+ CFRelease(fileURL);
+ return success;
+}
diff --git a/comm/mail/components/search/mdimporter/Info.plist b/comm/mail/components/search/mdimporter/Info.plist
new file mode 100644
index 0000000000..ccb2f3cd10
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/Info.plist
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleDocumentTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeOSTypes</key>
+ <array>
+ <string>TBMZ</string>
+ </array>
+ <key>CFBundleTypeRole</key>
+ <string>MDImporter</string>
+ <key>LSItemContentTypes</key>
+ <array>
+ <string>com.mozilla.thunderbird.mozeml</string>
+ </array>
+ </dict>
+ </array>
+ <key>CFBundleExecutable</key>
+ <string>thunderbird-mdimport</string>
+ <key>CFBundleIconFile</key>
+ <string>thunderbird-mdimport</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.mozilla.MDImporter.Thunderbird</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>Thunderbird-MDImport</string>
+ <key>CFBundleVersion</key>
+ <string>1.0</string>
+ <key>CFPlugInDynamicRegisterFunction</key>
+ <string></string>
+ <key>CFPlugInDynamicRegistration</key>
+ <string>NO</string>
+ <key>CFPlugInFactories</key>
+ <dict>
+ <key>37401ADE-1058-42DB-BBE5-F2AAB9D7C13E</key>
+ <string>MetadataImporterPluginFactory</string>
+ </dict>
+ <key>CFPlugInTypes</key>
+ <dict>
+ <key>8B08C4BF-415B-11D8-B3F9-0003936726FC</key>
+ <array>
+ <string>37401ADE-1058-42DB-BBE5-F2AAB9D7C13E</string>
+ </array>
+ </dict>
+ <key>CFPlugInUnloadFunction</key>
+ <string></string>
+</dict>
+</plist>
diff --git a/comm/mail/components/search/mdimporter/Makefile.in b/comm/mail/components/search/mdimporter/Makefile.in
new file mode 100644
index 0000000000..fbc6d27034
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/Makefile.in
@@ -0,0 +1,26 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This directory is producing a framework as a target. The output of this
+# framework will be located here.
+FRAMEWORK_DIR := $(DIST)/package/thunderbird.mdimporter
+
+STRING_FILES := $(srcdir)/English.lproj/InfoPlist.strings $(srcdir)/English.lproj/schema.strings
+STRING_DEST := $(FRAMEWORK_DIR)/Contents/Resources/English.lproj
+INSTALL_TARGETS += STRING
+
+SCHEMA_FILES := $(srcdir)/schema.xml
+SCHEMA_DEST := $(FRAMEWORK_DIR)/Contents/Resources
+INSTALL_TARGETS += SCHEMA
+
+PLIST_FILES := $(srcdir)/Info.plist
+PLIST_DEST := $(FRAMEWORK_DIR)/Contents
+INSTALL_TARGETS += PLIST
+
+CFLAGS += -mmacosx-version-min=$(MACOSX_DEPLOYMENT_TARGET)
+# We don't need mozglue
+WRAP_LDFLAGS :=
+
+include $(topsrcdir)/config/rules.mk
+
diff --git a/comm/mail/components/search/mdimporter/main.c b/comm/mail/components/search/mdimporter/main.c
new file mode 100644
index 0000000000..20afc65351
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/main.c
@@ -0,0 +1,208 @@
+/* -*- Mode: C; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//==============================================================================
+//
+// DO NO MODIFY THE CONTENT OF THIS FILE
+//
+// This file contains the generic CFPlug-in code necessary for TB Spotlight
+// The actual importer is implemented in GetMetadataForFile.c
+//
+//==============================================================================
+
+#include <CoreFoundation/CoreFoundation.h>
+#include <CoreFoundation/CFPlugInCOM.h>
+#include <CoreServices/CoreServices.h>
+
+// -----------------------------------------------------------------------------
+// constants
+// -----------------------------------------------------------------------------
+
+#define PLUGIN_ID "37401ADE-1058-42DB-BBE5-F2AAB9D7C13E"
+
+//
+// Below is the generic glue code for all plug-ins.
+//
+// You should not have to modify this code aside from changing
+// names if you decide to change the names defined in the Info.plist
+//
+
+// -----------------------------------------------------------------------------
+// typedefs
+// -----------------------------------------------------------------------------
+
+// The import function to be implemented in GetMetadataForFile.c
+Boolean GetMetadataForFile(void* thisInterface,
+ CFMutableDictionaryRef attributes,
+ CFStringRef contentTypeUTI, CFStringRef pathToFile);
+
+// The layout for an instance of MetaDataImporterPlugIn
+typedef struct __MetadataImporterPluginType {
+ MDImporterInterfaceStruct* conduitInterface;
+ CFUUIDRef factoryID;
+ UInt32 refCount;
+} MetadataImporterPluginType;
+
+// -----------------------------------------------------------------------------
+// prototypes
+// -----------------------------------------------------------------------------
+// Forward declaration for the IUnknown implementation.
+//
+
+MetadataImporterPluginType* AllocMetadataImporterPluginType(
+ CFUUIDRef inFactoryID);
+void DeallocMetadataImporterPluginType(
+ MetadataImporterPluginType* thisInstance);
+HRESULT MetadataImporterQueryInterface(void* thisInstance, REFIID iid,
+ LPVOID* ppv);
+void* MetadataImporterPluginFactory(CFAllocatorRef allocator, CFUUIDRef typeID);
+ULONG MetadataImporterPluginAddRef(void* thisInstance);
+ULONG MetadataImporterPluginRelease(void* thisInstance);
+// -----------------------------------------------------------------------------
+// testInterfaceFtbl definition
+// -----------------------------------------------------------------------------
+// The TestInterface function table.
+//
+
+static MDImporterInterfaceStruct testInterfaceFtbl = {
+ NULL, MetadataImporterQueryInterface, MetadataImporterPluginAddRef,
+ MetadataImporterPluginRelease, GetMetadataForFile};
+
+// -----------------------------------------------------------------------------
+// AllocMetadataImporterPluginType
+// -----------------------------------------------------------------------------
+// Utility function that allocates a new instance.
+// You can do some initial setup for the importer here if you wish
+// like allocating globals etc...
+//
+MetadataImporterPluginType* AllocMetadataImporterPluginType(
+ CFUUIDRef inFactoryID) {
+ MetadataImporterPluginType* theNewInstance;
+
+ theNewInstance =
+ (MetadataImporterPluginType*)malloc(sizeof(MetadataImporterPluginType));
+ memset(theNewInstance, 0, sizeof(MetadataImporterPluginType));
+
+ /* Point to the function table */
+ theNewInstance->conduitInterface = &testInterfaceFtbl;
+
+ /* Retain and keep an open instance refcount for each factory. */
+ theNewInstance->factoryID = CFRetain(inFactoryID);
+ CFPlugInAddInstanceForFactory(inFactoryID);
+
+ /* This function returns the IUnknown interface so set the refCount to one. */
+ theNewInstance->refCount = 1;
+ return theNewInstance;
+}
+
+// -----------------------------------------------------------------------------
+// DeallocTBSpotlightMDImporterPluginType
+// -----------------------------------------------------------------------------
+// Utility function that deallocates the instance when
+// the refCount goes to zero.
+// In the current implementation importer interfaces are never deallocated
+// but implement this as this might change in the future
+//
+void DeallocMetadataImporterPluginType(
+ MetadataImporterPluginType* thisInstance) {
+ CFUUIDRef theFactoryID;
+
+ theFactoryID = thisInstance->factoryID;
+ free(thisInstance);
+ if (theFactoryID) {
+ CFPlugInRemoveInstanceForFactory(theFactoryID);
+ CFRelease(theFactoryID);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// MetadataImporterQueryInterface
+// -----------------------------------------------------------------------------
+// Implementation of the IUnknown QueryInterface function.
+//
+HRESULT MetadataImporterQueryInterface(void* thisInstance, REFIID iid,
+ LPVOID* ppv) {
+ CFUUIDRef interfaceID;
+
+ interfaceID = CFUUIDCreateFromUUIDBytes(kCFAllocatorDefault, iid);
+
+ if (CFEqual(interfaceID, kMDImporterInterfaceID)) {
+ /* If the Right interface was requested, bump the ref count,
+ * set the ppv parameter equal to the instance, and
+ * return good status.
+ */
+ ((MetadataImporterPluginType*)thisInstance)
+ ->conduitInterface->AddRef(thisInstance);
+ *ppv = thisInstance;
+ CFRelease(interfaceID);
+ return S_OK;
+ } else {
+ if (CFEqual(interfaceID, IUnknownUUID)) {
+ /* If the IUnknown interface was requested, same as above. */
+ ((MetadataImporterPluginType*)thisInstance)
+ ->conduitInterface->AddRef(thisInstance);
+ *ppv = thisInstance;
+ CFRelease(interfaceID);
+ return S_OK;
+ } else {
+ /* Requested interface unknown, bail with error. */
+ *ppv = NULL;
+ CFRelease(interfaceID);
+ return E_NOINTERFACE;
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------
+// MetadataImporterPluginAddRef
+// -----------------------------------------------------------------------------
+// Implementation of reference counting for this type. Whenever an interface
+// is requested, bump the refCount for the instance. NOTE: returning the
+// refcount is a convention but is not required so don't rely on it.
+//
+ULONG MetadataImporterPluginAddRef(void* thisInstance) {
+ ((MetadataImporterPluginType*)thisInstance)->refCount += 1;
+ return ((MetadataImporterPluginType*)thisInstance)->refCount;
+}
+
+// -----------------------------------------------------------------------------
+// SampleCMPluginRelease
+// -----------------------------------------------------------------------------
+// When an interface is released, decrement the refCount.
+// If the refCount goes to zero, deallocate the instance.
+//
+ULONG MetadataImporterPluginRelease(void* thisInstance) {
+ ((MetadataImporterPluginType*)thisInstance)->refCount -= 1;
+ if (((MetadataImporterPluginType*)thisInstance)->refCount == 0) {
+ DeallocMetadataImporterPluginType(
+ (MetadataImporterPluginType*)thisInstance);
+ return 0;
+ } else {
+ return ((MetadataImporterPluginType*)thisInstance)->refCount;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// TBSpotlightMDImporterPluginFactory
+// -----------------------------------------------------------------------------
+// Implementation of the factory function for this type.
+//
+void* MetadataImporterPluginFactory(CFAllocatorRef allocator,
+ CFUUIDRef typeID) {
+ MetadataImporterPluginType* result;
+ CFUUIDRef uuid;
+
+ /* If correct type is being requested, allocate an
+ * instance of TestType and return the IUnknown interface.
+ */
+ if (CFEqual(typeID, kMDImporterTypeID)) {
+ uuid = CFUUIDCreateFromString(kCFAllocatorDefault, CFSTR(PLUGIN_ID));
+ result = AllocMetadataImporterPluginType(uuid);
+ CFRelease(uuid);
+ return result;
+ }
+ /* If the requested type is incorrect, return NULL. */
+ return NULL;
+}
diff --git a/comm/mail/components/search/mdimporter/moz.build b/comm/mail/components/search/mdimporter/moz.build
new file mode 100644
index 0000000000..8bfc3e6c80
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+SOURCES = [
+ "GetMetadataForFile.c",
+ "main.c",
+]
+
+Program("thunderbird-mdimport")
+# This directory is producing a framework as a target. The output of this
+# framework will be located here.
+FINAL_TARGET = "dist/package/thunderbird.mdimporter/Contents/MacOS"
+
+OS_LIBS += [
+ "-framework CoreFoundation",
+ "-framework CoreServices",
+]
+
+# We're also a bundle.
+LDFLAGS += ["-bundle"]
diff --git a/comm/mail/components/search/mdimporter/schema.xml b/comm/mail/components/search/mdimporter/schema.xml
new file mode 100644
index 0000000000..f450209378
--- /dev/null
+++ b/comm/mail/components/search/mdimporter/schema.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+
+<schema version="1.0" xmlns="http://www.apple.com/metadata"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.apple.com/metadata file:///System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Resources/MetadataSchema.xsd">
+ <note>
+ Start off by supporting the same attributes as Mail.importer
+ </note>
+ <attributes>
+ <attribute name="kMDItemAuthorEmailAddresses" multivalued="true" type="CFString"/>
+ <attribute name="kMDItemRecipientEmailAddresses" multivalued="true" type="CFString"/>
+ </attributes>
+
+ <types>
+ <type name="com.mozilla.thunderbird.mozeml">
+ <note>
+ The keys that this metadata importer handles.
+ </note>
+ <allattrs>
+ kMDItemAuthorEmailAddresses
+ kMDItemRecipientEmailAddresses
+ </allattrs>
+ <displayattrs>
+ </displayattrs>
+ </type>
+ </types>
+</schema>
+
diff --git a/comm/mail/components/search/moz.build b/comm/mail/components/search/moz.build
new file mode 100644
index 0000000000..219ea99355
--- /dev/null
+++ b/comm/mail/components/search/moz.build
@@ -0,0 +1,23 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ DIRS += ["mdimporter"]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ DIRS += ["wsenable"]
+ SOURCES += ["nsMailWinSearchHelper.cpp"]
+ FINAL_LIBRARY = "mailcomps"
+
+DIRS += ["public"]
+
+EXTRA_JS_MODULES += [
+ "SearchIntegration.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/search/nsMailWinSearchHelper.cpp b/comm/mail/components/search/nsMailWinSearchHelper.cpp
new file mode 100644
index 0000000000..9cfdc40cc0
--- /dev/null
+++ b/comm/mail/components/search/nsMailWinSearchHelper.cpp
@@ -0,0 +1,254 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsMailWinSearchHelper.h"
+#include "nsDirectoryServiceUtils.h"
+#include "nsString.h"
+#include "nsIDirectoryEnumerator.h"
+#include "mozilla/ArrayUtils.h"
+
+#ifdef _WIN32_WINNT
+# undef _WIN32_WINNT
+#endif
+#define _WIN32_WINNT 0x0600
+#include <SearchAPI.h>
+#include <winsvc.h>
+#include <ShellAPI.h>
+#include <shlobj.h>
+
+static const CLSID CLSID_CSearchManager = {
+ 0x7d096c5f,
+ 0xac08,
+ 0x4f1f,
+ {0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39}};
+static const IID IID_ISearchManager = {
+ 0xab310581,
+ 0xac80,
+ 0x11d1,
+ {0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69}};
+
+static const char* const sFoldersToIndex[] = {"Mail", "ImapMail", "News"};
+
+// APP_REG_NAME_MAIL should be kept in synch with AppRegNameMail
+// in the installer file: defines.nsi.in
+#define APP_REG_NAME_MAIL L"Thunderbird"
+
+nsMailWinSearchHelper::nsMailWinSearchHelper() {}
+
+nsresult nsMailWinSearchHelper::Init() {
+ CoInitialize(NULL);
+ return NS_GetSpecialDirectory("ProfD", getter_AddRefs(mProfD));
+}
+
+nsMailWinSearchHelper::~nsMailWinSearchHelper() { CoUninitialize(); }
+
+NS_IMPL_ISUPPORTS(nsMailWinSearchHelper, nsIMailWinSearchHelper)
+
+NS_IMETHODIMP nsMailWinSearchHelper::GetFoldersInCrawlScope(bool* aResult) {
+ *aResult = false;
+ NS_ENSURE_ARG_POINTER(mProfD);
+
+ // If the service isn't present or running, we shouldn't proceed.
+ bool serviceRunning;
+ nsresult rv = GetServiceRunning(&serviceRunning);
+ if (!serviceRunning || NS_FAILED(rv)) return rv;
+
+ // We need to do this every time so that we have the latest data
+ RefPtr<ISearchManager> searchManager;
+ HRESULT hr =
+ CoCreateInstance(CLSID_CSearchManager, NULL, CLSCTX_ALL,
+ IID_ISearchManager, getter_AddRefs(searchManager));
+ if (FAILED(hr)) return NS_ERROR_FAILURE;
+
+ RefPtr<ISearchCatalogManager> catalogManager;
+ hr =
+ searchManager->GetCatalog(L"SystemIndex", getter_AddRefs(catalogManager));
+ if (FAILED(hr)) return NS_ERROR_FAILURE;
+
+ RefPtr<ISearchCrawlScopeManager> crawlScopeManager;
+ hr = catalogManager->GetCrawlScopeManager(getter_AddRefs(crawlScopeManager));
+ if (FAILED(hr)) return NS_ERROR_FAILURE;
+
+ // We need to create appropriate URLs to check with the crawl scope manager.
+ for (uint32_t i = 0; i < MOZ_ARRAY_LENGTH(sFoldersToIndex); i++) {
+ nsCOMPtr<nsIFile> subdir;
+ rv = mProfD->Clone(getter_AddRefs(subdir));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsDependentCString relativeStr(sFoldersToIndex[i]);
+ rv = subdir->AppendNative(relativeStr);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsString subdirPath;
+ rv = subdir->GetPath(subdirPath);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Form a URL as required by the crawl scope manager
+ nsString subdirURL(u"file:///"_ns);
+ subdirURL.Append(subdirPath);
+ subdirURL.Append('\\');
+
+ BOOL included;
+ if (FAILED(crawlScopeManager->IncludedInCrawlScope(subdirURL.get(),
+ &included)))
+ return NS_ERROR_FAILURE;
+
+ // If even one of the folders isn't there, we return false
+ if (!included) return NS_OK;
+ }
+ *aResult = true;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::GetServiceRunning(bool* aResult) {
+ *aResult = false;
+ SC_HANDLE hSCManager =
+ OpenSCManagerW(nullptr, SERVICES_ACTIVE_DATABASEW, SERVICE_QUERY_STATUS);
+ if (!hSCManager) return NS_ERROR_FAILURE;
+
+ SC_HANDLE hService =
+ OpenServiceW(hSCManager, L"wsearch", SERVICE_QUERY_STATUS);
+ CloseServiceHandle(hSCManager);
+ if (!hService)
+ // The service isn't present. Never mind.
+ return NS_ERROR_NOT_AVAILABLE;
+
+ SERVICE_STATUS status;
+ if (!QueryServiceStatus(hService, &status)) {
+ CloseServiceHandle(hService);
+ return NS_ERROR_FAILURE;
+ }
+
+ *aResult = (status.dwCurrentState == SERVICE_RUNNING);
+ CloseServiceHandle(hService);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::SetFANCIBit(nsIFile* aFile, bool aBit,
+ bool aRecurse) {
+ NS_ENSURE_ARG_POINTER(aFile);
+
+ bool exists;
+ nsresult rv = aFile->Exists(&exists);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!exists) return NS_ERROR_FILE_NOT_FOUND;
+
+ nsString filePath;
+ rv = aFile->GetPath(filePath);
+ NS_ENSURE_SUCCESS(rv, rv);
+ LPCWSTR pathStr = filePath.get();
+
+ // We should set the file attribute only if it isn't already set.
+ DWORD dwAttrs = GetFileAttributesW(pathStr);
+ if (dwAttrs == INVALID_FILE_ATTRIBUTES) return NS_ERROR_FAILURE;
+
+ if (aBit) {
+ if (!(dwAttrs & FILE_ATTRIBUTE_NOT_CONTENT_INDEXED))
+ SetFileAttributesW(pathStr, dwAttrs | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED);
+ } else {
+ if (dwAttrs & FILE_ATTRIBUTE_NOT_CONTENT_INDEXED)
+ SetFileAttributesW(pathStr,
+ dwAttrs & ~FILE_ATTRIBUTE_NOT_CONTENT_INDEXED);
+ }
+
+ // We should only try to recurse if it's a directory
+ bool isDirectory;
+ rv = aFile->IsDirectory(&isDirectory);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aRecurse && isDirectory) {
+ nsCOMPtr<nsIDirectoryEnumerator> children;
+ rv = aFile->GetDirectoryEntries(getter_AddRefs(children));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(children->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsIFile> childFile;
+ rv = children->GetNextFile(getter_AddRefs(childFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetFANCIBit(childFile, aBit, aRecurse);
+ }
+ }
+ return rv;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::GetIsFileAssociationSet(bool* aResult) {
+ NS_ENSURE_ARG_POINTER(aResult);
+
+ // We'll use the Vista method here
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, NULL, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+
+ BOOL res = false;
+ if (SUCCEEDED(hr))
+ pAAR->QueryAppIsDefault(L".wdseml", AT_FILEEXTENSION, AL_EFFECTIVE,
+ APP_REG_NAME_MAIL, &res);
+ *aResult = res;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::SetFileAssociation() {
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, NULL, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+ if (SUCCEEDED(hr))
+ hr = pAAR->SetAppAsDefault(APP_REG_NAME_MAIL, L".wdseml", AT_FILEEXTENSION);
+
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP nsMailWinSearchHelper::RunSetup(bool aEnable) {
+ nsresult rv;
+ if (!mCurProcD) {
+ rv = NS_GetSpecialDirectory("CurProcD", getter_AddRefs(mCurProcD));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mCurProcD->Append(u"WSEnable.exe"_ns);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsAutoString filePath;
+ rv = mCurProcD->GetPath(filePath);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // The parameters are of the format "1 <path>" for enabling and "0 <path>" for
+ // disabling
+ nsAutoString params(aEnable ? u"1 \""_ns : u"0 \""_ns);
+ nsAutoString profDPath;
+ rv = mProfD->GetPath(profDPath);
+ NS_ENSURE_SUCCESS(rv, rv);
+ params.Append(profDPath);
+ params.Append(u"\""_ns);
+
+ // We need an hWnd to cause UAC to pop up immediately
+ // If GetForegroundWindow returns NULL, then the UAC prompt will still appear,
+ // but minimized.
+ HWND hWnd = GetForegroundWindow();
+
+ SHELLEXECUTEINFOW executeInfo = {0};
+
+ executeInfo.cbSize = sizeof(SHELLEXECUTEINFOW);
+ executeInfo.hwnd = hWnd;
+ executeInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
+ executeInfo.lpDirectory = NULL;
+ executeInfo.lpFile = filePath.get();
+ executeInfo.lpParameters = params.get();
+ executeInfo.nShow = SW_SHOWNORMAL;
+
+ DWORD dwRet = ERROR_SUCCESS;
+
+ if (ShellExecuteExW(&executeInfo)) {
+ // We want to block until the program exits
+ DWORD dwSignaled = WaitForSingleObject(executeInfo.hProcess, INFINITE);
+ if (dwSignaled == WAIT_OBJECT_0)
+ if (!GetExitCodeProcess(executeInfo.hProcess, &dwRet))
+ dwRet = GetLastError();
+ } else
+ return NS_ERROR_ABORT;
+
+ return SUCCEEDED(HRESULT_FROM_WIN32(dwRet)) ? NS_OK : NS_ERROR_FAILURE;
+}
diff --git a/comm/mail/components/search/nsMailWinSearchHelper.h b/comm/mail/components/search/nsMailWinSearchHelper.h
new file mode 100644
index 0000000000..ee313e6acf
--- /dev/null
+++ b/comm/mail/components/search/nsMailWinSearchHelper.h
@@ -0,0 +1,34 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsMailWinSearchHelper_h_
+#define nsMailWinSearchHelper_h_
+
+#include "nsIMailWinSearchHelper.h"
+#include "nsIFile.h"
+#include "nsCOMPtr.h"
+
+#define NS_MAILWINSEARCHHELPER_CID \
+ { \
+ 0x5dd31c99, 0x8c7, 0x4a3b, { \
+ 0xae, 0xb3, 0xd2, 0xe6, 0x6, 0x65, 0xa3, 0x1a \
+ } \
+ }
+
+class nsMailWinSearchHelper : public nsIMailWinSearchHelper {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIMAILWINSEARCHHELPER
+
+ nsresult Init();
+ nsMailWinSearchHelper();
+
+ private:
+ virtual ~nsMailWinSearchHelper();
+ nsCOMPtr<nsIFile> mProfD;
+ nsCOMPtr<nsIFile> mCurProcD;
+};
+
+#endif
diff --git a/comm/mail/components/search/public/moz.build b/comm/mail/components/search/public/moz.build
new file mode 100644
index 0000000000..e920335704
--- /dev/null
+++ b/comm/mail/components/search/public/moz.build
@@ -0,0 +1,10 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "nsIMailWinSearchHelper.idl",
+]
+
+XPIDL_MODULE = "mailwinsearch"
diff --git a/comm/mail/components/search/public/nsIMailWinSearchHelper.idl b/comm/mail/components/search/public/nsIMailWinSearchHelper.idl
new file mode 100644
index 0000000000..1c1d03a07f
--- /dev/null
+++ b/comm/mail/components/search/public/nsIMailWinSearchHelper.idl
@@ -0,0 +1,58 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+
+[scriptable, uuid(a65307b3-64f8-49fc-96a7-2cfc7d1f18ee)]
+interface nsIMailWinSearchHelper : nsISupports
+{
+ /**
+ * Whether the Windows Search service is installed and running.
+ *
+ * @exception NS_ERROR_NOT_AVAILABLE if the Windows Search service is
+ * not installed
+ */
+ readonly attribute boolean serviceRunning;
+
+ /**
+ * Whether the Mail, ImapMail, and News folders are in the crawl scope.
+ *
+ * @exception NS_ERROR_NOT_AVAILABLE if the Windows Search service is not
+ * installed or running
+ */
+ readonly attribute boolean foldersInCrawlScope;
+
+ /**
+ * Sets the File Attribute Not Content Indexed bit. For proper operation
+ * of the indexer, this bit must be set to 0/false.
+ *
+ * @param aFile the file or directory for which this bit is supposed to be set
+ * @param aBit false if the content is to be indexed, true if not
+ * @param aRecurse whether this bit is to be set recursively for all subdirectories
+ * and files inside a directory
+ */
+ void setFANCIBit(in nsIFile aFile, in boolean aBit, in boolean aRecurse);
+
+ /**
+ * Returns whether the .wdseml file association has been set to Thunderbird or not.
+ */
+ readonly attribute boolean isFileAssociationSet;
+
+ /**
+ * Sets the .wdseml file association.
+ */
+ void setFileAssociation();
+
+ /**
+ * Runs the setup application using ShellExecute, passing the profile directory as
+ * a parameter.
+ *
+ * @param aEnable true to enable, false to disable
+ */
+ void runSetup(in boolean aEnable);
+};
diff --git a/comm/mail/components/search/wsenable/Makefile.in b/comm/mail/components/search/wsenable/Makefile.in
new file mode 100644
index 0000000000..254509ee77
--- /dev/null
+++ b/comm/mail/components/search/wsenable/Makefile.in
@@ -0,0 +1,6 @@
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+MOZ_WINCONSOLE = 0
diff --git a/comm/mail/components/search/wsenable/WSEnable.cpp b/comm/mail/components/search/wsenable/WSEnable.cpp
new file mode 100644
index 0000000000..825e0c4fe4
--- /dev/null
+++ b/comm/mail/components/search/wsenable/WSEnable.cpp
@@ -0,0 +1,141 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include <SearchAPI.h>
+#include <shellapi.h>
+#include <objbase.h>
+#include <string>
+
+static const CLSID CLSID_CSearchManager = {
+ 0x7d096c5f,
+ 0xac08,
+ 0x4f1f,
+ {0xbe, 0xb7, 0x5c, 0x22, 0xc5, 0x17, 0xce, 0x39}};
+static const IID IID_ISearchManager = {
+ 0xab310581,
+ 0xac80,
+ 0x11d1,
+ {0x8d, 0xf3, 0x00, 0xc0, 0x4f, 0xb6, 0xef, 0x69}};
+
+static const WCHAR* const sFoldersToIndex[] = {L"\\Mail\\", L"\\ImapMail\\",
+ L"\\News\\"};
+
+struct RegKey {
+ HKEY mRoot;
+ LPCWSTR mSubKey;
+ LPCWSTR mName;
+ LPCWSTR mValue;
+
+ RegKey(HKEY aRoot, LPCWSTR aSubKey, LPCWSTR aName, LPCWSTR aValue)
+ : mRoot(aRoot), mSubKey(aSubKey), mName(aName), mValue(aValue) {}
+};
+
+static const RegKey* const sRegKeys[] = {
+ new RegKey(HKEY_LOCAL_MACHINE,
+ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\PropertySystem\\"
+ L"PropertyHandlers\\.wdseml",
+ L"", L"{5FA29220-36A1-40f9-89C6-F4B384B7642E}"),
+ new RegKey(HKEY_CLASSES_ROOT, L".wdseml", L"Content Type",
+ L"message/rfc822"),
+ new RegKey(HKEY_CLASSES_ROOT, L".wdseml\\PersistentHandler", L"",
+ L"{5645c8c4-e277-11cf-8fda-00aa00a14f93}"),
+ new RegKey(HKEY_CLASSES_ROOT,
+ L".wdseml\\shellex\\{8895B1C6-B41F-4C1C-A562-0D564250836F}", L"",
+ L"{b9815375-5d7f-4ce2-9245-c9d4da436930}"),
+ new RegKey(
+ HKEY_LOCAL_MACHINE,
+ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\explorer\\KindMap",
+ L".wdseml", L"email;communication")};
+
+HRESULT GetCrawlScopeManager(ISearchCrawlScopeManager** aCrawlScopeManager) {
+ *aCrawlScopeManager = NULL;
+
+ ISearchManager* searchManager;
+ HRESULT hr = CoCreateInstance(CLSID_CSearchManager, NULL, CLSCTX_ALL,
+ IID_ISearchManager, (void**)&searchManager);
+ if (SUCCEEDED(hr)) {
+ ISearchCatalogManager* catalogManager;
+ hr = searchManager->GetCatalog(L"SystemIndex", &catalogManager);
+ if (SUCCEEDED(hr)) {
+ hr = catalogManager->GetCrawlScopeManager(aCrawlScopeManager);
+ catalogManager->Release();
+ }
+ searchManager->Release();
+ }
+ return hr;
+}
+
+LSTATUS SetRegistryKeys() {
+ LSTATUS rv = ERROR_SUCCESS;
+ for (uint32_t i = 0; rv == ERROR_SUCCESS && i < _countof(sRegKeys); i++) {
+ const RegKey* key = sRegKeys[i];
+ HKEY subKey;
+ // Since we're administrator, we should be able to do this just fine
+ rv = RegCreateKeyExW(key->mRoot, key->mSubKey, 0, NULL,
+ REG_OPTION_NON_VOLATILE,
+ KEY_ALL_ACCESS | KEY_WOW64_64KEY, NULL, &subKey, NULL);
+ if (rv == ERROR_SUCCESS)
+ rv = RegSetValueExW(subKey, key->mName, 0, REG_SZ, (LPBYTE)key->mValue,
+ (lstrlenW(key->mValue) + 1) * sizeof(WCHAR));
+ RegCloseKey(subKey);
+ }
+
+ return rv;
+}
+
+int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
+ LPWSTR lpCmdLine, int nCmdShow) {
+ UNREFERENCED_PARAMETER(lpCmdLine);
+
+ HRESULT hr =
+ CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
+ if (SUCCEEDED(hr)) {
+ int argc;
+ LPWSTR* argv = CommandLineToArgvW(lpCmdLine, &argc);
+ if (argc != 2) hr = E_INVALIDARG;
+ if (SUCCEEDED(hr)) {
+ ISearchCrawlScopeManager* crawlScopeManager;
+ hr = GetCrawlScopeManager(&crawlScopeManager);
+ if (SUCCEEDED(hr)) {
+ if (*argv[0] == L'1') {
+ // We first add the required registry entries
+ LSTATUS rv = SetRegistryKeys();
+ if (rv != ERROR_SUCCESS) hr = E_FAIL;
+
+ // Next, we add rules for each of the three folders
+ for (uint32_t i = 0; SUCCEEDED(hr) && i < _countof(sFoldersToIndex);
+ i++) {
+ std::wstring path = L"file:///";
+ path.append(argv[1]);
+ path.append(sFoldersToIndex[i]);
+ // Add only if the rule isn't already there
+ BOOL isIncluded = FALSE;
+ hr = crawlScopeManager->IncludedInCrawlScope(path.c_str(),
+ &isIncluded);
+ if (SUCCEEDED(hr) && !isIncluded)
+ hr = crawlScopeManager->AddUserScopeRule(path.c_str(), TRUE, TRUE,
+ TRUE);
+ }
+ } else if (*argv[0] == L'0') {
+ // This is simple, we just exclude the profile dir and override
+ // children
+ std::wstring path = L"file:///";
+ path.append(argv[1]);
+ hr = crawlScopeManager->AddUserScopeRule(path.c_str(), FALSE, TRUE,
+ TRUE);
+ } else
+ hr = E_INVALIDARG;
+
+ if (SUCCEEDED(hr)) {
+ hr = crawlScopeManager->SaveAll();
+ }
+ crawlScopeManager->Release();
+ }
+ }
+ LocalFree(argv);
+ }
+
+ return hr;
+}
diff --git a/comm/mail/components/search/wsenable/WSEnable.exe.manifest b/comm/mail/components/search/wsenable/WSEnable.exe.manifest
new file mode 100644
index 0000000000..1c5ebf8e57
--- /dev/null
+++ b/comm/mail/components/search/wsenable/WSEnable.exe.manifest
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="Mozilla.Thunderbird"
+ type="win32"
+/>
+<description>Mozilla Thunderbird Windows Search Integration Handler</description>
+<dependency>
+ <dependentAssembly>
+ <assemblyIdentity
+ type="win32"
+ name="Microsoft.Windows.Common-Controls"
+ version="6.0.0.0"
+ processorArchitecture="*"
+ publicKeyToken="6595b64144ccf1df"
+ language="*"
+ />
+ </dependentAssembly>
+</dependency>
+<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:security>
+ <ms_asmv3:requestedPrivileges>
+ <ms_asmv3:requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
+ </ms_asmv3:requestedPrivileges>
+ </ms_asmv3:security>
+</ms_asmv3:trustInfo>
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+ </compatibility>
+</assembly>
diff --git a/comm/mail/components/search/wsenable/WSEnable.rc b/comm/mail/components/search/wsenable/WSEnable.rc
new file mode 100644
index 0000000000..857298e160
--- /dev/null
+++ b/comm/mail/components/search/wsenable/WSEnable.rc
@@ -0,0 +1,6 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+1 24 "WSEnable.exe.manifest"
diff --git a/comm/mail/components/search/wsenable/module.ver b/comm/mail/components/search/wsenable/module.ver
new file mode 100644
index 0000000000..bd6f7f1c84
--- /dev/null
+++ b/comm/mail/components/search/wsenable/module.ver
@@ -0,0 +1 @@
+WIN32_MODULE_DESCRIPTION=@MOZ_APP_DISPLAYNAME@ Windows Search Integration Handler
diff --git a/comm/mail/components/search/wsenable/moz.build b/comm/mail/components/search/wsenable/moz.build
new file mode 100644
index 0000000000..bf9cdab1dc
--- /dev/null
+++ b/comm/mail/components/search/wsenable/moz.build
@@ -0,0 +1,21 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Program("WSEnable")
+
+SOURCES += [
+ "WSEnable.cpp",
+]
+
+OS_LIBS += [
+ "advapi32",
+ "ole32",
+ "shell32",
+]
+
+RCINCLUDE = "WSEnable.rc"
+
+# This isn't XPCOM code, but it wants to use STL so disable STL wrappers
+DisableStlWrapping()
diff --git a/comm/mail/components/shell/components.conf b/comm/mail/components/shell/components.conf
new file mode 100644
index 0000000000..7b7712522a
--- /dev/null
+++ b/comm/mail/components/shell/components.conf
@@ -0,0 +1,37 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = []
+
+if buildconfig.substs["OS_ARCH"] == "WINNT":
+ Classes += [
+ {
+ "cid": "{02ebbe84-c179-4598-af18-1bf2c4bc1df9}",
+ "contract_ids": ["@mozilla.org/mail/shell-service;1"],
+ "type": "nsWindowsShellService",
+ "init_method": "Init",
+ "headers": ["/comm/mail/components/shell/nsWindowsShellService.h"],
+ },
+ ]
+
+if buildconfig.substs["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ Classes += [
+ {
+ "cid": "{bddef0f4-5e2d-4846-bdec-86d0781d8ded}",
+ "contract_ids": ["@mozilla.org/mail/shell-service;1"],
+ "type": "nsGNOMEShellService",
+ "init_method": "Init",
+ "headers": ["/comm/mail/components/shell/nsGNOMEShellService.h"],
+ },
+ ]
+
+if buildconfig.substs["OS_ARCH"] == "Darwin":
+ Classes += [
+ {
+ "cid": "{85a27035-b970-4079-b9d2-e21f69e6b21f}",
+ "contract_ids": ["@mozilla.org/mail/shell-service;1"],
+ "type": "nsMacShellService",
+ "headers": ["/comm/mail/components/shell/nsMacShellService.h"],
+ },
+ ]
diff --git a/comm/mail/components/shell/moz.build b/comm/mail/components/shell/moz.build
new file mode 100644
index 0000000000..6687759226
--- /dev/null
+++ b/comm/mail/components/shell/moz.build
@@ -0,0 +1,43 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DEFINES["MOZ_APP_NAME"] = '"%s"' % CONFIG["MOZ_APP_NAME"]
+
+XPIDL_SOURCES += [
+ "nsIShellService.idl",
+]
+
+XPIDL_MODULE = "shellservice"
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ SOURCES += [
+ "nsWindowsShellService.cpp",
+ ]
+ LOCAL_INCLUDES += [
+ "/other-licenses/nsis/Contrib/CityHash/cityhash",
+ ]
+
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ SOURCES += [
+ "nsMacShellService.cpp",
+ ]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ SOURCES += [
+ "nsGNOMEShellService.cpp",
+ ]
+
+if SOURCES:
+ FINAL_LIBRARY = "mailcomps"
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/mail/components/shell/nsGNOMEShellService.cpp b/comm/mail/components/shell/nsGNOMEShellService.cpp
new file mode 100644
index 0000000000..f7381b2adc
--- /dev/null
+++ b/comm/mail/components/shell/nsGNOMEShellService.cpp
@@ -0,0 +1,341 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsGNOMEShellService.h"
+#include "nsIGIOService.h"
+#include "nsCOMPtr.h"
+#include "nsIServiceManager.h"
+#include "prenv.h"
+#include "nsIFile.h"
+#include "nsIStringBundle.h"
+#include "nsIPromptService.h"
+#include "nsIPrefService.h"
+#include "nsIPrefBranch.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsDirectoryServiceUtils.h"
+#include "nsEmbedCID.h"
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/Components.h"
+
+#include <glib.h>
+#include <limits.h>
+#include <stdlib.h>
+
+using mozilla::ArrayLength;
+
+static const char* const sMailProtocols[] = {"mailto", "mid"};
+
+static const char* const sNewsProtocols[] = {"news", "snews", "nntp"};
+
+static const char* const sFeedProtocols[] = {"feed"};
+
+static const char* const sCalendarProtocols[] = {"webcal", "webcals"};
+
+struct AppTypeAssociation {
+ uint16_t type;
+ const char* const* protocols;
+ unsigned int protocolsLength;
+ const char* mimeType;
+ const char* extensions;
+};
+
+static bool IsRunningAsASnap() {
+ // SNAP holds the path to the snap, use SNAP_NAME
+ // which is easier to parse.
+ const char* snap_name = PR_GetEnv("SNAP_NAME");
+
+ // return early if not set.
+ if (snap_name == nullptr) {
+ return false;
+ }
+
+ // snap_name as defined on https://snapcraft.io/thunderbird
+ return (strcmp(snap_name, "thunderbird") == 0);
+}
+
+static const AppTypeAssociation sAppTypes[] = {
+ {
+ nsIShellService::MAIL, sMailProtocols, ArrayLength(sMailProtocols),
+ "message/rfc822",
+ nullptr // don't associate .eml extension, as that breaks printing
+ // those
+ },
+ {nsIShellService::NEWS, sNewsProtocols, ArrayLength(sNewsProtocols),
+ nullptr, nullptr},
+ {nsIShellService::RSS, sFeedProtocols, ArrayLength(sFeedProtocols),
+ "application/rss+xml", "rss"},
+ {nsIShellService::CALENDAR, sCalendarProtocols,
+ ArrayLength(sCalendarProtocols), "text/calendar", "ics"}};
+
+nsGNOMEShellService::nsGNOMEShellService()
+ : mUseLocaleFilenames(false),
+ mCheckedThisSession(false),
+ mAppIsInPath(false) {}
+
+nsresult nsGNOMEShellService::Init() {
+ nsresult rv;
+
+ nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID);
+
+ if (!giovfs) return NS_ERROR_NOT_AVAILABLE;
+
+ // Check G_BROKEN_FILENAMES. If it's set, then filenames in glib use
+ // the locale encoding. If it's not set, they use UTF-8.
+ mUseLocaleFilenames = PR_GetEnv("G_BROKEN_FILENAMES") != nullptr;
+
+ if (GetAppPathFromLauncher()) return NS_OK;
+
+ nsCOMPtr<nsIFile> appPath;
+ rv = NS_GetSpecialDirectory(NS_XPCOM_CURRENT_PROCESS_DIR,
+ getter_AddRefs(appPath));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appPath->AppendNative(nsLiteralCString(MOZ_APP_NAME));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appPath->GetNativePath(mAppPath);
+ return rv;
+}
+
+NS_IMPL_ISUPPORTS(nsGNOMEShellService, nsIShellService, nsIToolkitShellService)
+
+bool nsGNOMEShellService::GetAppPathFromLauncher() {
+ gchar* tmp;
+
+ const char* launcher = PR_GetEnv("MOZ_APP_LAUNCHER");
+ if (!launcher) return false;
+
+ if (g_path_is_absolute(launcher)) {
+ mAppPath = launcher;
+ tmp = g_path_get_basename(launcher);
+ gchar* fullpath = g_find_program_in_path(tmp);
+ if (fullpath && mAppPath.Equals(fullpath)) {
+ mAppIsInPath = true;
+ }
+ g_free(fullpath);
+ } else {
+ tmp = g_find_program_in_path(launcher);
+ if (!tmp) return false;
+ mAppPath = tmp;
+ mAppIsInPath = true;
+ }
+
+ g_free(tmp);
+ return true;
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) {
+ *aIsDefaultClient = true;
+
+ for (unsigned int i = 0; i < MOZ_ARRAY_LENGTH(sAppTypes); i++) {
+ if (aApps & sAppTypes[i].type)
+ *aIsDefaultClient &=
+ checkDefault(sAppTypes[i].protocols, sAppTypes[i].protocolsLength);
+ }
+
+ // If this is the first mail window, maintain internal state that we've
+ // checked this session (so that subsequent window opens don't show the
+ // default client dialog).
+ if (aStartupCheck) mCheckedThisSession = true;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::SetDefaultClient(bool aForAllUsers, uint16_t aApps) {
+ nsresult rv = NS_OK;
+ for (unsigned int i = 0; i < MOZ_ARRAY_LENGTH(sAppTypes); i++) {
+ if (aApps & sAppTypes[i].type) {
+ nsresult tmp =
+ MakeDefault(sAppTypes[i].protocols, sAppTypes[i].protocolsLength,
+ sAppTypes[i].mimeType, sAppTypes[i].extensions);
+ if (NS_FAILED(tmp)) {
+ rv = tmp;
+ }
+ }
+ }
+
+ return rv;
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::GetShouldCheckDefaultClient(bool* aResult) {
+ if (mCheckedThisSession) {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->GetBoolPref("mail.shell.checkDefaultClient", aResult);
+}
+
+NS_IMETHODIMP
+nsGNOMEShellService::SetShouldCheckDefaultClient(bool aShouldCheck) {
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->SetBoolPref("mail.shell.checkDefaultClient", aShouldCheck);
+}
+
+bool nsGNOMEShellService::KeyMatchesAppName(const char* aKeyValue) const {
+ gchar* commandPath;
+ if (mUseLocaleFilenames) {
+ gchar* nativePath = g_filename_from_utf8(aKeyValue, -1, NULL, NULL, NULL);
+ if (!nativePath) {
+ NS_ERROR("Error converting path to filesystem encoding");
+ return false;
+ }
+
+ commandPath = g_find_program_in_path(nativePath);
+ g_free(nativePath);
+ } else {
+ commandPath = g_find_program_in_path(aKeyValue);
+ }
+
+ if (!commandPath) return false;
+
+ bool matches = mAppPath.Equals(commandPath);
+ g_free(commandPath);
+ return matches;
+}
+
+bool nsGNOMEShellService::CheckHandlerMatchesAppName(
+ const nsACString& handler) const {
+ gint argc;
+ gchar** argv;
+ nsAutoCString command(handler);
+
+ if (g_shell_parse_argv(command.get(), &argc, &argv, NULL)) {
+ command.Assign(argv[0]);
+ g_strfreev(argv);
+ } else {
+ return false;
+ }
+
+ return KeyMatchesAppName(command.get());
+}
+
+bool nsGNOMEShellService::checkDefault(const char* const* aProtocols,
+ unsigned int aLength) {
+ nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID);
+
+ nsAutoCString handler;
+ nsresult rv;
+
+ for (unsigned int i = 0; i < aLength; ++i) {
+ if (IsRunningAsASnap()) {
+ const gchar* argv[] = {"xdg-settings", "get",
+ "default-url-scheme-handler", aProtocols[i],
+ nullptr};
+ GSpawnFlags flags = static_cast<GSpawnFlags>(G_SPAWN_SEARCH_PATH |
+ G_SPAWN_STDERR_TO_DEV_NULL);
+ gchar* output = nullptr;
+ gint exit_status = 0;
+ if (!g_spawn_sync(nullptr, (gchar**)argv, nullptr, flags, nullptr,
+ nullptr, &output, nullptr, &exit_status, nullptr)) {
+ return false;
+ }
+ if (exit_status != 0) {
+ g_free(output);
+ return false;
+ }
+ if (strcmp(output, "thunderbird.desktop\n") == 0) {
+ g_free(output);
+ return true;
+ }
+ g_free(output);
+ return false;
+ }
+
+ if (giovfs) {
+ handler.Truncate();
+ nsCOMPtr<nsIHandlerApp> handlerApp;
+ rv = giovfs->GetAppForURIScheme(nsDependentCString(aProtocols[i]),
+ getter_AddRefs(handlerApp));
+ if (NS_FAILED(rv) || !handlerApp) {
+ return false;
+ }
+ nsCOMPtr<nsIGIOMimeApp> app = do_QueryInterface(handlerApp, &rv);
+ if (NS_FAILED(rv) || !app) {
+ return false;
+ }
+ rv = app->GetCommand(handler);
+ if (NS_SUCCEEDED(rv) && !CheckHandlerMatchesAppName(handler)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+nsresult nsGNOMEShellService::MakeDefault(const char* const* aProtocols,
+ unsigned int aProtocolsLength,
+ const char* aMimeType,
+ const char* aExtensions) {
+ nsAutoCString appKeyValue;
+ nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID);
+ if (mAppIsInPath) {
+ // mAppPath is in the users path, so use only the basename as the launcher
+ gchar* tmp = g_path_get_basename(mAppPath.get());
+ appKeyValue = tmp;
+ g_free(tmp);
+ } else {
+ appKeyValue = mAppPath;
+ }
+
+ appKeyValue.AppendLiteral(" %s");
+
+ if (IsRunningAsASnap()) {
+ for (unsigned int i = 0; i < aProtocolsLength; ++i) {
+ const gchar* argv[] = {"xdg-settings",
+ "set",
+ "default-url-scheme-handler",
+ aProtocols[i],
+ "thunderbird.desktop",
+ nullptr};
+ GSpawnFlags flags = static_cast<GSpawnFlags>(G_SPAWN_SEARCH_PATH |
+ G_SPAWN_STDOUT_TO_DEV_NULL |
+ G_SPAWN_STDERR_TO_DEV_NULL);
+ g_spawn_sync(nullptr, (gchar**)argv, nullptr, flags, nullptr, nullptr,
+ nullptr, nullptr, nullptr, nullptr);
+ }
+ }
+
+ nsresult rv;
+ if (giovfs) {
+ nsCOMPtr<nsIStringBundleService> bundleService =
+ mozilla::components::StringBundle::Service();
+ NS_ENSURE_TRUE(bundleService, NS_ERROR_UNEXPECTED);
+
+ nsCOMPtr<nsIStringBundle> brandBundle;
+ rv = bundleService->CreateBundle(BRAND_PROPERTIES,
+ getter_AddRefs(brandBundle));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsString brandShortName;
+ brandBundle->GetStringFromName("brandShortName", brandShortName);
+
+ // use brandShortName as the application id.
+ NS_ConvertUTF16toUTF8 id(brandShortName);
+
+ nsCOMPtr<nsIGIOMimeApp> app;
+ rv = giovfs->CreateAppFromCommand(mAppPath, id, getter_AddRefs(app));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (unsigned int i = 0; i < aProtocolsLength; ++i) {
+ rv = app->SetAsDefaultForURIScheme(nsDependentCString(aProtocols[i]));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aMimeType)
+ rv = app->SetAsDefaultForMimeType(nsDependentCString(aMimeType));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aExtensions)
+ rv =
+ app->SetAsDefaultForFileExtensions(nsDependentCString(aExtensions));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ return NS_OK;
+}
diff --git a/comm/mail/components/shell/nsGNOMEShellService.h b/comm/mail/components/shell/nsGNOMEShellService.h
new file mode 100644
index 0000000000..402eb31f41
--- /dev/null
+++ b/comm/mail/components/shell/nsGNOMEShellService.h
@@ -0,0 +1,49 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsGNOMEShellService_h_
+#define nsGNOMEShellService_h_
+
+#include "nsIShellService.h"
+#include "nsString.h"
+#include "nsToolkitShellService.h"
+
+#define BRAND_PROPERTIES "chrome://branding/locale/brand.properties"
+
+#define NS_MAILGNOMEINTEGRATION_CID \
+ { \
+ 0xbddef0f4, 0x5e2d, 0x4846, { \
+ 0xbd, 0xec, 0x86, 0xd0, 0x78, 0x1d, 0x8d, 0xed \
+ } \
+ }
+
+class nsGNOMEShellService : public nsIShellService,
+ public nsToolkitShellService {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSISHELLSERVICE
+
+ nsresult Init();
+ nsGNOMEShellService();
+
+ protected:
+ virtual ~nsGNOMEShellService(){};
+
+ bool KeyMatchesAppName(const char* aKeyValue) const;
+ bool checkDefault(const char* const* aProtocols, unsigned int aLength);
+ nsresult MakeDefault(const char* const* aProtocols,
+ unsigned int aProtocolsLength, const char* mimeType,
+ const char* extensions);
+
+ private:
+ bool GetAppPathFromLauncher();
+ bool CheckHandlerMatchesAppName(const nsACString& handler) const;
+ bool mUseLocaleFilenames;
+ bool mCheckedThisSession;
+ nsCString mAppPath;
+ bool mAppIsInPath;
+};
+
+#endif
diff --git a/comm/mail/components/shell/nsIShellService.idl b/comm/mail/components/shell/nsIShellService.idl
new file mode 100644
index 0000000000..307cf9e48d
--- /dev/null
+++ b/comm/mail/components/shell/nsIShellService.idl
@@ -0,0 +1,52 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(95F53544-F445-48d1-B3A2-D54AA020BC3D)]
+interface nsIShellService : nsISupports
+{
+ /**
+ * app types we can be registered to handle
+ */
+ const unsigned short MAIL = 0x0001;
+ const unsigned short NEWS = 0x0002;
+ const unsigned short RSS = 0x0004;
+ const unsigned short CALENDAR = 0x0008;
+
+ /**
+ * Determines whether or not Thunderbird is the "Default Client" for the
+ * passed in app type.
+ *
+ * This is simply whether or not Thunderbid is registered to handle
+ * the url scheme associated with the app.
+ *
+ * @param aStartupCheck true if this is the check being performed
+ * by the first mail window at startup,
+ * false otherwise.
+ * @param aApps the application types being tested (Mail, News, RSS, etc.)
+ */
+ boolean isDefaultClient(in boolean aStartupCheck, in unsigned short aApps);
+
+ /**
+ * Registers Thunderbird as the "Default Mail Client" for the
+ * passed in app type.
+ *
+ * @param aForAllUsers Whether or not Thunderbird should attempt
+ * to become the default client for all
+ * users on a multi-user system.
+ * @param aApps the application types being tested (Mail, News, RSS, etc.)
+ */
+ void setDefaultClient(in boolean aForAllUsers, in unsigned short aApps);
+
+ /**
+ * Used to determine whether or not to show a "Set Default Client"
+ * query dialog. This attribute is true if the application is starting
+ * up and "mail.shell.checkDefaultClient" is true, otherwise it
+ * is false.
+ */
+ attribute boolean shouldCheckDefaultClient;
+};
diff --git a/comm/mail/components/shell/nsMacShellService.cpp b/comm/mail/components/shell/nsMacShellService.cpp
new file mode 100644
index 0000000000..383e3a2896
--- /dev/null
+++ b/comm/mail/components/shell/nsMacShellService.cpp
@@ -0,0 +1,156 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsMacShellService.h"
+#include "nsCOMPtr.h"
+#include "nsIServiceManager.h"
+#include "nsIStringBundle.h"
+#include "nsIPromptService.h"
+#include "nsIPrefService.h"
+#include "nsIPrefBranch.h"
+#include "nsString.h"
+#include "nsEmbedCID.h"
+
+// These Launch Services functions are undocumented. We're using them since
+// they're the only way to set the default opener for URLs
+extern "C" {
+// Returns the CFURL for application currently set as the default opener for
+// the given URL scheme. appURL must be released by the caller.
+extern OSStatus _LSCopyDefaultSchemeHandlerURL(CFStringRef scheme,
+ CFURLRef* appURL);
+extern OSStatus _LSSetDefaultSchemeHandlerURL(CFStringRef scheme,
+ CFURLRef appURL);
+extern OSStatus _LSSaveAndRefresh(void);
+}
+
+NS_IMPL_ISUPPORTS(nsMacShellService, nsIShellService, nsIToolkitShellService)
+
+nsMacShellService::nsMacShellService() : mCheckedThisSession(false) {}
+
+NS_IMETHODIMP
+nsMacShellService::IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) {
+ *aIsDefaultClient = true;
+ if (aApps & nsIShellService::MAIL)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("mailto"));
+ if (aApps & nsIShellService::NEWS)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("news"));
+ if (aApps & nsIShellService::RSS)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("feed"));
+ if (aApps & nsIShellService::CALENDAR)
+ *aIsDefaultClient &= isDefaultHandlerForProtocol(CFSTR("webcal"));
+
+ // if this is the first mail window, maintain internal state that we've
+ // checked this session (so that subsequent window opens don't show the
+ // default client dialog.
+
+ if (aStartupCheck) mCheckedThisSession = true;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsMacShellService::SetDefaultClient(bool aForAllUsers, uint16_t aApps) {
+ nsresult rv = NS_OK;
+ if (aApps & nsIShellService::MAIL) {
+ rv = setAsDefaultHandlerForProtocol(CFSTR("mailto"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = setAsDefaultHandlerForProtocol(CFSTR("mid"));
+ }
+ if (NS_SUCCEEDED(rv) && aApps & nsIShellService::NEWS)
+ rv = setAsDefaultHandlerForProtocol(CFSTR("news"));
+ if (NS_SUCCEEDED(rv) && aApps & nsIShellService::RSS)
+ rv = setAsDefaultHandlerForProtocol(CFSTR("feed"));
+ if (NS_SUCCEEDED(rv) && aApps & nsIShellService::CALENDAR) {
+ rv = setAsDefaultHandlerForProtocol(CFSTR("webcal"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = setAsDefaultHandlerForProtocol(CFSTR("webcals"));
+ }
+
+ return rv;
+}
+
+NS_IMETHODIMP
+nsMacShellService::GetShouldCheckDefaultClient(bool* aResult) {
+ if (mCheckedThisSession) {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->GetBoolPref("mail.shell.checkDefaultClient", aResult);
+}
+
+NS_IMETHODIMP
+nsMacShellService::SetShouldCheckDefaultClient(bool aShouldCheck) {
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->SetBoolPref("mail.shell.checkDefaultClient", aShouldCheck);
+}
+
+bool nsMacShellService::isDefaultHandlerForProtocol(CFStringRef aScheme) {
+ bool isDefault = false;
+ // Since neither Launch Services nor Internet Config actually differ between
+ // bundles which have the same bundle identifier (That is, if we set our
+ // URL of our bundle as the default handler for the given protocol,
+ // Launch Service might return the URL of another thunderbird bundle as the
+ // default handler for that protocol), we are comparing the identifiers of the
+ // bundles rather than their URLs.
+
+ CFStringRef tbirdID = ::CFBundleGetIdentifier(CFBundleGetMainBundle());
+ if (!tbirdID) {
+ // CFBundleGetIdentifier is expected to return NULL only if the specified
+ // bundle doesn't have a bundle identifier in its dictionary. In this case,
+ // that means a failure, since our bundle does have an identifier.
+ return isDefault;
+ }
+
+ ::CFRetain(tbirdID);
+
+ // Get the default handler URL of the given protocol
+ CFURLRef defaultHandlerURL;
+ OSStatus err = ::_LSCopyDefaultSchemeHandlerURL(aScheme, &defaultHandlerURL);
+
+ if (err == noErr) {
+ // Get a reference to the bundle (based on its URL)
+ CFBundleRef defaultHandlerBundle =
+ ::CFBundleCreate(NULL, defaultHandlerURL);
+ if (defaultHandlerBundle) {
+ CFStringRef defaultHandlerID =
+ ::CFBundleGetIdentifier(defaultHandlerBundle);
+ if (defaultHandlerID) {
+ ::CFRetain(defaultHandlerID);
+ // and compare it to our bundle identifier
+ isDefault = ::CFStringCompare(tbirdID, defaultHandlerID, 0) ==
+ kCFCompareEqualTo;
+ ::CFRelease(defaultHandlerID);
+ } else {
+ // If the bundle doesn't have an identifier in its info property list,
+ // it's not our bundle.
+ isDefault = false;
+ }
+
+ ::CFRelease(defaultHandlerBundle);
+ }
+
+ ::CFRelease(defaultHandlerURL);
+ } else {
+ // If |_LSCopyDefaultSchemeHandlerURL| failed, there's no default
+ // handler for the given protocol
+ isDefault = false;
+ }
+
+ ::CFRelease(tbirdID);
+ return isDefault;
+}
+
+nsresult nsMacShellService::setAsDefaultHandlerForProtocol(
+ CFStringRef aScheme) {
+ CFURLRef tbirdURL = ::CFBundleCopyBundleURL(CFBundleGetMainBundle());
+
+ ::_LSSetDefaultSchemeHandlerURL(aScheme, tbirdURL);
+ ::_LSSaveAndRefresh();
+ ::CFRelease(tbirdURL);
+
+ return NS_OK;
+}
diff --git a/comm/mail/components/shell/nsMacShellService.h b/comm/mail/components/shell/nsMacShellService.h
new file mode 100644
index 0000000000..7a301cb2fb
--- /dev/null
+++ b/comm/mail/components/shell/nsMacShellService.h
@@ -0,0 +1,36 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsMacShellService_h_
+#define nsMacShellService_h_
+
+#include "nsIShellService.h"
+#include "nsString.h"
+#include "nsToolkitShellService.h"
+
+#include <CoreFoundation/CoreFoundation.h>
+
+#define NS_MAILMACINTEGRATION_CID \
+ { \
+ 0x85a27035, 0xb970, 0x4079, { \
+ 0xb9, 0xd2, 0xe2, 0x1f, 0x69, 0xe6, 0xb2, 0x1f \
+ } \
+ }
+
+class nsMacShellService : public nsIShellService, public nsToolkitShellService {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSISHELLSERVICE
+ nsMacShellService();
+
+ protected:
+ bool isDefaultHandlerForProtocol(CFStringRef aScheme);
+ nsresult setAsDefaultHandlerForProtocol(CFStringRef aScheme);
+
+ private:
+ virtual ~nsMacShellService(){};
+ bool mCheckedThisSession;
+};
+#endif
diff --git a/comm/mail/components/shell/nsToolkitShellService.h b/comm/mail/components/shell/nsToolkitShellService.h
new file mode 100644
index 0000000000..160f9d7cbe
--- /dev/null
+++ b/comm/mail/components/shell/nsToolkitShellService.h
@@ -0,0 +1,23 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nstoolkitshellservice_h____
+#define nstoolkitshellservice_h____
+
+#include "nsIToolkitShellService.h"
+
+class nsToolkitShellService : public nsIToolkitShellService {
+ public:
+ NS_IMETHOD IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) = 0;
+
+ NS_IMETHODIMP IsDefaultApplication(bool* aIsDefaultClient) {
+ // This does some OS-specific checking: GConf on Linux, mailto/news protocol
+ // handler on Mac, registry and application association checks on Windows.
+ return IsDefaultClient(false, nsIShellService::MAIL, aIsDefaultClient);
+ }
+};
+
+#endif // nstoolkitshellservice_h____
diff --git a/comm/mail/components/shell/nsWindowsShellService.cpp b/comm/mail/components/shell/nsWindowsShellService.cpp
new file mode 100644
index 0000000000..e82cf0ed0e
--- /dev/null
+++ b/comm/mail/components/shell/nsWindowsShellService.cpp
@@ -0,0 +1,329 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsWindowsShellService.h"
+#include "nsIServiceManager.h"
+#include "nsICategoryManager.h"
+#include "nsNativeCharsetUtils.h"
+#include "nsIPrefService.h"
+#include "windows.h"
+#include "shellapi.h"
+#include "nsIFile.h"
+#include "nsDirectoryServiceDefs.h"
+#include "nsUnicharUtils.h"
+#include "nsComponentManagerUtils.h"
+#include "nsServiceManagerUtils.h"
+#include "nsIProperties.h"
+#include "nsString.h"
+
+#ifdef _WIN32_WINNT
+# undef _WIN32_WINNT
+#endif
+#define _WIN32_WINNT 0x0600
+#define INITGUID
+#include <shlobj.h>
+
+#include <mbstring.h>
+
+#ifndef MAX_BUF
+# define MAX_BUF 4096
+#endif
+
+#define REG_FAILED(val) (val != ERROR_SUCCESS)
+
+NS_IMPL_ISUPPORTS(nsWindowsShellService, nsIShellService,
+ nsIToolkitShellService)
+
+static nsresult OpenKeyForReading(HKEY aKeyRoot, const nsAString& aKeyName,
+ HKEY* aKey) {
+ const nsString& flatName = PromiseFlatString(aKeyName);
+
+ DWORD res = ::RegOpenKeyExW(aKeyRoot, flatName.get(), 0, KEY_READ, aKey);
+ switch (res) {
+ case ERROR_SUCCESS:
+ break;
+ case ERROR_ACCESS_DENIED:
+ return NS_ERROR_FILE_ACCESS_DENIED;
+ case ERROR_FILE_NOT_FOUND:
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ return NS_OK;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Default Mail Registry Settings
+///////////////////////////////////////////////////////////////////////////////
+
+typedef enum {
+ NO_SUBSTITUTION = 0x00,
+ APP_PATH_SUBSTITUTION = 0x01
+} SettingFlags;
+
+// APP_REG_NAME_MAIL and APP_REG_NAME_NEWS should be kept in synch with
+// AppRegNameMail and AppRegNameNews in the installer file: defines.nsi.in
+#define APP_REG_NAME_MAIL L"Thunderbird"
+#define APP_REG_NAME_NEWS L"Thunderbird (News)"
+#define APP_REG_NAME_CALENDAR L"Thunderbird (Calendar)"
+#define CLS_EML "ThunderbirdEML"
+#define CLS_MAILTOURL "Thunderbird.Url.mailto"
+#define CLS_MIDURL "Thunderbird.Url.mid"
+#define CLS_NEWSURL "Thunderbird.Url.news"
+#define CLS_FEEDURL "Thunderbird.Url.feed"
+#define CLS_WEBCALURL "Thunderbird.Url.webcal"
+#define CLS_ICS "ThunderbirdICS"
+#define SOP "\\shell\\open\\command"
+#define VAL_OPEN "\"%APPPATH%\" \"%1\""
+#define VAL_MAIL_OPEN "\"%APPPATH%\" -osint -mail \"%1\""
+#define VAL_COMPOSE_OPEN "\"%APPPATH%\" -osint -compose \"%1\""
+
+#define MAKE_KEY_NAME1(PREFIX, MID) PREFIX MID
+
+static SETTING gMailSettings[] = {
+ // File Extension Class
+ {".eml", "", CLS_EML, NO_SUBSTITUTION},
+
+ // File Extension Class
+ {MAKE_KEY_NAME1(CLS_EML, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+
+ // Protocol Handler Class - for Vista and above
+ {MAKE_KEY_NAME1(CLS_MAILTOURL, SOP), "", VAL_COMPOSE_OPEN,
+ APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1(CLS_MIDURL, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+
+ // Protocol Handlers
+ {MAKE_KEY_NAME1("mailto", SOP), "", VAL_COMPOSE_OPEN,
+ APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("mid", SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+};
+
+static SETTING gNewsSettings[] = {
+ // Protocol Handler Class - for Vista and above
+ {MAKE_KEY_NAME1(CLS_NEWSURL, SOP), "", VAL_MAIL_OPEN,
+ APP_PATH_SUBSTITUTION},
+
+ // Protocol Handlers
+ {MAKE_KEY_NAME1("news", SOP), "", VAL_MAIL_OPEN, APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("nntp", SOP), "", VAL_MAIL_OPEN, APP_PATH_SUBSTITUTION},
+};
+
+static SETTING gCalendarSettings[] = {
+ // File Extension Class
+ {".ics", "", CLS_ICS, NO_SUBSTITUTION},
+
+ // File Extension Class
+ {MAKE_KEY_NAME1(CLS_ICS, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+
+ // Protocol Handlers
+ {MAKE_KEY_NAME1(CLS_WEBCALURL, SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("webcal", SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+ {MAKE_KEY_NAME1("webcals", SOP), "", VAL_OPEN, APP_PATH_SUBSTITUTION},
+};
+
+nsresult GetHelperPath(nsAutoString& aPath) {
+ nsresult rv;
+ nsCOMPtr<nsIProperties> directoryService =
+ do_GetService(NS_DIRECTORY_SERVICE_CONTRACTID, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIFile> appHelper;
+ rv = directoryService->Get(NS_XPCOM_CURRENT_PROCESS_DIR, NS_GET_IID(nsIFile),
+ getter_AddRefs(appHelper));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appHelper->Append(u"uninstall"_ns);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = appHelper->Append(u"helper.exe"_ns);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return appHelper->GetPath(aPath);
+}
+
+nsresult LaunchHelper(nsAutoString& aPath, nsAutoString& aParams) {
+ SHELLEXECUTEINFOW executeInfo = {0};
+
+ executeInfo.cbSize = sizeof(SHELLEXECUTEINFOW);
+ executeInfo.hwnd = NULL;
+ executeInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
+ executeInfo.lpDirectory = NULL;
+ executeInfo.lpFile = aPath.get();
+ executeInfo.lpParameters = aParams.get();
+ executeInfo.nShow = SW_SHOWNORMAL;
+
+ if (ShellExecuteExW(&executeInfo))
+ // Block until the program exits
+ WaitForSingleObject(executeInfo.hProcess, INFINITE);
+ else
+ return NS_ERROR_ABORT;
+
+ // We're going to ignore errors here since there's nothing we can do about
+ // them, and helper.exe seems to return non-zero ret on success.
+ return NS_OK;
+}
+
+nsresult nsWindowsShellService::Init() {
+ WCHAR appPath[MAX_BUF];
+ if (!::GetModuleFileNameW(0, appPath, MAX_BUF)) return NS_ERROR_FAILURE;
+
+ // Convert the path to a long path since GetModuleFileNameW returns the path
+ // that was used to launch the app which is not necessarily a long path.
+ if (!::GetLongPathNameW(appPath, appPath, MAX_BUF)) return NS_ERROR_FAILURE;
+
+ mAppLongPath = appPath;
+
+ return NS_OK;
+}
+
+nsWindowsShellService::nsWindowsShellService() : mCheckedThisSession(false) {}
+
+NS_IMETHODIMP
+nsWindowsShellService::IsDefaultClient(bool aStartupCheck, uint16_t aApps,
+ bool* aIsDefaultClient) {
+ // If this is the first mail window, maintain internal state that we've
+ // checked this session (so that subsequent window opens don't show the
+ // default client dialog).
+ if (aStartupCheck) mCheckedThisSession = true;
+
+ *aIsDefaultClient = true;
+
+ // for each type,
+ if (aApps & nsIShellService::MAIL) {
+ *aIsDefaultClient &=
+ TestForDefault(gMailSettings, sizeof(gMailSettings) / sizeof(SETTING));
+ // Only check if this app is default on Vista if the previous checks
+ // indicate that this app is the default.
+ if (*aIsDefaultClient)
+ IsDefaultClientVista(nsIShellService::MAIL, aIsDefaultClient);
+ }
+ if (aApps & nsIShellService::NEWS) {
+ *aIsDefaultClient &=
+ TestForDefault(gNewsSettings, sizeof(gNewsSettings) / sizeof(SETTING));
+ // Only check if this app is default on Vista if the previous checks
+ // indicate that this app is the default.
+ if (*aIsDefaultClient)
+ IsDefaultClientVista(nsIShellService::NEWS, aIsDefaultClient);
+ }
+ if (aApps & nsIShellService::CALENDAR) {
+ *aIsDefaultClient &= TestForDefault(
+ gCalendarSettings, sizeof(gCalendarSettings) / sizeof(SETTING));
+ // Only check if this app is default on Vista if the previous checks
+ // indicate that this app is the default.
+ if (*aIsDefaultClient)
+ IsDefaultClientVista(nsIShellService::CALENDAR, aIsDefaultClient);
+ }
+ // RSS / feed protocol shell integration is not working so return true
+ // until it is fixed (bug 445823).
+ if (aApps & nsIShellService::RSS) *aIsDefaultClient &= true;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsWindowsShellService::SetDefaultClient(bool aForAllUsers, uint16_t aApps) {
+ nsAutoString appHelperPath;
+ if (NS_FAILED(GetHelperPath(appHelperPath))) return NS_ERROR_FAILURE;
+
+ nsAutoString params;
+ if (aForAllUsers) {
+ params.AppendLiteral(" /SetAsDefaultAppGlobal");
+ } else {
+ params.AppendLiteral(" /SetAsDefaultAppUser");
+ if (aApps & nsIShellService::MAIL) params.AppendLiteral(" Mail");
+
+ if (aApps & nsIShellService::NEWS) params.AppendLiteral(" News");
+
+ if (aApps & nsIShellService::CALENDAR) params.AppendLiteral(" Calendar");
+ }
+
+ return LaunchHelper(appHelperPath, params);
+}
+
+NS_IMETHODIMP
+nsWindowsShellService::GetShouldCheckDefaultClient(bool* aResult) {
+ if (mCheckedThisSession) {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->GetBoolPref("mail.shell.checkDefaultClient", aResult);
+}
+
+NS_IMETHODIMP
+nsWindowsShellService::SetShouldCheckDefaultClient(bool aShouldCheck) {
+ nsCOMPtr<nsIPrefBranch> prefs(do_GetService(NS_PREFSERVICE_CONTRACTID));
+ return prefs->SetBoolPref("mail.shell.checkDefaultClient", aShouldCheck);
+}
+
+/* helper routine. Iterate over the passed in settings object. */
+bool nsWindowsShellService::TestForDefault(SETTING aSettings[], int32_t aSize) {
+ bool isDefault = true;
+ char16_t currValue[MAX_BUF];
+ SETTING* end = aSettings + aSize;
+ for (SETTING* settings = aSettings; settings < end; ++settings) {
+ NS_ConvertUTF8toUTF16 dataLongPath(settings->valueData);
+ NS_ConvertUTF8toUTF16 key(settings->keyName);
+ NS_ConvertUTF8toUTF16 value(settings->valueName);
+ if (settings->flags & APP_PATH_SUBSTITUTION) {
+ int32_t offset = dataLongPath.Find(u"%APPPATH%");
+ dataLongPath.Replace(offset, 9, mAppLongPath);
+ }
+
+ ::ZeroMemory(currValue, sizeof(currValue));
+ HKEY theKey;
+ nsresult rv = OpenKeyForReading(HKEY_CLASSES_ROOT, key, &theKey);
+ if (NS_FAILED(rv)) {
+ // Key doesn't exist
+ isDefault = false;
+ break;
+ }
+
+ DWORD len = sizeof currValue;
+ DWORD result = ::RegQueryValueExW(theKey, value.get(), NULL, NULL,
+ (LPBYTE)currValue, &len);
+ // Close the key we opened.
+ ::RegCloseKey(theKey);
+ if (REG_FAILED(result) ||
+ !dataLongPath.Equals(currValue, nsCaseInsensitiveStringComparator)) {
+ // Key wasn't set, or was set to something else (something else became the
+ // default client)
+ isDefault = false;
+ break;
+ }
+ } // for each registry key we want to look at
+
+ return isDefault;
+}
+
+bool nsWindowsShellService::IsDefaultClientVista(uint16_t aApps,
+ bool* aIsDefaultClient) {
+ IApplicationAssociationRegistration* pAAR;
+
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, NULL, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, (void**)&pAAR);
+
+ if (SUCCEEDED(hr)) {
+ BOOL isDefaultMail = true;
+ BOOL isDefaultNews = true;
+ BOOL isDefaultCalendar = true;
+ if (aApps & nsIShellService::MAIL)
+ pAAR->QueryAppIsDefaultAll(AL_EFFECTIVE, APP_REG_NAME_MAIL,
+ &isDefaultMail);
+ if (aApps & nsIShellService::NEWS)
+ pAAR->QueryAppIsDefaultAll(AL_EFFECTIVE, APP_REG_NAME_NEWS,
+ &isDefaultNews);
+ if (aApps & nsIShellService::CALENDAR)
+ pAAR->QueryAppIsDefaultAll(AL_EFFECTIVE, APP_REG_NAME_CALENDAR,
+ &isDefaultCalendar);
+
+ *aIsDefaultClient = isDefaultNews && isDefaultMail && isDefaultCalendar;
+
+ pAAR->Release();
+ return true;
+ }
+ return false;
+}
diff --git a/comm/mail/components/shell/nsWindowsShellService.h b/comm/mail/components/shell/nsWindowsShellService.h
new file mode 100644
index 0000000000..0dd1f760a8
--- /dev/null
+++ b/comm/mail/components/shell/nsWindowsShellService.h
@@ -0,0 +1,51 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsWindowsShellService_h_
+#define nsWindowsShellService_h_
+
+#include "nsIShellService.h"
+#include "nsIObserver.h"
+#include "nsString.h"
+#include "nsToolkitShellService.h"
+
+#include <ole2.h>
+#include <windows.h>
+
+#define NS_MAILWININTEGRATION_CID \
+ { \
+ 0x2ebbe84, 0xc179, 0x4598, { \
+ 0xaf, 0x18, 0x1b, 0xf2, 0xc4, 0xbc, 0x1d, 0xf9 \
+ } \
+ }
+
+typedef struct {
+ const char* keyName;
+ const char* valueName;
+ const char* valueData;
+
+ int32_t flags;
+} SETTING;
+
+class nsWindowsShellService : public nsIShellService,
+ public nsToolkitShellService {
+ public:
+ nsWindowsShellService();
+ nsresult Init();
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSISHELLSERVICE
+
+ protected:
+ bool TestForDefault(SETTING aSettings[], int32_t aSize);
+ bool IsDefaultClientVista(uint16_t aApps, bool* aIsDefaultClient);
+
+ private:
+ virtual ~nsWindowsShellService(){};
+ bool mCheckedThisSession;
+ nsAutoString mAppLongPath;
+};
+
+#endif
diff --git a/comm/mail/components/shell/test/unit/test_shellService.js b/comm/mail/components/shell/test/unit/test_shellService.js
new file mode 100644
index 0000000000..ebd9f85532
--- /dev/null
+++ b/comm/mail/components/shell/test/unit/test_shellService.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test setDefaultClient works for all supported types.
+ */
+add_task(function test_setDefaultClient() {
+ let shellSvc = Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+
+ let types = ["MAIL", "NEWS", "RSS", "CALENDAR"];
+
+ for (let type of types) {
+ shellSvc.setDefaultClient(false, shellSvc[type]);
+ ok(
+ shellSvc.isDefaultClient(false, shellSvc[type]),
+ `setDefaultClient works for type ${type}`
+ );
+ }
+});
diff --git a/comm/mail/components/shell/test/unit/xpcshell.ini b/comm/mail/components/shell/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..0983e89525
--- /dev/null
+++ b/comm/mail/components/shell/test/unit/xpcshell.ini
@@ -0,0 +1,2 @@
+[test_shellService.js]
+skip-if = os == 'win' # setDefaultClient requires user confirmation on Windows.
diff --git a/comm/mail/components/storybook/.storybook/main.js b/comm/mail/components/storybook/.storybook/main.js
new file mode 100644
index 0000000000..743243b4a0
--- /dev/null
+++ b/comm/mail/components/storybook/.storybook/main.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint-env node */
+
+const path = require("path");
+
+// ./mach environment --format json
+// topobjdir should be the build location
+
+module.exports = {
+ stories: [
+ "../stories/**/*.stories.mdx",
+ "../stories/**/*.stories.@(mjs|jsx|ts|tsx)",
+ ],
+ addons: ["@storybook/addon-links", "@storybook/addon-essentials"],
+ framework: "@storybook/web-components",
+ webpackFinal: async (config, { configType }) => {
+ // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
+ // You can change the configuration based on that.
+ // 'PRODUCTION' is used when building the static version of storybook.
+
+ // Make whatever fine-grained changes you need
+ const projectRoot = path.resolve(__dirname, "../../../../");
+ config.resolve.alias.mail = `${projectRoot}/mail`;
+
+ config.module.rules.push({
+ test: /\.ftl$/,
+ type: "asset/source",
+ });
+
+ config.optimization = {
+ splitChunks: false,
+ runtimeChunk: false,
+ sideEffects: false,
+ usedExports: false,
+ concatenateModules: false,
+ minimizer: [],
+ };
+
+ // Return the altered config
+ return config;
+ },
+ core: {
+ builder: "webpack5",
+ },
+};
diff --git a/comm/mail/components/storybook/.storybook/preview-head.html b/comm/mail/components/storybook/.storybook/preview-head.html
new file mode 100644
index 0000000000..90b30870b9
--- /dev/null
+++ b/comm/mail/components/storybook/.storybook/preview-head.html
@@ -0,0 +1,5 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<link rel="stylesheet" href="chrome://global/skin/global.css">
diff --git a/comm/mail/components/storybook/.storybook/preview.mjs b/comm/mail/components/storybook/.storybook/preview.mjs
new file mode 100644
index 0000000000..6b654c6049
--- /dev/null
+++ b/comm/mail/components/storybook/.storybook/preview.mjs
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { DOMLocalization } from "@fluent/dom";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+
+// Base Fluent set up.
+let storybookBundle = new FluentBundle("en-US");
+let loadedResources = new Set();
+function* generateBundles() {
+ yield* [storybookBundle];
+}
+document.l10n = new DOMLocalization([], generateBundles);
+document.l10n.connectRoot(document.documentElement);
+
+// Any fluent imports should go through MozXULElement.insertFTLIfNeeded.
+window.MozXULElement = {
+ async insertFTLIfNeeded(name) {
+ if (loadedResources.has(name)) {
+ return;
+ }
+ //TODO might have to dynamically change this depending on where a component
+ // lives in the tree (for example for calendar or mailnews).
+ // eslint-disable-next-line no-unsanitized/method
+ let imported = await import(
+ /* webpackInclude: /.*[\/\\].*\.ftl/ */
+ `mail/locales/en-US/${name}`
+ );
+ let ftlContents = imported.default;
+
+ if (loadedResources.has(name)) {
+ // Seems possible we've attempted to load this twice before the first call
+ // resolves, so once the first load is complete we can abandon the others.
+ return;
+ }
+
+ let ftlResource = new FluentResource(ftlContents);
+ storybookBundle.addResource(ftlResource);
+ loadedResources.add(name);
+ document.l10n.translateRoots();
+ },
+};
diff --git a/comm/mail/components/storybook/README.md b/comm/mail/components/storybook/README.md
new file mode 100644
index 0000000000..4fe739c07a
--- /dev/null
+++ b/comm/mail/components/storybook/README.md
@@ -0,0 +1,39 @@
+= Storybook for Thunderbird
+
+Storybook is a component library to document our design system, reusable
+components and any specific components you might want to test with dummy data.
+
+== Background
+
+The storybook will list components that can be reused, and will help document
+what common elements we have. It can also list implementation specific
+components, but they should not be added to the "Design System" section.
+
+Changes to files directly referenced from the storybook (so basically
+non-chrome:// paths) should automatically reflect changes in the opened tab.
+If you make a change to a chrome:// referenced file then you'll need to do a
+hard refresh (Cmd+Shift+R/Ctrl+Shift+R) to notice the changes.
+
+=== Running storybook
+
+First time around, you will have to install the npm dependencies for storybook.
+There is a mach command to do so using the mach-provided `npm`:
+
+```
+# Working directory is your comm-central checkout root directory.
+../mach tb-storybook install
+```
+
+Once the npm dependencies are installed, you can run storybook by executing
+
+```
+# Working directory is your comm-central checkout root directory.
+../mach tb-storybook
+```
+
+Now storybook should be running at `http://localhost:5703`. To use storybook, run
+the following command in your Thunderbird developer console:
+
+```js
+tabmail.openTab("contentTab", { url: "http://localhost:5703" })
+```
diff --git a/comm/mail/components/storybook/mach_commands.py b/comm/mail/components/storybook/mach_commands.py
new file mode 100644
index 0000000000..568e9c08b2
--- /dev/null
+++ b/comm/mail/components/storybook/mach_commands.py
@@ -0,0 +1,42 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from mach.decorators import Command, SubCommand
+
+
+def run_mach(command_context, cmd, **kwargs):
+ return command_context._mach_context.commands.dispatch(
+ cmd, command_context._mach_context, **kwargs
+ )
+
+
+def run_npm(command_context, args):
+ return run_mach(command_context, "npm", args=[*args, "--prefix=mail/components/storybook"])
+
+
+@Command(
+ "tb-storybook",
+ category="misc",
+ description="Start the Storybook server",
+)
+def storybook_run(command_context):
+ return run_npm(command_context, args=["run", "storybook"])
+
+
+@SubCommand(
+ "tb-storybook",
+ "install",
+ description="Install Storybook node dependencies.",
+)
+def storybook_install(command_context):
+ return run_npm(command_context, args=["ci"])
+
+
+@SubCommand(
+ "tb-storybook",
+ "build",
+ description="Build the Storybook for export.",
+)
+def storybook_build(command_context):
+ return run_npm(command_context, args=["run", "build-storybook"])
diff --git a/comm/mail/components/storybook/package-lock.json b/comm/mail/components/storybook/package-lock.json
new file mode 100644
index 0000000000..02ca970af4
--- /dev/null
+++ b/comm/mail/components/storybook/package-lock.json
@@ -0,0 +1,37747 @@
+{
+ "name": "mail-storybook",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mail-storybook",
+ "version": "1.0.0",
+ "license": "MPL-2.0",
+ "devDependencies": {
+ "@babel/core": "^7.19.3",
+ "@fluent/bundle": "^0.17.1",
+ "@fluent/dom": "^0.8.1",
+ "@storybook/addon-actions": "^6.5.12",
+ "@storybook/addon-essentials": "^6.5.12",
+ "@storybook/addon-links": "^6.5.12",
+ "@storybook/builder-webpack5": "^6.5.12",
+ "@storybook/manager-webpack5": "^6.5.12",
+ "@storybook/web-components": "^6.5.12",
+ "babel-loader": "^8.2.5",
+ "lit": "^2.3.1"
+ }
+ },
+ "../../..": {
+ "extraneous": true
+ },
+ "../../../..": {
+ "extraneous": true,
+ "license": "MPL-2.0",
+ "devDependencies": {
+ "@babel/core": "7.19.1",
+ "@babel/eslint-parser": "7.19.1",
+ "@babel/eslint-plugin": "7.19.1",
+ "@babel/plugin-syntax-jsx": "7.18.6",
+ "@microsoft/eslint-plugin-sdl": "github:mozfreddyb/eslint-plugin-sdl#17b22cd527682108af7a1a4edacf69cb7a9b4a06",
+ "eslint": "8.24.0",
+ "eslint-config-prettier": "8.5.0",
+ "eslint-plugin-fetch-options": "0.0.5",
+ "eslint-plugin-file-header": "0.0.1",
+ "eslint-plugin-html": "7.1.0",
+ "eslint-plugin-import": "2.26.0",
+ "eslint-plugin-jest": "23.20.0",
+ "eslint-plugin-jsdoc": "39.3.6",
+ "eslint-plugin-jsx-a11y": "6.6.1",
+ "eslint-plugin-mozilla": "file:tools/lint/eslint/eslint-plugin-mozilla",
+ "eslint-plugin-no-unsanitized": "4.0.1",
+ "eslint-plugin-prettier": "3.4.0",
+ "eslint-plugin-react": "7.29.4",
+ "eslint-plugin-react-hooks": "4.6.0",
+ "eslint-plugin-spidermonkey-js": "file:tools/lint/eslint/eslint-plugin-spidermonkey-js",
+ "jsdoc": "3.6.11",
+ "prettier": "1.19.1",
+ "yarn": "1.22.19"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+ "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.1.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+ "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/highlight": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz",
+ "integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz",
+ "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==",
+ "dev": true,
+ "dependencies": {
+ "@ampproject/remapping": "^2.1.0",
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helpers": "^7.19.0",
+ "@babel/parser": "^7.19.3",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.1",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz",
+ "integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.19.3",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "jsesc": "^2.5.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+ "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz",
+ "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-explode-assignable-expression": "^7.18.6",
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz",
+ "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-validator-option": "^7.18.6",
+ "browserslist": "^4.21.3",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "regexpu-core": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz",
+ "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.17.7",
+ "@babel/helper-plugin-utils": "^7.16.7",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0-0"
+ }
+ },
+ "node_modules/@babel/helper-environment-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+ "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-explode-assignable-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz",
+ "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-function-name": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+ "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.18.10",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-hoist-variables": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+ "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz",
+ "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+ "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz",
+ "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz",
+ "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+ "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz",
+ "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-wrap-function": "^7.18.9",
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz",
+ "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/traverse": "^7.19.1",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-simple-access": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz",
+ "integrity": "sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz",
+ "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-split-export-declaration": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+ "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz",
+ "integrity": "sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+ "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+ "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz",
+ "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.0.tgz",
+ "integrity": "sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+ "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "chalk": "^2.0.0",
+ "js-tokens": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz",
+ "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==",
+ "dev": true,
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz",
+ "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-async-generator-functions": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz",
+ "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-remap-async-to-generator": "^7.18.9",
+ "@babel/plugin-syntax-async-generators": "^7.8.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-class-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+ "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-class-static-block": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz",
+ "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-decorators": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.3.tgz",
+ "integrity": "sha512-MbgXtNXqo7RTKYIXVchVJGPvaVufQH3pxvQyfbGvNw1DObIhph+PesYXJTcd8J4DdWibvf6Z2eanOyItX8WnJg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.19.1",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/plugin-syntax-decorators": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-dynamic-import": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz",
+ "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-export-default-from": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.18.10.tgz",
+ "integrity": "sha512-5H2N3R2aQFxkV4PIBUR/i7PUSwgTZjouJKzI8eKswfIjT0PhvzkPn0t0wIS5zn6maQuvtT0t1oHtMUz61LOuow==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-default-from": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-export-namespace-from": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz",
+ "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-json-strings": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz",
+ "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-logical-assignment-operators": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz",
+ "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz",
+ "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-numeric-separator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz",
+ "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz",
+ "integrity": "sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.18.8",
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-transform-parameters": "^7.18.8"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-optional-catch-binding": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz",
+ "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-methods": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
+ "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz",
+ "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-unicode-property-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
+ "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-decorators": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz",
+ "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-dynamic-import": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+ "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-export-default-from": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.18.6.tgz",
+ "integrity": "sha512-Kr//z3ujSVNx6E9z9ih5xXXMqK07VVTuqPmqGe6Mss/zW5XPeLZeSDZoP9ab/hT4wPKqAgjl2PnhPrcpk8Seew==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-export-namespace-from": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+ "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.3"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz",
+ "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+ "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz",
+ "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz",
+ "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz",
+ "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-remap-async-to-generator": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz",
+ "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz",
+ "integrity": "sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz",
+ "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-compilation-targets": "^7.19.0",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz",
+ "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.18.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz",
+ "integrity": "sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz",
+ "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz",
+ "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz",
+ "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz",
+ "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz",
+ "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-function-name": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz",
+ "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz",
+ "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz",
+ "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz",
+ "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz",
+ "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz",
+ "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz",
+ "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz",
+ "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz",
+ "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz",
+ "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz",
+ "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-display-name": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz",
+ "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz",
+ "integrity": "sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-jsx": "^7.18.6",
+ "@babel/types": "^7.19.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz",
+ "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-pure-annotations": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz",
+ "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
+ "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "regenerator-transform": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-reserved-words": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz",
+ "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz",
+ "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz",
+ "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-sticky-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz",
+ "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz",
+ "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz",
+ "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typescript": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz",
+ "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-typescript": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz",
+ "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz",
+ "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz",
+ "integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-async-generator-functions": "^7.19.1",
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
+ "@babel/plugin-proposal-class-static-block": "^7.18.6",
+ "@babel/plugin-proposal-dynamic-import": "^7.18.6",
+ "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
+ "@babel/plugin-proposal-json-strings": "^7.18.6",
+ "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+ "@babel/plugin-proposal-numeric-separator": "^7.18.6",
+ "@babel/plugin-proposal-object-rest-spread": "^7.18.9",
+ "@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-private-methods": "^7.18.6",
+ "@babel/plugin-proposal-private-property-in-object": "^7.18.6",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.18.6",
+ "@babel/plugin-syntax-async-generators": "^7.8.4",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+ "@babel/plugin-syntax-import-assertions": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+ "@babel/plugin-syntax-top-level-await": "^7.14.5",
+ "@babel/plugin-transform-arrow-functions": "^7.18.6",
+ "@babel/plugin-transform-async-to-generator": "^7.18.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.18.6",
+ "@babel/plugin-transform-block-scoping": "^7.18.9",
+ "@babel/plugin-transform-classes": "^7.19.0",
+ "@babel/plugin-transform-computed-properties": "^7.18.9",
+ "@babel/plugin-transform-destructuring": "^7.18.13",
+ "@babel/plugin-transform-dotall-regex": "^7.18.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.18.9",
+ "@babel/plugin-transform-exponentiation-operator": "^7.18.6",
+ "@babel/plugin-transform-for-of": "^7.18.8",
+ "@babel/plugin-transform-function-name": "^7.18.9",
+ "@babel/plugin-transform-literals": "^7.18.9",
+ "@babel/plugin-transform-member-expression-literals": "^7.18.6",
+ "@babel/plugin-transform-modules-amd": "^7.18.6",
+ "@babel/plugin-transform-modules-commonjs": "^7.18.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.19.0",
+ "@babel/plugin-transform-modules-umd": "^7.18.6",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1",
+ "@babel/plugin-transform-new-target": "^7.18.6",
+ "@babel/plugin-transform-object-super": "^7.18.6",
+ "@babel/plugin-transform-parameters": "^7.18.8",
+ "@babel/plugin-transform-property-literals": "^7.18.6",
+ "@babel/plugin-transform-regenerator": "^7.18.6",
+ "@babel/plugin-transform-reserved-words": "^7.18.6",
+ "@babel/plugin-transform-shorthand-properties": "^7.18.6",
+ "@babel/plugin-transform-spread": "^7.19.0",
+ "@babel/plugin-transform-sticky-regex": "^7.18.6",
+ "@babel/plugin-transform-template-literals": "^7.18.9",
+ "@babel/plugin-transform-typeof-symbol": "^7.18.9",
+ "@babel/plugin-transform-unicode-escapes": "^7.18.10",
+ "@babel/plugin-transform-unicode-regex": "^7.18.6",
+ "@babel/preset-modules": "^0.1.5",
+ "@babel/types": "^7.19.3",
+ "babel-plugin-polyfill-corejs2": "^0.3.3",
+ "babel-plugin-polyfill-corejs3": "^0.6.0",
+ "babel-plugin-polyfill-regenerator": "^0.4.1",
+ "core-js-compat": "^3.25.1",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz",
+ "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
+ "@babel/plugin-transform-dotall-regex": "^7.4.4",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-react": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz",
+ "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-react-display-name": "^7.18.6",
+ "@babel/plugin-transform-react-jsx": "^7.18.6",
+ "@babel/plugin-transform-react-jsx-development": "^7.18.6",
+ "@babel/plugin-transform-react-pure-annotations": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
+ "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-typescript": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/register": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.18.9.tgz",
+ "integrity": "sha512-ZlbnXDcNYHMR25ITwwNKT88JiaukkdVj/nG7r3wnuXkOTHc60Uy05PwMCPre0hSkY68E6zK3xz+vUJSP2jWmcw==",
+ "dev": true,
+ "dependencies": {
+ "clone-deep": "^4.0.1",
+ "find-cache-dir": "^2.0.0",
+ "make-dir": "^2.1.0",
+ "pirates": "^4.0.5",
+ "source-map-support": "^0.5.16"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
+ "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
+ "dev": true,
+ "dependencies": {
+ "regenerator-runtime": "^0.13.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
+ "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/parser": "^7.18.10",
+ "@babel/types": "^7.18.10"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz",
+ "integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/parser": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz",
+ "integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.18.10",
+ "@babel/helper-validator-identifier": "^7.19.1",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@cnakazawa/watch": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
+ "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==",
+ "dev": true,
+ "dependencies": {
+ "exec-sh": "^0.3.2",
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "watch": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.1.95"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@fluent/bundle": {
+ "version": "0.17.1",
+ "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz",
+ "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@fluent/dom": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@fluent/dom/-/dom-0.8.1.tgz",
+ "integrity": "sha512-wlQ3vHgioDL8dC0wcZ9AyCSpOgor0OREKXJMvvnx6bzk/PT2SZNA5frslmSdbEaiBQIVy2MhVvAIDtbKbdoVCg==",
+ "dev": true,
+ "dependencies": {
+ "cached-iterable": "^0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "dev": true
+ },
+ "node_modules/@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/transform": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz",
+ "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.1.0",
+ "@jest/types": "^26.6.2",
+ "babel-plugin-istanbul": "^6.0.0",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^1.4.0",
+ "fast-json-stable-stringify": "^2.0.0",
+ "graceful-fs": "^4.2.4",
+ "jest-haste-map": "^26.6.2",
+ "jest-regex-util": "^26.0.0",
+ "jest-util": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "pirates": "^4.0.1",
+ "slash": "^3.0.0",
+ "source-map": "^0.6.1",
+ "write-file-atomic": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@jest/transform/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/transform/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/types": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
+ "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^15.0.0",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/@jest/types/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@jest/types/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@jest/types/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@jest/types/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@jest/types/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/types/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+ "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.0",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+ "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+ "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ },
+ "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.14",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz",
+ "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.1.tgz",
+ "integrity": "sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==",
+ "dev": true
+ },
+ "node_modules/@mdx-js/mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz",
+ "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "7.12.9",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@babel/plugin-syntax-object-rest-spread": "7.8.3",
+ "@mdx-js/util": "1.6.22",
+ "babel-plugin-apply-mdx-type-prop": "1.6.22",
+ "babel-plugin-extract-import-names": "1.6.22",
+ "camelcase-css": "2.0.1",
+ "detab": "2.0.4",
+ "hast-util-raw": "6.0.1",
+ "lodash.uniq": "4.5.0",
+ "mdast-util-to-hast": "10.0.1",
+ "remark-footnotes": "2.0.0",
+ "remark-mdx": "1.6.22",
+ "remark-parse": "8.0.3",
+ "remark-squeeze-paragraphs": "4.0.0",
+ "style-to-object": "0.3.0",
+ "unified": "9.2.0",
+ "unist-builder": "2.0.3",
+ "unist-util-visit": "2.0.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@mdx-js/mdx/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@mdx-js/util": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz",
+ "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/@mrmlnc/readdir-enhanced": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
+ "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==",
+ "dev": true,
+ "dependencies": {
+ "call-me-maybe": "^1.0.1",
+ "glob-to-regexp": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@mrmlnc/readdir-enhanced/node_modules/glob-to-regexp": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz",
+ "integrity": "sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==",
+ "dev": true
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "dev": true,
+ "dependencies": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "dev": true,
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@storybook/addon-actions": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.5.12.tgz",
+ "integrity": "sha512-yEbyKjBsSRUr61SlS+SOTqQwdumO8Wa3GoHO3AfmvoKfzdGrM7w8G5Zs9Iev16khWg/7bQvoH3KZsg/hQuKnNg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "polished": "^4.2.2",
+ "prop-types": "^15.7.2",
+ "react-inspector": "^5.1.0",
+ "regenerator-runtime": "^0.13.7",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "uuid-browser": "^3.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-actions/node_modules/react-inspector": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz",
+ "integrity": "sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "is-dom": "^1.0.0",
+ "prop-types": "^15.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.4 || ^17.0.0"
+ }
+ },
+ "node_modules/@storybook/addon-backgrounds": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-6.5.12.tgz",
+ "integrity": "sha512-S0QThY1jnU7Q+HY+g9JgpAJszzNmNkigZ4+X/4qlUXE0WYYn9i2YG5H6me1+57QmIXYddcWWqqgF9HUXl667NA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-controls": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.5.12.tgz",
+ "integrity": "sha512-UoaamkGgAQXplr0kixkPhROdzkY+ZJQpG7VFDU6kmZsIgPRNfX/QoJFR5vV6TpDArBIjWaUUqWII+GHgPRzLgQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "lodash": "^4.17.21",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-docs": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-6.5.12.tgz",
+ "integrity": "sha512-T+QTkmF7QlMVfXHXEberP8CYti/XMTo9oi6VEbZLx+a2N3qY4GZl7X2g26Sf5V4Za+xnapYKBMEIiJ5SvH9weQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@jest/transform": "^26.6.2",
+ "@mdx-js/react": "^1.6.22",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/postinstall": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/source-loader": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "babel-loader": "^8.0.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7",
+ "remark-external-links": "^8.0.0",
+ "remark-slug": "^6.0.0",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "@storybook/mdx2-csf": "^0.0.3",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/mdx2-csf": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-docs/node_modules/@mdx-js/react": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz",
+ "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0"
+ }
+ },
+ "node_modules/@storybook/addon-essentials": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-6.5.12.tgz",
+ "integrity": "sha512-4AAV0/mQPSk3V0Pie1NIqqgBgScUc0VtBEXDm8BgPeuDNVhPEupnaZgVt+I3GkzzPPo6JjdCsp2L11f3bBSEjw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addon-actions": "6.5.12",
+ "@storybook/addon-backgrounds": "6.5.12",
+ "@storybook/addon-controls": "6.5.12",
+ "@storybook/addon-docs": "6.5.12",
+ "@storybook/addon-measure": "6.5.12",
+ "@storybook/addon-outline": "6.5.12",
+ "@storybook/addon-toolbars": "6.5.12",
+ "@storybook/addon-viewport": "6.5.12",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.9.6"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/angular": {
+ "optional": true
+ },
+ "@storybook/builder-manager4": {
+ "optional": true
+ },
+ "@storybook/builder-manager5": {
+ "optional": true
+ },
+ "@storybook/builder-webpack4": {
+ "optional": true
+ },
+ "@storybook/builder-webpack5": {
+ "optional": true
+ },
+ "@storybook/html": {
+ "optional": true
+ },
+ "@storybook/vue": {
+ "optional": true
+ },
+ "@storybook/vue3": {
+ "optional": true
+ },
+ "@storybook/web-components": {
+ "optional": true
+ },
+ "lit": {
+ "optional": true
+ },
+ "lit-html": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "sveltedoc-parser": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-links": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.5.12.tgz",
+ "integrity": "sha512-Dyt922J5nTBwM/9KtuuDIt3sX8xdTkKh+aXSoOX6OzT04Xwm5NumFOvuQ2YA00EM+3Ihn7Ayc3urvxnHTixmKg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "prop-types": "^15.7.2",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-measure": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-6.5.12.tgz",
+ "integrity": "sha512-zmolO6+VG4ov2620G7f1myqLQLztfU+ykN+U5y52GXMFsCOyB7fMoVWIMrZwsNlinDu+CnUvelXHUNbqqnjPRg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-outline": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-6.5.12.tgz",
+ "integrity": "sha512-jXwLz2rF/CZt6Cgy+QUTa+pNW0IevSONYwS3D533E9z5h0T5ZKJbbxG5jxM+oC+FpZ/nFk5mEmUaYNkxgIVdpw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-toolbars": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-6.5.12.tgz",
+ "integrity": "sha512-+QjoEHkekz4wTy8zqxYdV9ijDJ5YcjDc/qdnV8wx22zkoVU93FQlo0CHHVjpyvc3ilQliZbdQDJx62BcHXw30Q==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addon-viewport": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-6.5.12.tgz",
+ "integrity": "sha512-eQ1UrmbiMiPmWe+fdMWIc0F6brh/S2z4ADfwFz0tTd+vOLWRZp1xw8JYQ9P2ZasE+PM3WFOVT9jvNjZj/cHnfw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "prop-types": "^15.7.2",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/addons": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.12.tgz",
+ "integrity": "sha512-y3cgxZq41YGnuIlBJEuJjSFdMsm8wnvlNOGUP9Q+Er2dgfx8rJz4Q22o4hPjpvpaj4XdBtxCJXI2NeFpN59+Cw==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.12.tgz",
+ "integrity": "sha512-DuUZmMlQxkFNU9Vgkp9aNfCkAongU76VVmygvCuSpMVDI9HQ2lG0ydL+ppL4XKoSMCCoXTY6+rg4hJANnH+1AQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.5.12.tgz",
+ "integrity": "sha512-TsthT5jm9ZxQPNOZJbF5AV24me3i+jjYD7gbdKdSHrOVn1r3ydX4Z8aD6+BjLCtTn3T+e8NMvUkL4dInEo1x6g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "autoprefixer": "^9.8.6",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^4.1.6",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "global": "^4.4.0",
+ "html-webpack-plugin": "^4.0.0",
+ "pnp-webpack-plugin": "1.6.4",
+ "postcss": "^7.0.36",
+ "postcss-flexbugs-fixes": "^4.2.1",
+ "postcss-loader": "^4.2.0",
+ "raw-loader": "^4.0.2",
+ "stable": "^0.1.8",
+ "style-loader": "^1.3.0",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-filter-warnings-plugin": "^1.2.1",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 4.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/css-loader/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fork-ts-checker-webpack-plugin": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz",
+ "integrity": "sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.5.5",
+ "chalk": "^2.4.1",
+ "micromatch": "^3.1.10",
+ "minimatch": "^3.0.4",
+ "semver": "^5.6.0",
+ "tapable": "^1.0.0",
+ "worker-rpc": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6.11.5",
+ "yarn": ">=1.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "bin": {
+ "html-minifier-terser": "cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-minifier-terser/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "dependencies": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/html-webpack-plugin/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/p-locate/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/pkg-dir/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "dependencies": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "dependencies": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-filter-warnings-plugin": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz",
+ "integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4.3 < 5.0.0 || >= 5.10"
+ },
+ "peerDependencies": {
+ "webpack": "^2.0.0 || ^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^3.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/webpack/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/builder-webpack4/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/builder-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-6.5.12.tgz",
+ "integrity": "sha512-jK5jWxhSbMAM/onPB6WN7xVqwZnAmzJljOG24InO/YIjW8pQof7MeAXCYBM4rYM+BbK61gkZ/RKxwlkqXBWv+Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-named-exports-order": "^0.0.2",
+ "browser-assert": "^1.2.1",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "html-webpack-plugin": "^5.0.0",
+ "path-browserify": "^1.0.1",
+ "process": "^0.11.10",
+ "stable": "^0.1.8",
+ "style-loader": "^2.0.0",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/builder-webpack5/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/channel-postmessage": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.5.12.tgz",
+ "integrity": "sha512-SL/tJBLOdDlbUAAxhiZWOEYd5HI4y8rN50r6jeed5nD8PlocZjxJ6mO0IxnePqIL9Yu3nSrQRHrtp8AJvPX0Yg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "qs": "^6.10.0",
+ "telejson": "^6.0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/channel-websocket": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-websocket/-/channel-websocket-6.5.12.tgz",
+ "integrity": "sha512-0t5dLselHVKTRYaphxx1dRh4pmOFCfR7h8oNJlOvJ29Qy5eNyVujDG9nhwWbqU6IKayuP4nZrAbe9Req9YZYlQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "telejson": "^6.0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/channels": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.12.tgz",
+ "integrity": "sha512-X5XaKbe4b7LXJ4sUakBo00x6pXnW78JkOonHoaKoWsccHLlEzwfBZpVVekhVZnqtCoLT23dB8wjKgA71RYWoiw==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/client-api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.5.12.tgz",
+ "integrity": "sha512-+JiRSgiU829KPc25nG/k0+Ao2nUelHUe8Y/9cRoKWbCAGzi4xd0JLhHAOr9Oi2szWx/OI1L08lxVv1+WTveAeA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/client-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.12.tgz",
+ "integrity": "sha512-IrkMr5KZcudX935/C2balFbxLHhkvQnJ78rbVThHDVckQ7l3oIXTh66IMzldeOabVFDZEMiW8AWuGEYof+JtLw==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.12.tgz",
+ "integrity": "sha512-NAAGl5PDXaHdVLd6hA+ttmLwH3zAVGXeUmEubzKZ9bJzb+duhFKxDa9blM4YEkI+palumvgAMm0UgS7ou680Ig==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/core": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core/-/core-6.5.12.tgz",
+ "integrity": "sha512-+o3psAVWL+5LSwyJmEbvhgxKO1Et5uOX8ujNVt/f1fgwJBIf6BypxyPKu9YGQDRzcRssESQQZWNrZCCAZlFeuQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-server": "6.5.12"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "webpack": "*"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/builder-webpack5": {
+ "optional": true
+ },
+ "@storybook/manager-webpack5": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-client": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.5.12.tgz",
+ "integrity": "sha512-jyAd0ud6zO+flpLv0lEHbbt1Bv9Ms225M6WTQLrfe7kN/7j1pVKZEoeVCLZwkJUtSKcNiWQxZbS15h31pcYwqg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channel-websocket": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "airbnb-js-shims": "^2.2.1",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "webpack": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-common": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.5.12.tgz",
+ "integrity": "sha512-gG20+eYdIhwQNu6Xs805FLrOCWtkoc8Rt8gJiRt8yXzZh9EZkU4xgCRoCxrrJ03ys/gTiCFbBOfRi749uM3z4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-proposal-class-properties": "^7.12.1",
+ "@babel/plugin-proposal-decorators": "^7.12.12",
+ "@babel/plugin-proposal-export-default-from": "^7.12.1",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
+ "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
+ "@babel/plugin-proposal-optional-chaining": "^7.12.7",
+ "@babel/plugin-proposal-private-methods": "^7.12.1",
+ "@babel/plugin-proposal-private-property-in-object": "^7.12.1",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-transform-arrow-functions": "^7.12.1",
+ "@babel/plugin-transform-block-scoping": "^7.12.12",
+ "@babel/plugin-transform-classes": "^7.12.1",
+ "@babel/plugin-transform-destructuring": "^7.12.1",
+ "@babel/plugin-transform-for-of": "^7.12.1",
+ "@babel/plugin-transform-parameters": "^7.12.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.12.1",
+ "@babel/plugin-transform-spread": "^7.12.1",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/preset-react": "^7.12.10",
+ "@babel/preset-typescript": "^7.12.7",
+ "@babel/register": "^7.12.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/pretty-hrtime": "^1.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-macros": "^3.0.1",
+ "babel-plugin-polyfill-corejs3": "^0.1.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "express": "^4.17.1",
+ "file-system-cache": "^1.0.5",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "fs-extra": "^9.0.1",
+ "glob": "^7.1.6",
+ "handlebars": "^4.7.7",
+ "interpret": "^2.2.0",
+ "json5": "^2.1.3",
+ "lazy-universal-dotenv": "^3.0.1",
+ "picomatch": "^2.3.0",
+ "pkg-dir": "^5.0.0",
+ "pretty-hrtime": "^1.0.3",
+ "resolve-from": "^5.0.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz",
+ "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.13.0",
+ "@babel/helper-module-imports": "^7.12.13",
+ "@babel/helper-plugin-utils": "^7.13.0",
+ "@babel/traverse": "^7.13.0",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0-0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz",
+ "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.1.5",
+ "core-js-compat": "^3.8.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-common/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/loader-utils/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/core-common/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-events": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.12.tgz",
+ "integrity": "sha512-0AMyMM19R/lHsYRfWqM8zZTXthasTAK2ExkSRzYi2GkIaVMxRKtM33YRwxKIpJ6KmIKIs8Ru3QCXu1mfCmGzNg==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/core-server": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.5.12.tgz",
+ "integrity": "sha512-q1b/XKwoLUcCoCQ+8ndPD5THkEwXZYJ9ROv16i2VGUjjjAuSqpEYBq5GMGQUgxlWp1bkxtdGL2Jz+6pZfvldzA==",
+ "dev": true,
+ "dependencies": {
+ "@discoveryjs/json-ext": "^0.5.3",
+ "@storybook/builder-webpack4": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/csf-tools": "6.5.12",
+ "@storybook/manager-webpack4": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/telemetry": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/node-fetch": "^2.5.7",
+ "@types/pretty-hrtime": "^1.0.0",
+ "@types/webpack": "^4.41.26",
+ "better-opn": "^2.1.1",
+ "boxen": "^5.1.2",
+ "chalk": "^4.1.0",
+ "cli-table3": "^0.6.1",
+ "commander": "^6.2.1",
+ "compression": "^1.7.4",
+ "core-js": "^3.8.2",
+ "cpy": "^8.1.2",
+ "detect-port": "^1.3.0",
+ "express": "^4.17.1",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "globby": "^11.0.2",
+ "ip": "^2.0.0",
+ "lodash": "^4.17.21",
+ "node-fetch": "^2.6.7",
+ "open": "^8.4.0",
+ "pretty-hrtime": "^1.0.3",
+ "prompts": "^2.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "serve-favicon": "^2.5.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "watchpack": "^2.2.0",
+ "webpack": "4",
+ "ws": "^8.2.3",
+ "x-default-browser": "^0.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/builder-webpack5": {
+ "optional": true
+ },
+ "@storybook/manager-webpack5": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/core-server/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/webpack/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/core-server/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/csf": {
+ "version": "0.0.2--canary.4566f4d.1",
+ "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz",
+ "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.15"
+ }
+ },
+ "node_modules/@storybook/csf-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.5.12.tgz",
+ "integrity": "sha512-BPhnB1xJtBVOzXuCURzQRdXcstE27ht4qoTgQkbwUTy4MEtUZ/f1AnHSYRdzrgukXdUFWseNIK4RkNdJpfOfNQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/traverse": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "core-js": "^3.8.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "@storybook/mdx2-csf": "^0.0.3"
+ },
+ "peerDependenciesMeta": {
+ "@storybook/mdx2-csf": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/docs-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/docs-tools/-/docs-tools-6.5.12.tgz",
+ "integrity": "sha512-8brf8W89KVk95flVqW0sYEqkL+FBwb5W9CnwI+Ggd6r2cqXe9jyg+0vDZFdYp6kYNQKrPr4fbXGrGVXQG18/QQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "core-js": "^3.8.2",
+ "doctrine": "^3.0.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.5.12.tgz",
+ "integrity": "sha512-LH3e6qfvq2znEdxe2kaWtmdDPTnvSkufzoC9iwOgNvo3YrTGrYNyUTDegvW293TOTVfUn7j6TBcsOxIgRnt28g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "express": "^4.17.1",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^4.0.0",
+ "node-fetch": "^2.6.7",
+ "pnp-webpack-plugin": "1.6.4",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^1.3.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 4.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/css-loader/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/enhanced-resolve/node_modules/memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "bin": {
+ "html-minifier-terser": "cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-minifier-terser/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "dependencies": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/html-webpack-plugin/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.3.0 <5.0.0 || >=5.10"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/p-locate/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/pkg-dir/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "dependencies": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "dependencies": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0"
+ },
+ "optionalDependencies": {
+ "chokidar": "^3.4.1",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=6.11.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ },
+ "webpack-command": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "dependencies": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "dependencies": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^3.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/webpack/node_modules/terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "dependencies": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ },
+ "engines": {
+ "node": ">= 6.9.0"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack4/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack5/-/manager-webpack5-6.5.12.tgz",
+ "integrity": "sha512-F+KgoINhfo1ArbirCc9L+EyADYD8Z4t0LyZYDVcBiZ8DlRIMIoUSye6tDsnyEm+OPloLVAcGwRMYgFhuHB70Lg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "express": "^4.17.1",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^5.0.0",
+ "node-fetch": "^2.6.7",
+ "process": "^0.11.10",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^2.0.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/manager-webpack5/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/mdx1-csf": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@storybook/mdx1-csf/-/mdx1-csf-0.0.1.tgz",
+ "integrity": "sha512-4biZIWWzoWlCarMZmTpqcJNgo/RBesYZwGFbQeXiGYsswuvfWARZnW9RE9aUEMZ4XPn7B1N3EKkWcdcWe/K2tg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@mdx-js/mdx": "^1.6.22",
+ "@types/lodash": "^4.14.167",
+ "js-string-escape": "^1.0.1",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "node_modules/@storybook/node-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.5.12.tgz",
+ "integrity": "sha512-jdLtT3mX5GQKa+0LuX0q0sprKxtCGf6HdXlKZGD5FEuz4MgJUGaaiN0Hgi+U7Z4tVNOtSoIbYBYXHqfUgJrVZw==",
+ "dev": true,
+ "dependencies": {
+ "@types/npmlog": "^4.1.2",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "npmlog": "^5.0.1",
+ "pretty-hrtime": "^1.0.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/node-logger/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/node-logger/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/postinstall": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/postinstall/-/postinstall-6.5.12.tgz",
+ "integrity": "sha512-6K73f9c2UO+w4Wtyo2BxEpEsnhPvMgqHSaJ9Yt6Tc90LaDGUbcVgy6PNibsRyuJ/KQ543WeiRO5rSZfm2uJU9A==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.8.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/preview-web": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/preview-web/-/preview-web-6.5.12.tgz",
+ "integrity": "sha512-Q5mduCJsY9zhmlsrhHvtOBA3Jt2n45bhfVkiUEqtj8fDit45/GW+eLoffv8GaVTGjV96/Y1JFwDZUwU6mEfgGQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/router": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.12.tgz",
+ "integrity": "sha512-xHubde9YnBbpkDY5+zGO4Pr6VPxP8H9J2v4OTF3H82uaxCIKR0PKG0utS9pFKIsEiP3aM62Hb9qB8nU+v1nj3w==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/semver": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz",
+ "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==",
+ "dev": true,
+ "dependencies": {
+ "core-js": "^3.6.5",
+ "find-up": "^4.1.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/semver/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/source-loader": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.5.12.tgz",
+ "integrity": "sha512-4iuILFsKNV70sEyjzIkOqgzgQx7CJ8kTEFz590vkmWXQNKz7YQzjgISIwL7GBw/myJgeb04bl5psVgY0cbG5vg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "estraverse": "^5.2.0",
+ "global": "^4.4.0",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/store": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/store/-/store-6.5.12.tgz",
+ "integrity": "sha512-SMQOr0XvV0mhTuqj3XOwGGc4kTPVjh3xqrG1fqkj9RGs+2jRdmO6mnwzda5gPwUmWNTorZ7FxZ1iEoyfYNtuiQ==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "slash": "^3.0.0",
+ "stable": "^0.1.8",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/telemetry": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-6.5.12.tgz",
+ "integrity": "sha512-mCHxx7NmQ3n7gx0nmblNlZE5ZgrjQm6B08mYeWg6Y7r4GZnqS6wZbvAwVhZZ3Gg/9fdqaBApHsdAXp0d5BrlxA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "detect-package-manager": "^2.0.1",
+ "fetch-retry": "^5.0.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "isomorphic-unfetch": "^3.1.0",
+ "nanoid": "^3.3.1",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/@storybook/telemetry/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/telemetry/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@storybook/theming": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.12.tgz",
+ "integrity": "sha512-uWOo84qMQ2R6c1C0faZ4Q0nY01uNaX7nXoJKieoiJ6ZqY9PSYxJl1kZLi3uPYnrxLZjzjVyXX8MgdxzbppYItA==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/ui": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/ui/-/ui-6.5.12.tgz",
+ "integrity": "sha512-P7+ARI5NvaEYkrbIciT/UMgy3kxMt4WCtHMXss2T01UMCIWh1Ws4BJaDNqtQSpKuwjjS4eqZL3aQWhlUpYAUEg==",
+ "dev": true,
+ "dependencies": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@storybook/web-components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-6.5.12.tgz",
+ "integrity": "sha512-SkaLdaCYNXiKKtXoDcZ7sDvONkIB/NNfe39Kkijm/sotymi+7iDzbywwyAZY6tFxSy9DUkVZWQgexpmFpogWkw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/preset-env": "^7.12.11",
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@types/node": "^14.14.20 || ^16.0.0",
+ "@types/webpack-env": "^1.16.0",
+ "babel-plugin-bundled-import-meta": "^0.3.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "react": "16.14.0",
+ "react-dom": "16.14.0",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "bin": {
+ "build-storybook": "bin/build.js",
+ "start-storybook": "bin/index.js",
+ "storybook-server": "bin/index.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "lit-html": "^1.4.1 || ^2.0.0"
+ }
+ },
+ "node_modules/@storybook/web-components/node_modules/@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "node_modules/@storybook/web-components/node_modules/react": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
+ "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@storybook/web-components/node_modules/react-dom": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
+ "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2",
+ "scheduler": "^0.19.1"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0"
+ }
+ },
+ "node_modules/@storybook/web-components/node_modules/scheduler": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
+ "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1"
+ }
+ },
+ "node_modules/@types/eslint": {
+ "version": "8.4.6",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz",
+ "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "*",
+ "@types/json-schema": "*"
+ }
+ },
+ "node_modules/@types/eslint-scope": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz",
+ "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==",
+ "dev": true,
+ "dependencies": {
+ "@types/eslint": "*",
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "0.0.51",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
+ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
+ "dev": true
+ },
+ "node_modules/@types/glob": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.0.0.tgz",
+ "integrity": "sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==",
+ "dev": true,
+ "dependencies": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/graceful-fs": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
+ "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz",
+ "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==",
+ "dev": true
+ },
+ "node_modules/@types/is-function": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.1.tgz",
+ "integrity": "sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==",
+ "dev": true
+ },
+ "node_modules/@types/istanbul-lib-coverage": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+ "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+ "dev": true
+ },
+ "node_modules/@types/istanbul-lib-report": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+ "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+ "dev": true,
+ "dependencies": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "node_modules/@types/istanbul-reports": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
+ "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
+ "dev": true,
+ "dependencies": {
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
+ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
+ "dev": true
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.14.186",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz",
+ "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==",
+ "dev": true
+ },
+ "node_modules/@types/mdast": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
+ "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/minimatch": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
+ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "18.8.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.1.tgz",
+ "integrity": "sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ==",
+ "dev": true
+ },
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz",
+ "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^3.0.0"
+ }
+ },
+ "node_modules/@types/normalize-package-data": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
+ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
+ "dev": true
+ },
+ "node_modules/@types/npmlog": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-4.1.4.tgz",
+ "integrity": "sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==",
+ "dev": true
+ },
+ "node_modules/@types/parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
+ "dev": true
+ },
+ "node_modules/@types/parse5": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz",
+ "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==",
+ "dev": true
+ },
+ "node_modules/@types/pretty-hrtime": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz",
+ "integrity": "sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==",
+ "dev": true
+ },
+ "node_modules/@types/qs": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
+ "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
+ "dev": true
+ },
+ "node_modules/@types/source-list-map": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
+ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==",
+ "dev": true
+ },
+ "node_modules/@types/tapable": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
+ "integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==",
+ "dev": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
+ "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==",
+ "dev": true
+ },
+ "node_modules/@types/uglify-js": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.0.tgz",
+ "integrity": "sha512-3HO6rm0y+/cqvOyA8xcYLweF0TKXlAxmQASjbOi49Co51A1N4nR4bEwBgRoD9kNM+rqFGArjKr654SLp2CoGmQ==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
+ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
+ "dev": true
+ },
+ "node_modules/@types/webpack": {
+ "version": "4.41.32",
+ "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz",
+ "integrity": "sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/tapable": "^1",
+ "@types/uglify-js": "*",
+ "@types/webpack-sources": "*",
+ "anymatch": "^3.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/@types/webpack-env": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.0.tgz",
+ "integrity": "sha512-56/MAlX5WMsPVbOg7tAxnYvNYMMWr/QJiIp6BxVSW3JJXUVzzOn64qW8TzQyMSqSUFM2+PVI4aUHcHOzIz/1tg==",
+ "dev": true
+ },
+ "node_modules/@types/webpack-sources": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz",
+ "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/source-list-map": "*",
+ "source-map": "^0.7.3"
+ }
+ },
+ "node_modules/@types/webpack-sources/node_modules/source-map": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
+ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@types/yargs": {
+ "version": "15.0.14",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz",
+ "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "node_modules/@types/yargs-parser": {
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
+ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/ast": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
+ "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-numbers": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz",
+ "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz",
+ "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz",
+ "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-code-frame": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz",
+ "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-code-frame/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-code-frame/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-code-frame/node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-fsm": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz",
+ "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-module-context": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz",
+ "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-module-context/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-module-context/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-numbers": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz",
+ "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/floating-point-hex-parser": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz",
+ "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz",
+ "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/ieee754": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz",
+ "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@webassemblyjs/leb128": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz",
+ "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/utf8": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz",
+ "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz",
+ "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/helper-wasm-section": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-opt": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "@webassemblyjs/wast-printer": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz",
+ "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
+ "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz",
+ "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz",
+ "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/floating-point-hex-parser": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-code-frame": "1.9.0",
+ "@webassemblyjs/helper-fsm": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz",
+ "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wast-parser/node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz",
+ "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@xtuc/ieee754": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+ "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+ "dev": true
+ },
+ "node_modules/@xtuc/long": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+ "dev": true
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
+ "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-import-assertions": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
+ "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^8"
+ }
+ },
+ "node_modules/address": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/address/-/address-1.2.1.tgz",
+ "integrity": "sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/airbnb-js-shims": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz",
+ "integrity": "sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==",
+ "dev": true,
+ "dependencies": {
+ "array-includes": "^3.0.3",
+ "array.prototype.flat": "^1.2.1",
+ "array.prototype.flatmap": "^1.2.1",
+ "es5-shim": "^4.5.13",
+ "es6-shim": "^0.35.5",
+ "function.prototype.name": "^1.1.0",
+ "globalthis": "^1.0.0",
+ "object.entries": "^1.1.0",
+ "object.fromentries": "^2.0.0 || ^1.0.0",
+ "object.getownpropertydescriptors": "^2.0.3",
+ "object.values": "^1.1.0",
+ "promise.allsettled": "^1.0.0",
+ "promise.prototype.finally": "^3.1.0",
+ "string.prototype.matchall": "^4.0.0 || ^3.0.1",
+ "string.prototype.padend": "^3.0.0",
+ "string.prototype.padstart": "^3.0.0",
+ "symbol.prototype.description": "^1.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-errors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
+ "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
+ "dev": true,
+ "peerDependencies": {
+ "ajv": ">=5.0.0"
+ }
+ },
+ "node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.1.0"
+ }
+ },
+ "node_modules/ansi-colors": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz",
+ "integrity": "sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8.0"
+ ],
+ "bin": {
+ "ansi-html": "bin/ansi-html"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ansi-to-html": {
+ "version": "0.6.15",
+ "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.15.tgz",
+ "integrity": "sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==",
+ "dev": true,
+ "dependencies": {
+ "entities": "^2.0.0"
+ },
+ "bin": {
+ "ansi-to-html": "bin/ansi-to-html"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/app-root-dir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz",
+ "integrity": "sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==",
+ "dev": true
+ },
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "dev": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "dev": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/arr-diff": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
+ "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/arr-flatten": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+ "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array-find-index": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
+ "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz",
+ "integrity": "sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5",
+ "get-intrinsic": "^1.1.1",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/array-uniq": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
+ "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array-unique": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
+ "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz",
+ "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz",
+ "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.map": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.4.tgz",
+ "integrity": "sha512-Qds9QnX7A0qISY7JT5WuJO0NJPE9CMlC6JzHQfhpqAAQQzufVRoeH7EzUY5GcPTx72voG8LV/5eo+b8Qi8hmhA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.reduce": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.4.tgz",
+ "integrity": "sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arrify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
+ "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
+ "node_modules/asn1.js/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/assert": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz",
+ "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==",
+ "dev": true,
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "util": "0.10.3"
+ }
+ },
+ "node_modules/assert/node_modules/inherits": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
+ "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==",
+ "dev": true
+ },
+ "node_modules/assert/node_modules/util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "2.0.1"
+ }
+ },
+ "node_modules/assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/async-each": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
+ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "dev": true,
+ "bin": {
+ "atob": "bin/atob.js"
+ },
+ "engines": {
+ "node": ">= 4.5.0"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "9.8.8",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz",
+ "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
+ "dev": true,
+ "dependencies": {
+ "browserslist": "^4.12.0",
+ "caniuse-lite": "^1.0.30001109",
+ "normalize-range": "^0.1.2",
+ "num2fraction": "^1.2.2",
+ "picocolors": "^0.2.1",
+ "postcss": "^7.0.32",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ }
+ },
+ "node_modules/babel-loader": {
+ "version": "8.2.5",
+ "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz",
+ "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==",
+ "dev": true,
+ "dependencies": {
+ "find-cache-dir": "^3.3.1",
+ "loader-utils": "^2.0.0",
+ "make-dir": "^3.1.0",
+ "schema-utils": "^2.6.5"
+ },
+ "engines": {
+ "node": ">= 8.9"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "webpack": ">=2"
+ }
+ },
+ "node_modules/babel-loader/node_modules/find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+ }
+ },
+ "node_modules/babel-loader/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-loader/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-loader/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-loader/node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-apply-mdx-type-prop": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz",
+ "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@mdx-js/util": "1.6.22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.11.6"
+ }
+ },
+ "node_modules/babel-plugin-apply-mdx-type-prop/node_modules/@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "node_modules/babel-plugin-bundled-import-meta": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-bundled-import-meta/-/babel-plugin-bundled-import-meta-0.3.2.tgz",
+ "integrity": "sha512-RMXzsnWoFHDSUc1X/QiejEwQBtQ0Y68HQZ542JQ4voFa5Sgl5f/D4T7+EOocUeSbiT4XIDbrhfxbH5OmcV8Ibw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-syntax-import-meta": "^7.2.0",
+ "@babel/template": "^7.7.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.7.0"
+ }
+ },
+ "node_modules/babel-plugin-dynamic-import-node": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz",
+ "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==",
+ "dev": true,
+ "dependencies": {
+ "object.assign": "^4.1.0"
+ }
+ },
+ "node_modules/babel-plugin-extract-import-names": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz",
+ "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "7.10.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/babel-plugin-extract-import-names/node_modules/@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "node_modules/babel-plugin-istanbul": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@istanbuljs/load-nyc-config": "^1.0.0",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-instrument": "^5.0.4",
+ "test-exclude": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/babel-plugin-named-exports-order": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-named-exports-order/-/babel-plugin-named-exports-order-0.0.2.tgz",
+ "integrity": "sha512-OgOYHOLoRK+/mvXU9imKHlG6GkPLYrUCvFXG/CM93R/aNNO8pOOF4aS+S8CCHMDQoNSeiOYEZb/G6RwL95Jktw==",
+ "dev": true
+ },
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
+ "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.17.7",
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "semver": "^6.1.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz",
+ "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "core-js-compat": "^3.25.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz",
+ "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/bail": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
+ "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/base": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
+ "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==",
+ "dev": true,
+ "dependencies": {
+ "cache-base": "^1.0.1",
+ "class-utils": "^0.3.5",
+ "component-emitter": "^1.2.1",
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.1",
+ "mixin-deep": "^1.2.0",
+ "pascalcase": "^0.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/base/node_modules/define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/better-opn": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz",
+ "integrity": "sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==",
+ "dev": true,
+ "dependencies": {
+ "open": "^7.0.3"
+ },
+ "engines": {
+ "node": ">8.0.0"
+ }
+ },
+ "node_modules/better-opn/node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/big-integer": {
+ "version": "1.6.51",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "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.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "node_modules/bn.js": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
+ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==",
+ "dev": true
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
+ "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.4",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.10.3",
+ "raw-body": "2.5.1",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "node_modules/boxen": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
+ "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-align": "^3.0.0",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.1.0",
+ "cli-boxes": "^2.2.1",
+ "string-width": "^4.2.2",
+ "type-fest": "^0.20.2",
+ "widest-line": "^3.1.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/boxen/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/boxen/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/boxen/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bplist-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.1.1.tgz",
+ "integrity": "sha512-2AEM0FXy8ZxVLBuqX0hqt1gDwcnz2zygEkQ6zaD5Wko/sB9paUNwlpawrFtKeHUAQUOzjVy9AO4oeonqIHKA9Q==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "big-integer": "^1.6.7"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "dev": true
+ },
+ "node_modules/browser-assert": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz",
+ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==",
+ "dev": true
+ },
+ "node_modules/browserify-aes": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+ "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
+ "dev": true,
+ "dependencies": {
+ "buffer-xor": "^1.0.3",
+ "cipher-base": "^1.0.0",
+ "create-hash": "^1.1.0",
+ "evp_bytestokey": "^1.0.3",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/browserify-cipher": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
+ "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+ "dev": true,
+ "dependencies": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "node_modules/browserify-des": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
+ "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/browserify-rsa": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz",
+ "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^5.0.0",
+ "randombytes": "^2.0.1"
+ }
+ },
+ "node_modules/browserify-sign": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz",
+ "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^5.1.1",
+ "browserify-rsa": "^4.0.1",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.3",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.5",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ }
+ },
+ "node_modules/browserify-sign/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "dev": true,
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.21.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
+ "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ }
+ ],
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001400",
+ "electron-to-chromium": "^1.4.251",
+ "node-releases": "^2.0.6",
+ "update-browserslist-db": "^1.0.9"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "dependencies": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "4.9.2",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
+ "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
+ "dev": true,
+ "dependencies": {
+ "base64-js": "^1.0.2",
+ "ieee754": "^1.1.4",
+ "isarray": "^1.0.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "node_modules/buffer-xor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
+ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==",
+ "dev": true
+ },
+ "node_modules/buffer/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/builtin-status-codes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
+ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
+ "dev": true
+ },
+ "node_modules/bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/cacache/node_modules/p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "dev": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cache-base": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
+ "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==",
+ "dev": true,
+ "dependencies": {
+ "collection-visit": "^1.0.0",
+ "component-emitter": "^1.2.1",
+ "get-value": "^2.0.6",
+ "has-value": "^1.0.0",
+ "isobject": "^3.0.1",
+ "set-value": "^2.0.0",
+ "to-object-path": "^0.3.0",
+ "union-value": "^1.0.0",
+ "unset-value": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cached-iterable": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz",
+ "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.9.0"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+ "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-me-maybe": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
+ "integrity": "sha512-wCyFsDQkKPwwF8BDwOiWNx/9K45L/hvggQiDbve+viMNMQnWhrlYIuBk09offfwCRtCO9P6XwUttufzU11WCVw==",
+ "dev": true
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camel-case": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+ "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+ "dev": true,
+ "dependencies": {
+ "pascal-case": "^3.1.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/camelcase-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+ "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "camelcase": "^2.0.0",
+ "map-obj": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/camelcase-keys/node_modules/camelcase": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
+ "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001415",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001415.tgz",
+ "integrity": "sha512-ER+PfgCJUe8BqunLGWd/1EY4g8AzQcsDAVzdtMGKVtQEmKAwaFfU6vb7EAVIqTMYsqxBorYZi2+22Iouj/y7GQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ }
+ ]
+ },
+ "node_modules/capture-exit": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
+ "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==",
+ "dev": true,
+ "dependencies": {
+ "rsvp": "^4.8.4"
+ },
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/case-sensitive-paths-webpack-plugin": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz",
+ "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ccount": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz",
+ "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
+ "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
+ "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
+ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/chrome-trace-event": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
+ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/ci-info": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
+ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
+ "dev": true
+ },
+ "node_modules/cipher-base": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
+ "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/class-utils": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
+ "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==",
+ "dev": true,
+ "dependencies": {
+ "arr-union": "^3.1.0",
+ "define-property": "^0.2.5",
+ "isobject": "^3.0.0",
+ "static-extend": "^0.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/class-utils/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/class-utils/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/clean-css": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz",
+ "integrity": "sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 10.0"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
+ "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-table3": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz",
+ "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": "10.* || >= 12.*"
+ },
+ "optionalDependencies": {
+ "@colors/colors": "1.5.0"
+ }
+ },
+ "node_modules/clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "dev": true,
+ "dependencies": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/collapse-white-space": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz",
+ "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/collection-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
+ "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==",
+ "dev": true,
+ "dependencies": {
+ "map-visit": "^1.0.0",
+ "object-visit": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "dev": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
+ "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+ "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true
+ },
+ "node_modules/component-emitter": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
+ "dev": true
+ },
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
+ "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "~1.3.5",
+ "bytes": "3.0.0",
+ "compressible": "~2.0.16",
+ "debug": "2.6.9",
+ "on-headers": "~1.0.2",
+ "safe-buffer": "5.1.2",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8"
+ ],
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concat-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/concat-stream/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/concat-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/console-browserify": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
+ "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==",
+ "dev": true
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "dev": true
+ },
+ "node_modules/constants-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
+ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==",
+ "dev": true
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-disposition/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+ "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.1"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
+ "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "dev": true
+ },
+ "node_modules/copy-concurrently": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
+ "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.1.1",
+ "fs-write-stream-atomic": "^1.0.8",
+ "iferr": "^0.1.5",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.0"
+ }
+ },
+ "node_modules/copy-concurrently/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "node_modules/copy-concurrently/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/copy-concurrently/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/copy-descriptor": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
+ "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/core-js": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
+ "integrity": "sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-js-compat": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.5.tgz",
+ "integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==",
+ "dev": true,
+ "dependencies": {
+ "browserslist": "^4.21.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true
+ },
+ "node_modules/cosmiconfig": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+ "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cp-file": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-7.0.0.tgz",
+ "integrity": "sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "make-dir": "^3.0.0",
+ "nested-error-stacks": "^2.0.0",
+ "p-event": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cp-file/node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cpy": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/cpy/-/cpy-8.1.2.tgz",
+ "integrity": "sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg==",
+ "dev": true,
+ "dependencies": {
+ "arrify": "^2.0.1",
+ "cp-file": "^7.0.0",
+ "globby": "^9.2.0",
+ "has-glob": "^1.0.0",
+ "junk": "^3.1.0",
+ "nested-error-stacks": "^2.1.0",
+ "p-all": "^2.1.0",
+ "p-filter": "^2.1.0",
+ "p-map": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cpy/node_modules/@nodelib/fs.stat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
+ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cpy/node_modules/@types/glob": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
+ "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/cpy/node_modules/array-union": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
+ "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
+ "dev": true,
+ "dependencies": {
+ "array-uniq": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/dir-glob": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz",
+ "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==",
+ "dev": true,
+ "dependencies": {
+ "path-type": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cpy/node_modules/fast-glob": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz",
+ "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==",
+ "dev": true,
+ "dependencies": {
+ "@mrmlnc/readdir-enhanced": "^2.2.1",
+ "@nodelib/fs.stat": "^1.1.2",
+ "glob-parent": "^3.1.0",
+ "is-glob": "^4.0.0",
+ "merge2": "^1.2.3",
+ "micromatch": "^3.1.10"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/cpy/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ }
+ },
+ "node_modules/cpy/node_modules/glob-parent/node_modules/is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/globby": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz",
+ "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==",
+ "dev": true,
+ "dependencies": {
+ "@types/glob": "^7.1.1",
+ "array-union": "^1.0.2",
+ "dir-glob": "^2.2.2",
+ "fast-glob": "^2.2.6",
+ "glob": "^7.1.3",
+ "ignore": "^4.0.3",
+ "pify": "^4.0.1",
+ "slash": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cpy/node_modules/ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/cpy/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/cpy/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cpy/node_modules/path-type": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+ "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cpy/node_modules/path-type/node_modules/pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cpy/node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cpy/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/create-ecdh": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
+ "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ }
+ },
+ "node_modules/create-ecdh/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
+ "node_modules/create-hmac": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+ "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.3",
+ "create-hash": "^1.1.0",
+ "inherits": "^2.0.1",
+ "ripemd160": "^2.0.0",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/crypto-browserify": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
+ "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
+ "dev": true,
+ "dependencies": {
+ "browserify-cipher": "^1.0.0",
+ "browserify-sign": "^4.0.0",
+ "create-ecdh": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "create-hmac": "^1.1.0",
+ "diffie-hellman": "^5.0.0",
+ "inherits": "^2.0.1",
+ "pbkdf2": "^3.0.3",
+ "public-encrypt": "^4.0.0",
+ "randombytes": "^2.0.0",
+ "randomfill": "^1.0.3"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/css-loader": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz",
+ "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.1.0",
+ "loader-utils": "^2.0.0",
+ "postcss": "^8.2.15",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.27.0 || ^5.0.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "dev": true,
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/css-loader/node_modules/postcss": {
+ "version": "8.4.17",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz",
+ "integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.4",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-extract-imports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+ "dev": true,
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-local-by-default": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+ "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-scope": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+ "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+ "dev": true,
+ "dependencies": {
+ "postcss-selector-parser": "^6.0.4"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.0.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/css-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/css-loader/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/css-select": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
+ "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.0.1",
+ "domhandler": "^4.3.1",
+ "domutils": "^2.8.0",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/currently-unhandled": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
+ "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "array-find-index": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/cyclist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
+ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz",
+ "integrity": "sha512-qPy925qewwul9Hifs+3sx1ZYn14obHxpkX+mPD369w4Rzg+YkJBgi3SOvwUq81nWSjqGUegIgEPwD8u+HUnxlw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "bplist-parser": "^0.1.0",
+ "meow": "^3.1.0",
+ "untildify": "^2.0.0"
+ },
+ "bin": {
+ "default-browser-id": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
+ "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
+ "dev": true,
+ "dependencies": {
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-property": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
+ "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.2",
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "dev": true
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/des.js": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
+ "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detab": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz",
+ "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==",
+ "dev": true,
+ "dependencies": {
+ "repeat-string": "^1.5.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/detect-package-manager": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/detect-package-manager/-/detect-package-manager-2.0.1.tgz",
+ "integrity": "sha512-j/lJHyoLlWi6G1LDdLgvUtz60Zo5GEj+sVYtTVXnYLDPuzgC3llMxonXym9zIwhhUII8vjdw0LXxavpLqTbl1A==",
+ "dev": true,
+ "dependencies": {
+ "execa": "^5.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/detect-port": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz",
+ "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==",
+ "dev": true,
+ "dependencies": {
+ "address": "^1.0.1",
+ "debug": "4"
+ },
+ "bin": {
+ "detect": "bin/detect-port.js",
+ "detect-port": "bin/detect-port.js"
+ }
+ },
+ "node_modules/diffie-hellman": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+ "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ }
+ },
+ "node_modules/diffie-hellman/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dom-converter": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
+ "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==",
+ "dev": true,
+ "dependencies": {
+ "utila": "~0.4"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
+ "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
+ "dev": true
+ },
+ "node_modules/domain-browser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
+ "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4",
+ "npm": ">=1.2"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
+ "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.2.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "dev": true,
+ "dependencies": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
+ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
+ "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==",
+ "dev": true
+ },
+ "node_modules/duplexify": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
+ "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0",
+ "stream-shift": "^1.0.0"
+ }
+ },
+ "node_modules/duplexify/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/duplexify/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/duplexify/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.4.271",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.271.tgz",
+ "integrity": "sha512-BCPBtK07xR1/uY2HFDtl3wK2De66AW4MSiPlLrnPNxKC/Qhccxd59W73654S3y6Rb/k3hmuGJOBnhjfoutetXA==",
+ "dev": true
+ },
+ "node_modules/elliptic": {
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
+ "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/elliptic/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "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",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz",
+ "integrity": "sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/enhanced-resolve/node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/errno": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+ "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+ "dev": true,
+ "dependencies": {
+ "prr": "~1.0.1"
+ },
+ "bin": {
+ "errno": "cli.js"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz",
+ "integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "function.prototype.name": "^1.1.5",
+ "get-intrinsic": "^1.1.3",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-property-descriptors": "^1.0.0",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.6",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.2",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.4.3",
+ "safe-regex-test": "^1.0.0",
+ "string.prototype.trimend": "^1.0.5",
+ "string.prototype.trimstart": "^1.0.5",
+ "unbox-primitive": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-array-method-boxes-properly": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz",
+ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==",
+ "dev": true
+ },
+ "node_modules/es-get-iterator": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
+ "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.0",
+ "has-symbols": "^1.0.1",
+ "is-arguments": "^1.1.0",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.5",
+ "isarray": "^2.0.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
+ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
+ "dev": true
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
+ "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
+ "dev": true,
+ "dependencies": {
+ "has": "^1.0.3"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "dev": true,
+ "dependencies": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es5-shim": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz",
+ "integrity": "sha512-jg21/dmlrNQI7JyyA2w7n+yifSxBng0ZralnSfVZjoCawgNTCnS+yBCyVM9DL5itm7SUnDGgv7hcq2XCZX4iRQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/es6-shim": {
+ "version": "0.35.6",
+ "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz",
+ "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
+ "dev": true
+ },
+ "node_modules/escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/eslint-scope/node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/evp_bytestokey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
+ "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
+ "dev": true,
+ "dependencies": {
+ "md5.js": "^1.3.4",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "node_modules/exec-sh": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz",
+ "integrity": "sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==",
+ "dev": true
+ },
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/expand-brackets": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
+ "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^2.3.3",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "posix-character-classes": "^0.1.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/expand-brackets/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expand-brackets/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/express": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz",
+ "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.0",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.5.0",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.2.0",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.1",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.10.3",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.18.0",
+ "serve-static": "1.15.0",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/express/node_modules/qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/express/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "dev": true
+ },
+ "node_modules/extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "dev": true,
+ "dependencies": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
+ "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==",
+ "dev": true,
+ "dependencies": {
+ "array-unique": "^0.3.2",
+ "define-property": "^1.0.0",
+ "expand-brackets": "^2.1.4",
+ "extend-shallow": "^2.0.1",
+ "fragment-cache": "^0.2.1",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob/node_modules/define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extglob/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-glob": {
+ "version": "3.2.12",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
+ "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fastq": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+ "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "dependencies": {
+ "bser": "2.1.1"
+ }
+ },
+ "node_modules/fetch-retry": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz",
+ "integrity": "sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==",
+ "dev": true
+ },
+ "node_modules/figgy-pudding": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
+ "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==",
+ "dev": true
+ },
+ "node_modules/file-loader": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
+ "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/file-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/file-system-cache": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz",
+ "integrity": "sha512-IzF5MBq+5CR0jXx5RxPe4BICl/oEhBSXKaL9fLhAXrIfIUS77Hr4vzrYyqYMHN6uTt+BOqi3fDCTjjEBCjERKw==",
+ "dev": true,
+ "dependencies": {
+ "fs-extra": "^10.1.0",
+ "ramda": "^0.28.0"
+ }
+ },
+ "node_modules/file-system-cache/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
+ "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/find-cache-dir/node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flush-write-stream": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
+ "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.3.6"
+ }
+ },
+ "node_modules/flush-write-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/flush-write-stream/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/flush-write-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz",
+ "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.8.3",
+ "@types/json-schema": "^7.0.5",
+ "chalk": "^4.1.0",
+ "chokidar": "^3.4.2",
+ "cosmiconfig": "^6.0.0",
+ "deepmerge": "^4.2.2",
+ "fs-extra": "^9.0.0",
+ "glob": "^7.1.6",
+ "memfs": "^3.1.2",
+ "minimatch": "^3.0.4",
+ "schema-utils": "2.7.0",
+ "semver": "^7.3.2",
+ "tapable": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "yarn": ">=1.0.0"
+ },
+ "peerDependencies": {
+ "eslint": ">= 6",
+ "typescript": ">= 2.7",
+ "vue-template-compiler": "*",
+ "webpack": ">= 4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ },
+ "vue-template-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+ "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+ "dev": true,
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.1.0",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.7.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
+ "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.4",
+ "ajv": "^6.12.2",
+ "ajv-keywords": "^3.4.1"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fork-ts-checker-webpack-plugin/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+ "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+ "dev": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fragment-cache": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
+ "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==",
+ "dev": true,
+ "dependencies": {
+ "map-cache": "^0.2.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/from2": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz",
+ "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0"
+ }
+ },
+ "node_modules/from2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/from2/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/from2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-monkey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz",
+ "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==",
+ "dev": true
+ },
+ "node_modules/fs-write-stream-atomic": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
+ "integrity": "sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "iferr": "^0.1.5",
+ "imurmurhash": "^0.1.4",
+ "readable-stream": "1 || 2"
+ }
+ },
+ "node_modules/fs-write-stream-atomic/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/fs-write-stream-atomic/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/fs-write-stream-atomic/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz",
+ "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "functions-have-names": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
+ "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/get-stdin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
+ "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+ "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/github-slugger": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz",
+ "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==",
+ "dev": true
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/glob-promise": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-3.4.0.tgz",
+ "integrity": "sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==",
+ "dev": true,
+ "dependencies": {
+ "@types/glob": "*"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "glob": "*"
+ }
+ },
+ "node_modules/glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true
+ },
+ "node_modules/global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "dev": true,
+ "dependencies": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
+ "node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
+ "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
+ "dev": true,
+ "dependencies": {
+ "define-properties": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+ "dev": true
+ },
+ "node_modules/handlebars": {
+ "version": "4.7.7",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+ "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.0",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/has-bigints": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
+ "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-glob": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz",
+ "integrity": "sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-glob/node_modules/is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
+ "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+ "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+ "dev": true,
+ "dependencies": {
+ "has-symbols": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "dev": true
+ },
+ "node_modules/has-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
+ "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==",
+ "dev": true,
+ "dependencies": {
+ "get-value": "^2.0.6",
+ "has-values": "^1.0.0",
+ "isobject": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz",
+ "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "kind-of": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/has-values/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-values/node_modules/kind-of": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
+ "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/hash-base": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
+ "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/hash-base/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "node_modules/hast-to-hyperscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz",
+ "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.3",
+ "comma-separated-tokens": "^1.0.0",
+ "property-information": "^5.3.0",
+ "space-separated-tokens": "^1.0.0",
+ "style-to-object": "^0.3.0",
+ "unist-util-is": "^4.0.0",
+ "web-namespaces": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz",
+ "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==",
+ "dev": true,
+ "dependencies": {
+ "@types/parse5": "^5.0.0",
+ "hastscript": "^6.0.0",
+ "property-information": "^5.0.0",
+ "vfile": "^4.0.0",
+ "vfile-location": "^3.2.0",
+ "web-namespaces": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
+ "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-raw": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz",
+ "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==",
+ "dev": true,
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "hast-util-from-parse5": "^6.0.0",
+ "hast-util-to-parse5": "^6.0.0",
+ "html-void-elements": "^1.0.0",
+ "parse5": "^6.0.0",
+ "unist-util-position": "^3.0.0",
+ "vfile": "^4.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-parse5": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz",
+ "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==",
+ "dev": true,
+ "dependencies": {
+ "hast-to-hyperscript": "^9.0.0",
+ "property-information": "^5.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
+ "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
+ "dev": true,
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "dev": true,
+ "dependencies": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/hosted-git-info": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "dev": true
+ },
+ "node_modules/html-entities": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
+ "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==",
+ "dev": true
+ },
+ "node_modules/html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.2",
+ "clean-css": "^5.2.2",
+ "commander": "^8.3.0",
+ "he": "^1.2.0",
+ "param-case": "^3.0.4",
+ "relateurl": "^0.2.7",
+ "terser": "^5.10.0"
+ },
+ "bin": {
+ "html-minifier-terser": "cli.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/html-minifier-terser/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "dev": true,
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/html-void-elements": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz",
+ "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/html-webpack-plugin": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz",
+ "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==",
+ "dev": true,
+ "dependencies": {
+ "@types/html-minifier-terser": "^6.0.0",
+ "html-minifier-terser": "^6.0.2",
+ "lodash": "^4.17.21",
+ "pretty-error": "^4.0.0",
+ "tapable": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/html-webpack-plugin"
+ },
+ "peerDependencies": {
+ "webpack": "^5.20.0"
+ }
+ },
+ "node_modules/html-webpack-plugin/node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
+ "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
+ "dev": true,
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.0.0",
+ "domutils": "^2.5.2",
+ "entities": "^2.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dev": true,
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/https-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
+ "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==",
+ "dev": true
+ },
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/icss-utils": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
+ "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.14"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/iferr": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz",
+ "integrity": "sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==",
+ "dev": true
+ },
+ "node_modules/ignore": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+ "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-fresh/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "dev": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
+ "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
+ "dev": true
+ },
+ "node_modules/internal-slot": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+ "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.1.0",
+ "has": "^1.0.3",
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/interpret": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
+ "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/ip": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
+ "dev": true
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-absolute-url": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz",
+ "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-alphabetical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
+ "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
+ "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
+ "dev": true,
+ "dependencies": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-arguments": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+ "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "node_modules/is-bigint": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+ "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+ "dev": true,
+ "dependencies": {
+ "has-bigints": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+ "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-buffer": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+ "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
+ "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
+ "dev": true,
+ "dependencies": {
+ "ci-info": "^2.0.0"
+ },
+ "bin": {
+ "is-ci": "bin.js"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz",
+ "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==",
+ "dev": true,
+ "dependencies": {
+ "has": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+ "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
+ "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-dom": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-dom/-/is-dom-1.1.0.tgz",
+ "integrity": "sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==",
+ "dev": true,
+ "dependencies": {
+ "is-object": "^1.0.1",
+ "is-window": "^1.0.2"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "dev": true,
+ "dependencies": {
+ "is-plain-object": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finite": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz",
+ "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "dev": true
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
+ "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
+ "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
+ "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
+ "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz",
+ "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+ "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+ "dev": true,
+ "dependencies": {
+ "has-symbols": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+ "dev": true
+ },
+ "node_modules/is-utf8": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
+ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/is-weakref": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+ "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-whitespace-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz",
+ "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-window": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz",
+ "integrity": "sha512-uj00kdXyZb9t9RcAUAwMZAnkBUwdYGhYlt7djMXhfyhUCzwNba50tIiBKR7q0l7tdoBtFVw/3JmLY6fI3rmZmg==",
+ "dev": true
+ },
+ "node_modules/is-windows": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-word-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz",
+ "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isomorphic-unfetch": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz",
+ "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==",
+ "dev": true,
+ "dependencies": {
+ "node-fetch": "^2.6.1",
+ "unfetch": "^4.2.0"
+ }
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+ "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-instrument": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz",
+ "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/iterate-iterator": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz",
+ "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/iterate-value": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz",
+ "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==",
+ "dev": true,
+ "dependencies": {
+ "es-get-iterator": "^1.0.2",
+ "iterate-iterator": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/jest-haste-map": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz",
+ "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^26.6.2",
+ "@types/graceful-fs": "^4.1.2",
+ "@types/node": "*",
+ "anymatch": "^3.0.3",
+ "fb-watchman": "^2.0.0",
+ "graceful-fs": "^4.2.4",
+ "jest-regex-util": "^26.0.0",
+ "jest-serializer": "^26.6.2",
+ "jest-util": "^26.6.2",
+ "jest-worker": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "sane": "^4.0.3",
+ "walker": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ },
+ "optionalDependencies": {
+ "fsevents": "^2.1.2"
+ }
+ },
+ "node_modules/jest-regex-util": {
+ "version": "26.0.0",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz",
+ "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/jest-serializer": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz",
+ "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "graceful-fs": "^4.2.4"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/jest-util": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz",
+ "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^26.6.2",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.4",
+ "is-ci": "^2.0.0",
+ "micromatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">= 10.14.2"
+ }
+ },
+ "node_modules/jest-util/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/jest-util/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/jest-util/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/jest-util/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/jest-util/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest-util/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest-worker": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
+ "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/jest-worker/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest-worker/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-string-escape": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
+ "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/json-parse-better-errors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+ "dev": true
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json5": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
+ "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "dev": true,
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/junk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
+ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/klona": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+ "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/lazy-universal-dotenv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-3.0.1.tgz",
+ "integrity": "sha512-prXSYk799h3GY3iOWnC6ZigYzMPjxN2svgjJ9shk7oMadSNX3wXy0B6F32PMJv7qtMnrIbUxoEHzbutvxR2LBQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.5.0",
+ "app-root-dir": "^1.0.2",
+ "core-js": "^3.0.4",
+ "dotenv": "^8.0.0",
+ "dotenv-expand": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=6.0.0",
+ "npm": ">=6.0.0",
+ "yarn": ">=1.0.0"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "node_modules/lit": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-2.3.1.tgz",
+ "integrity": "sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==",
+ "dev": true,
+ "dependencies": {
+ "@lit/reactive-element": "^1.4.0",
+ "lit-element": "^3.2.0",
+ "lit-html": "^2.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.2.tgz",
+ "integrity": "sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==",
+ "dev": true,
+ "dependencies": {
+ "@lit/reactive-element": "^1.3.0",
+ "lit-html": "^2.2.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.3.1.tgz",
+ "integrity": "sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==",
+ "dev": true,
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
+ "node_modules/load-json-file": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
+ "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "parse-json": "^2.2.0",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0",
+ "strip-bom": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/load-json-file/node_modules/parse-json": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
+ "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "error-ex": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/load-json-file/node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/loader-runner": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
+ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.11.5"
+ }
+ },
+ "node_modules/loader-utils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+ "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=8.9.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true
+ },
+ "node_modules/lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+ "dev": true
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loud-rejection": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz",
+ "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "currently-unhandled": "^0.4.1",
+ "signal-exit": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/makeerror": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+ "dev": true,
+ "dependencies": {
+ "tmpl": "1.0.5"
+ }
+ },
+ "node_modules/map-age-cleaner": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz",
+ "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==",
+ "dev": true,
+ "dependencies": {
+ "p-defer": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/map-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+ "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/map-or-similar": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz",
+ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==",
+ "dev": true
+ },
+ "node_modules/map-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
+ "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==",
+ "dev": true,
+ "dependencies": {
+ "object-visit": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/markdown-escapes": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz",
+ "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "dev": true,
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/mdast-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-remove": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-definitions": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz",
+ "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz",
+ "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==",
+ "dev": true,
+ "dependencies": {
+ "@types/mdast": "^3.0.0",
+ "@types/unist": "^2.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "mdurl": "^1.0.0",
+ "unist-builder": "^2.0.0",
+ "unist-util-generated": "^1.0.0",
+ "unist-util-position": "^3.0.0",
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz",
+ "integrity": "sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+ "dev": true
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mem": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz",
+ "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==",
+ "dev": true,
+ "dependencies": {
+ "map-age-cleaner": "^0.1.3",
+ "mimic-fn": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/mem?sponsor=1"
+ }
+ },
+ "node_modules/mem/node_modules/mimic-fn": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
+ "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/memfs": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.7.tgz",
+ "integrity": "sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==",
+ "dev": true,
+ "dependencies": {
+ "fs-monkey": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/memoizerific": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
+ "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==",
+ "dev": true,
+ "dependencies": {
+ "map-or-similar": "^1.5.0"
+ }
+ },
+ "node_modules/memory-fs": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
+ "integrity": "sha512-cda4JKCxReDXFXRqOHPQscuIYg1PvxbE2S2GP45rnwfEK+vZaXC8C1OFvdHIbgw0DLzowXGVoxLaAmlgRy14GQ==",
+ "dev": true,
+ "dependencies": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ },
+ "node_modules/memory-fs/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/memory-fs/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/memory-fs/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/meow": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+ "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "camelcase-keys": "^2.0.0",
+ "decamelize": "^1.1.2",
+ "loud-rejection": "^1.0.0",
+ "map-obj": "^1.0.1",
+ "minimist": "^1.1.3",
+ "normalize-package-data": "^2.3.4",
+ "object-assign": "^4.0.1",
+ "read-pkg-up": "^1.0.1",
+ "redent": "^1.0.0",
+ "trim-newlines": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/find-up": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
+ "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "path-exists": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/path-exists": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
+ "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "pinkie-promise": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/path-type": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
+ "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/read-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
+ "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "load-json-file": "^1.0.0",
+ "normalize-package-data": "^2.3.2",
+ "path-type": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/meow/node_modules/read-pkg-up": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
+ "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "find-up": "^1.0.0",
+ "read-pkg": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
+ "dev": true
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/microevent.ts": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz",
+ "integrity": "sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==",
+ "dev": true
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+ "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.2",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "bin": {
+ "miller-rabin": "bin/miller-rabin"
+ }
+ },
+ "node_modules/miller-rabin/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/min-document": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+ "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
+ "dev": true,
+ "dependencies": {
+ "dom-walk": "^0.1.0"
+ }
+ },
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "dev": true
+ },
+ "node_modules/minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "dev": true
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+ "dev": true
+ },
+ "node_modules/minipass": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz",
+ "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/mississippi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
+ "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==",
+ "dev": true,
+ "dependencies": {
+ "concat-stream": "^1.5.0",
+ "duplexify": "^3.4.2",
+ "end-of-stream": "^1.1.0",
+ "flush-write-stream": "^1.0.0",
+ "from2": "^2.1.0",
+ "parallel-transform": "^1.1.0",
+ "pump": "^3.0.0",
+ "pumpify": "^1.3.3",
+ "stream-each": "^1.1.0",
+ "through2": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mixin-deep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
+ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
+ "dev": true,
+ "dependencies": {
+ "for-in": "^1.0.2",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/move-concurrently": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
+ "integrity": "sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.1.1",
+ "copy-concurrently": "^1.0.0",
+ "fs-write-stream-atomic": "^1.0.8",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.3"
+ }
+ },
+ "node_modules/move-concurrently/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "node_modules/move-concurrently/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/move-concurrently/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nan": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.16.0.tgz",
+ "integrity": "sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+ "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "dev": true,
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nanomatch": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
+ "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "fragment-cache": "^0.2.1",
+ "is-windows": "^1.0.2",
+ "kind-of": "^6.0.2",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true
+ },
+ "node_modules/nested-error-stacks": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz",
+ "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==",
+ "dev": true
+ },
+ "node_modules/nice-try": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+ "dev": true
+ },
+ "node_modules/no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "dev": true,
+ "dependencies": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+ "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+ "dev": true,
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
+ "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==",
+ "dev": true,
+ "dependencies": {
+ "assert": "^1.1.1",
+ "browserify-zlib": "^0.2.0",
+ "buffer": "^4.3.0",
+ "console-browserify": "^1.1.0",
+ "constants-browserify": "^1.0.0",
+ "crypto-browserify": "^3.11.0",
+ "domain-browser": "^1.1.1",
+ "events": "^3.0.0",
+ "https-browserify": "^1.0.0",
+ "os-browserify": "^0.3.0",
+ "path-browserify": "0.0.1",
+ "process": "^0.11.10",
+ "punycode": "^1.2.4",
+ "querystring-es3": "^0.2.0",
+ "readable-stream": "^2.3.3",
+ "stream-browserify": "^2.0.1",
+ "stream-http": "^2.7.2",
+ "string_decoder": "^1.0.0",
+ "timers-browserify": "^2.0.4",
+ "tty-browserify": "0.0.0",
+ "url": "^0.11.0",
+ "util": "^0.11.0",
+ "vm-browserify": "^1.0.1"
+ }
+ },
+ "node_modules/node-libs-browser/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser/node_modules/path-browserify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
+ "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser/node_modules/punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ },
+ "node_modules/node-libs-browser/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/node-libs-browser/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
+ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
+ "dev": true
+ },
+ "node_modules/normalize-package-data": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+ "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+ "dev": true,
+ "dependencies": {
+ "hosted-git-info": "^2.1.4",
+ "resolve": "^1.10.0",
+ "semver": "2 || 3 || 4 || 5",
+ "validate-npm-package-license": "^3.0.1"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "dev": true,
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/num2fraction": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
+ "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==",
+ "dev": true
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
+ "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==",
+ "dev": true,
+ "dependencies": {
+ "copy-descriptor": "^0.1.0",
+ "define-property": "^0.2.5",
+ "kind-of": "^3.0.3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/object-copy/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/is-descriptor/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-copy/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz",
+ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object-visit": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
+ "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz",
+ "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "has-symbols": "^1.0.3",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+ "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+ "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.getownpropertydescriptors": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.4.tgz",
+ "integrity": "sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ==",
+ "dev": true,
+ "dependencies": {
+ "array.prototype.reduce": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.20.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.pick": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
+ "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+ "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz",
+ "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==",
+ "dev": true,
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/os-browserify": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
+ "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==",
+ "dev": true
+ },
+ "node_modules/os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/p-all": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-all/-/p-all-2.1.0.tgz",
+ "integrity": "sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==",
+ "dev": true,
+ "dependencies": {
+ "p-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/p-all/node_modules/p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/p-defer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
+ "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-event": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
+ "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
+ "dev": true,
+ "dependencies": {
+ "p-timeout": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-filter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz",
+ "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==",
+ "dev": true,
+ "dependencies": {
+ "p-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-filter/node_modules/p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+ "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+ "dev": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "dev": true,
+ "dependencies": {
+ "p-finally": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true
+ },
+ "node_modules/parallel-transform": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
+ "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==",
+ "dev": true,
+ "dependencies": {
+ "cyclist": "^1.0.1",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.1.5"
+ }
+ },
+ "node_modules/parallel-transform/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/parallel-transform/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/parallel-transform/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/param-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+ "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+ "dev": true,
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-asn1": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
+ "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==",
+ "dev": true,
+ "dependencies": {
+ "asn1.js": "^5.2.0",
+ "browserify-aes": "^1.0.0",
+ "evp_bytestokey": "^1.0.0",
+ "pbkdf2": "^3.0.3",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
+ "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
+ "dev": true,
+ "dependencies": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "dev": true
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/pascal-case": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+ "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/pascalcase": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
+ "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true
+ },
+ "node_modules/path-dirname": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
+ "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==",
+ "dev": true
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
+ "dev": true
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pbkdf2": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
+ "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
+ "dev": true,
+ "dependencies": {
+ "create-hash": "^1.1.2",
+ "create-hmac": "^1.1.4",
+ "ripemd160": "^2.0.1",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
+ "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pinkie": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+ "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pinkie-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "pinkie": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
+ "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-dir": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz",
+ "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pnp-webpack-plugin": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz",
+ "integrity": "sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==",
+ "dev": true,
+ "dependencies": {
+ "ts-pnp": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/polished": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz",
+ "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.17.8"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/posix-character-classes": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
+ "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "7.0.39",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
+ "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
+ "dev": true,
+ "dependencies": {
+ "picocolors": "^0.2.1",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ }
+ },
+ "node_modules/postcss-flexbugs-fixes": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-4.2.1.tgz",
+ "integrity": "sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.26"
+ }
+ },
+ "node_modules/postcss-loader": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz",
+ "integrity": "sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==",
+ "dev": true,
+ "dependencies": {
+ "cosmiconfig": "^7.0.0",
+ "klona": "^2.0.4",
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.4"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "postcss": "^7.0.0 || ^8.0.1",
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/postcss-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/postcss-loader/node_modules/semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/postcss-modules-extract-imports": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
+ "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-modules-local-by-default": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
+ "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^4.1.1",
+ "postcss": "^7.0.32",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-modules-scope": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
+ "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss": "^7.0.6",
+ "postcss-selector-parser": "^6.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-modules-values": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
+ "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^4.0.0",
+ "postcss": "^7.0.6"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "node_modules/prettier": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.0.tgz",
+ "integrity": "sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin-prettier.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/pretty-error": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
+ "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.20",
+ "renderkid": "^3.0.0"
+ }
+ },
+ "node_modules/pretty-hrtime": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+ "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "dev": true
+ },
+ "node_modules/promise.allsettled": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.5.tgz",
+ "integrity": "sha512-tVDqeZPoBC0SlzJHzWGZ2NKAguVq2oiYj7gbggbiTvH2itHohijTp7njOUA0aQ/nl+0lr/r6egmhoYu63UZ/pQ==",
+ "dev": true,
+ "dependencies": {
+ "array.prototype.map": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "iterate-value": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/promise.prototype.finally": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/promise.prototype.finally/-/promise.prototype.finally-3.1.3.tgz",
+ "integrity": "sha512-EXRF3fC9/0gz4qkt/f5EP5iW4kj9oFpBICNpCNOb/52+8nlHIX07FPLbi/q4qYBQ1xZqivMzTpNQSnArVASolQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
+ "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
+ "dev": true,
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/prr": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
+ "dev": true
+ },
+ "node_modules/public-encrypt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
+ "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/public-encrypt/node_modules/bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ },
+ "node_modules/pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/pumpify": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz",
+ "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==",
+ "dev": true,
+ "dependencies": {
+ "duplexify": "^3.6.0",
+ "inherits": "^2.0.3",
+ "pump": "^2.0.0"
+ }
+ },
+ "node_modules/pumpify/node_modules/pump": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",
+ "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+ "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/querystring": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+ "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
+ "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/querystring-es3": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
+ "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/ramda": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
+ "integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ramda"
+ }
+ },
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
+ "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/raw-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
+ "node_modules/read-pkg": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
+ "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+ "dev": true,
+ "dependencies": {
+ "@types/normalize-package-data": "^2.4.0",
+ "normalize-package-data": "^2.5.0",
+ "parse-json": "^5.0.0",
+ "type-fest": "^0.6.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+ "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.1.0",
+ "read-pkg": "^5.2.0",
+ "type-fest": "^0.8.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg-up/node_modules/type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/read-pkg/node_modules/type-fest": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
+ "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
+ "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "indent-string": "^2.1.0",
+ "strip-indent": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/redent/node_modules/indent-string": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
+ "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "repeating": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+ "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+ "dev": true,
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.9",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
+ "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==",
+ "dev": true
+ },
+ "node_modules/regenerator-transform": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz",
+ "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.8.4"
+ }
+ },
+ "node_modules/regex-not": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
+ "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^3.0.2",
+ "safe-regex": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
+ "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "functions-have-names": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexpu-core": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz",
+ "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==",
+ "dev": true,
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.1.0",
+ "regjsgen": "^0.7.1",
+ "regjsparser": "^0.9.1",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz",
+ "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==",
+ "dev": true
+ },
+ "node_modules/regjsparser": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+ "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+ "dev": true,
+ "dependencies": {
+ "jsesc": "~0.5.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/regjsparser/node_modules/jsesc": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+ "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ }
+ },
+ "node_modules/relateurl": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/remark-external-links": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz",
+ "integrity": "sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==",
+ "dev": true,
+ "dependencies": {
+ "extend": "^3.0.0",
+ "is-absolute-url": "^3.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "space-separated-tokens": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-footnotes": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz",
+ "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz",
+ "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "7.12.9",
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@babel/plugin-proposal-object-rest-spread": "7.12.1",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@mdx-js/util": "1.6.22",
+ "is-alphabetical": "1.0.4",
+ "remark-parse": "8.0.3",
+ "unified": "9.2.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "node_modules/remark-mdx/node_modules/@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz",
+ "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.0",
+ "@babel/plugin-transform-parameters": "^7.12.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/remark-mdx/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz",
+ "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==",
+ "dev": true,
+ "dependencies": {
+ "ccount": "^1.0.0",
+ "collapse-white-space": "^1.0.2",
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-whitespace-character": "^1.0.0",
+ "is-word-character": "^1.0.0",
+ "markdown-escapes": "^1.0.0",
+ "parse-entities": "^2.0.0",
+ "repeat-string": "^1.5.4",
+ "state-toggle": "^1.0.0",
+ "trim": "0.0.1",
+ "trim-trailing-lines": "^1.0.0",
+ "unherit": "^1.0.4",
+ "unist-util-remove-position": "^2.0.0",
+ "vfile-location": "^3.0.0",
+ "xtend": "^4.0.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-slug": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/remark-slug/-/remark-slug-6.1.0.tgz",
+ "integrity": "sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==",
+ "dev": true,
+ "dependencies": {
+ "github-slugger": "^1.0.0",
+ "mdast-util-to-string": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==",
+ "dev": true,
+ "dependencies": {
+ "mdast-squeeze-paragraphs": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remove-trailing-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
+ "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==",
+ "dev": true
+ },
+ "node_modules/renderkid": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
+ "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==",
+ "dev": true,
+ "dependencies": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "node_modules/repeat-element": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
+ "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/repeating": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
+ "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-finite": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+ "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.9.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve-url": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+ "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==",
+ "deprecated": "https://github.com/lydell/resolve-url#deprecated",
+ "dev": true
+ },
+ "node_modules/ret": {
+ "version": "0.1.15",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ripemd160": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
+ "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
+ "dev": true,
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "node_modules/rsvp": {
+ "version": "4.8.5",
+ "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
+ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || >= 7.*"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/run-queue": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
+ "integrity": "sha512-ntymy489o0/QQplUDnpYAYUsO50K9SBrIVaKCWDOJzYJts0f9WH9RFJkyagebkw5+y1oi00R7ynNW/d12GBumg==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.1.1"
+ }
+ },
+ "node_modules/run-queue/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "node_modules/safe-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+ "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==",
+ "dev": true,
+ "dependencies": {
+ "ret": "~0.1.10"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
+ "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "is-regex": "^1.1.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "node_modules/sane": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz",
+ "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==",
+ "deprecated": "some dependency vulnerabilities fixed, support for node < 10 dropped, and newer ECMAScript syntax/features added",
+ "dev": true,
+ "dependencies": {
+ "@cnakazawa/watch": "^1.0.3",
+ "anymatch": "^2.0.0",
+ "capture-exit": "^2.0.0",
+ "exec-sh": "^0.3.2",
+ "execa": "^1.0.0",
+ "fb-watchman": "^2.0.0",
+ "micromatch": "^3.1.4",
+ "minimist": "^1.1.1",
+ "walker": "~1.0.5"
+ },
+ "bin": {
+ "sane": "src/cli.js"
+ },
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/sane/node_modules/anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "dependencies": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ }
+ },
+ "node_modules/sane/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/cross-spawn": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+ "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+ "dev": true,
+ "dependencies": {
+ "nice-try": "^1.0.4",
+ "path-key": "^2.0.1",
+ "semver": "^5.5.0",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ },
+ "engines": {
+ "node": ">=4.8"
+ }
+ },
+ "node_modules/sane/node_modules/execa": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+ "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^6.0.0",
+ "get-stream": "^4.0.0",
+ "is-stream": "^1.1.0",
+ "npm-run-path": "^2.0.0",
+ "p-finally": "^1.0.0",
+ "signal-exit": "^3.0.0",
+ "strip-eof": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/sane/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/get-stream": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+ "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+ "dev": true,
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/sane/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/sane/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "dependencies": {
+ "remove-trailing-separator": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/sane/node_modules/path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/sane/node_modules/semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/sane/node_modules/shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sane/node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/schema-utils": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
+ "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.5",
+ "ajv": "^6.12.4",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
+ "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/send/node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/serialize-javascript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+ "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/serve-favicon": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz",
+ "integrity": "sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==",
+ "dev": true,
+ "dependencies": {
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "ms": "2.1.1",
+ "parseurl": "~1.3.2",
+ "safe-buffer": "5.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/serve-favicon/node_modules/ms": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
+ "dev": true
+ },
+ "node_modules/serve-favicon/node_modules/safe-buffer": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
+ "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
+ "dev": true
+ },
+ "node_modules/serve-static": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
+ "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
+ "dev": true,
+ "dependencies": {
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.18.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "dev": true
+ },
+ "node_modules/set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/set-value/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/set-value/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
+ "node_modules/sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ },
+ "bin": {
+ "sha.js": "bin.js"
+ }
+ },
+ "node_modules/shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/snapdragon": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
+ "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==",
+ "dev": true,
+ "dependencies": {
+ "base": "^0.11.1",
+ "debug": "^2.2.0",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "map-cache": "^0.2.2",
+ "source-map": "^0.5.6",
+ "source-map-resolve": "^0.5.0",
+ "use": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz",
+ "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==",
+ "dev": true,
+ "dependencies": {
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.0",
+ "snapdragon-util": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-node/node_modules/define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-util": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz",
+ "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon-util/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/snapdragon-util/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/snapdragon/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/snapdragon/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/snapdragon/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-list-map": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
+ "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
+ "dev": true
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-resolve": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
+ "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+ "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated",
+ "dev": true,
+ "dependencies": {
+ "atob": "^2.1.2",
+ "decode-uri-component": "^0.2.0",
+ "resolve-url": "^0.2.1",
+ "source-map-url": "^0.4.0",
+ "urix": "^0.1.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-url": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
+ "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+ "deprecated": "See https://github.com/lydell/source-map-url#deprecated",
+ "dev": true
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
+ "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/spdx-correct": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+ "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+ "dev": true,
+ "dependencies": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-exceptions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+ "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+ "dev": true
+ },
+ "node_modules/spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "dependencies": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-license-ids": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz",
+ "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==",
+ "dev": true
+ },
+ "node_modules/split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ },
+ "node_modules/ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/stable": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
+ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
+ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility",
+ "dev": true
+ },
+ "node_modules/state-toggle": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",
+ "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/static-extend": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
+ "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==",
+ "dev": true,
+ "dependencies": {
+ "define-property": "^0.2.5",
+ "object-copy": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "dependencies": {
+ "is-descriptor": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-accessor-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/static-extend/node_modules/is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-data-descriptor/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "dependencies": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/static-extend/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/store2": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.2.tgz",
+ "integrity": "sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==",
+ "dev": true
+ },
+ "node_modules/stream-browserify": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
+ "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "~2.0.1",
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/stream-browserify/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/stream-browserify/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/stream-browserify/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/stream-each": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
+ "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "stream-shift": "^1.0.0"
+ }
+ },
+ "node_modules/stream-http": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz",
+ "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==",
+ "dev": true,
+ "dependencies": {
+ "builtin-status-codes": "^3.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.3.6",
+ "to-arraybuffer": "^1.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/stream-http/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/stream-http/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/stream-http/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/stream-shift": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
+ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==",
+ "dev": true
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+ "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "regexp.prototype.flags": "^1.4.1",
+ "side-channel": "^1.0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.padend": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz",
+ "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.padstart": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padstart/-/string.prototype.padstart-3.1.3.tgz",
+ "integrity": "sha512-NZydyOMtYxpTjGqp0VN5PYUF/tsU15yDMZnUdj16qRUIUiMJkHHSDElYyQFrMu+/WloTpA7MQSiADhBicDfaoA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz",
+ "integrity": "sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz",
+ "integrity": "sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-utf8": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz",
+ "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "get-stdin": "^4.0.1"
+ },
+ "bin": {
+ "strip-indent": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/style-loader": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
+ "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/style-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
+ "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
+ "dev": true,
+ "dependencies": {
+ "inline-style-parser": "0.1.1"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/symbol.prototype.description": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/symbol.prototype.description/-/symbol.prototype.description-1.0.5.tgz",
+ "integrity": "sha512-x738iXRYsrAt9WBhRCVG5BtIC3B7CUkFwbHW2zOvGtwM33s7JjrCDyq8V0zgMYVb5ymsL8+qkzzpANH63CPQaQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-symbol-description": "^1.0.0",
+ "has-symbols": "^1.0.2",
+ "object.getownpropertydescriptors": "^2.1.2"
+ },
+ "engines": {
+ "node": ">= 0.11.15"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/synchronous-promise": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.16.tgz",
+ "integrity": "sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==",
+ "dev": true
+ },
+ "node_modules/tapable": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
+ "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+ "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
+ "dev": true,
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^3.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/telejson": {
+ "version": "6.0.8",
+ "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz",
+ "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==",
+ "dev": true,
+ "dependencies": {
+ "@types/is-function": "^1.0.0",
+ "global": "^4.4.0",
+ "is-function": "^1.0.2",
+ "is-regex": "^1.1.2",
+ "is-symbol": "^1.0.3",
+ "isobject": "^4.0.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3"
+ }
+ },
+ "node_modules/telejson/node_modules/isobject": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
+ "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz",
+ "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.2",
+ "acorn": "^8.5.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/terser-webpack-plugin": {
+ "version": "5.3.6",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz",
+ "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.14",
+ "jest-worker": "^27.4.5",
+ "schema-utils": "^3.1.1",
+ "serialize-javascript": "^6.0.0",
+ "terser": "^5.14.1"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "uglify-js": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/jest-worker": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/through2": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
+ "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
+ "dev": true,
+ "dependencies": {
+ "readable-stream": "~2.3.6",
+ "xtend": "~4.0.1"
+ }
+ },
+ "node_modules/through2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/through2/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/through2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/timers-browserify": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
+ "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
+ "dev": true,
+ "dependencies": {
+ "setimmediate": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/tmpl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+ "dev": true
+ },
+ "node_modules/to-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==",
+ "dev": true
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/to-object-path": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
+ "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-object-path/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "node_modules/to-object-path/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-regex": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
+ "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==",
+ "dev": true,
+ "dependencies": {
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "regex-not": "^1.0.2",
+ "safe-regex": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "dev": true
+ },
+ "node_modules/trim": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
+ "integrity": "sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==",
+ "dev": true
+ },
+ "node_modules/trim-newlines": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
+ "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/trim-trailing-lines": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz",
+ "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
+ "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-dedent": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
+ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.10"
+ }
+ },
+ "node_modules/ts-pnp": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
+ "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
+ "dev": true
+ },
+ "node_modules/tty-browserify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==",
+ "dev": true
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "dev": true
+ },
+ "node_modules/typedarray-to-buffer": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+ "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+ "dev": true,
+ "dependencies": {
+ "is-typedarray": "^1.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
+ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+ "dev": true,
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=4.2.0"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.17.2",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.2.tgz",
+ "integrity": "sha512-bbxglRjsGQMchfvXZNusUcYgiB9Hx2K4AHYXQy2DITZ9Rd+JzhX7+hoocE5Winr7z2oHvPsekkBwXtigvxevXg==",
+ "dev": true,
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
+ "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.0.3",
+ "which-boxed-primitive": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/unfetch": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
+ "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
+ "dev": true
+ },
+ "node_modules/unherit": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz",
+ "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.0",
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+ "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unified": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz",
+ "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==",
+ "dev": true,
+ "dependencies": {
+ "bail": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-buffer": "^2.0.0",
+ "is-plain-obj": "^2.0.0",
+ "trough": "^1.0.0",
+ "vfile": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "dev": true,
+ "dependencies": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/union-value/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "dev": true,
+ "dependencies": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
+ "node_modules/unist-builder": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz",
+ "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-generated": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz",
+ "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz",
+ "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz",
+ "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz",
+ "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-is": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove-position": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz",
+ "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==",
+ "dev": true,
+ "dependencies": {
+ "unist-util-visit": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
+ "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz",
+ "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0",
+ "unist-util-visit-parents": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz",
+ "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/unset-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
+ "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==",
+ "dev": true,
+ "dependencies": {
+ "has-value": "^0.3.1",
+ "isobject": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/has-value": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz",
+ "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==",
+ "dev": true,
+ "dependencies": {
+ "get-value": "^2.0.3",
+ "has-values": "^0.1.4",
+ "isobject": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/has-value/node_modules/isobject": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+ "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==",
+ "dev": true,
+ "dependencies": {
+ "isarray": "1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/has-values": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz",
+ "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unset-value/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/untildify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/untildify/-/untildify-2.1.0.tgz",
+ "integrity": "sha512-sJjbDp2GodvkB0FZZcn7k6afVisqX5BZD7Yq3xp4nN2O15BBK0cLm3Vwn2vQaF7UDS0UUsrQMkkplmDI5fskig==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "os-homedir": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=4",
+ "yarn": "*"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz",
+ "integrity": "sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.1.1",
+ "picocolors": "^1.0.0"
+ },
+ "bin": {
+ "browserslist-lint": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/update-browserslist-db/node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/urix": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+ "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==",
+ "deprecated": "Please see https://github.com/lydell/urix#deprecated",
+ "dev": true
+ },
+ "node_modules/url": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+ "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "1.3.2",
+ "querystring": "0.2.0"
+ }
+ },
+ "node_modules/url-loader": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
+ "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "mime-types": "^2.1.27",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "file-loader": "*",
+ "webpack": "^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "file-loader": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/url-loader/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/url/node_modules/punycode": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+ "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
+ "dev": true
+ },
+ "node_modules/use": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/util": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
+ "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "2.0.3"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "node_modules/util.promisify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",
+ "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==",
+ "dev": true,
+ "dependencies": {
+ "define-properties": "^1.1.2",
+ "object.getownpropertydescriptors": "^2.0.3"
+ }
+ },
+ "node_modules/util/node_modules/inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "dev": true
+ },
+ "node_modules/utila": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
+ "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
+ "dev": true
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+ "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
+ "dev": true,
+ "bin": {
+ "uuid": "bin/uuid"
+ }
+ },
+ "node_modules/uuid-browser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/uuid-browser/-/uuid-browser-3.1.0.tgz",
+ "integrity": "sha512-dsNgbLaTrd6l3MMxTtouOCFw4CBFc/3a+GgYA2YyrJvyQ1u6q4pcu3ktLoUZ/VN/Aw9WsauazbgsgdfVWgAKQg==",
+ "dev": true
+ },
+ "node_modules/validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "dependencies": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz",
+ "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "is-buffer": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0",
+ "vfile-message": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-location": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz",
+ "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz",
+ "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vm-browserify": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
+ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
+ "dev": true
+ },
+ "node_modules/walker": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+ "dev": true,
+ "dependencies": {
+ "makeerror": "1.0.12"
+ }
+ },
+ "node_modules/watchpack": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+ "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
+ "dev": true,
+ "dependencies": {
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz",
+ "integrity": "sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "chokidar": "^2.1.8"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/anymatch/node_modules/normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "remove-trailing-separator": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/binary-extensions": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
+ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/braces/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/chokidar": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
+ "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==",
+ "deprecated": "Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x fewer dependencies",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "anymatch": "^2.0.0",
+ "async-each": "^1.0.1",
+ "braces": "^2.3.2",
+ "glob-parent": "^3.1.0",
+ "inherits": "^2.0.3",
+ "is-binary-path": "^1.0.0",
+ "is-glob": "^4.0.0",
+ "normalize-path": "^3.0.0",
+ "path-is-absolute": "^1.0.0",
+ "readdirp": "^2.2.1",
+ "upath": "^1.1.1"
+ },
+ "optionalDependencies": {
+ "fsevents": "^1.2.7"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/fill-range/node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/fsevents": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
+ "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
+ "deprecated": "fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2.",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ },
+ "engines": {
+ "node": ">= 4.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/glob-parent/node_modules/is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-extglob": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-binary-path": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz",
+ "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "binary-extensions": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/is-number/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/watchpack-chokidar2/node_modules/micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/readdirp": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz",
+ "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.11",
+ "micromatch": "^3.1.10",
+ "readable-stream": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/watchpack-chokidar2/node_modules/to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz",
+ "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "dev": true
+ },
+ "node_modules/webpack": {
+ "version": "5.74.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
+ "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
+ "dev": true,
+ "dependencies": {
+ "@types/eslint-scope": "^3.7.3",
+ "@types/estree": "^0.0.51",
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/wasm-edit": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "acorn": "^8.7.1",
+ "acorn-import-assertions": "^1.7.6",
+ "browserslist": "^4.14.5",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^5.10.0",
+ "es-module-lexer": "^0.9.0",
+ "eslint-scope": "5.1.1",
+ "events": "^3.2.0",
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.2.9",
+ "json-parse-even-better-errors": "^2.3.1",
+ "loader-runner": "^4.2.0",
+ "mime-types": "^2.1.27",
+ "neo-async": "^2.6.2",
+ "schema-utils": "^3.1.0",
+ "tapable": "^2.1.1",
+ "terser-webpack-plugin": "^5.1.3",
+ "watchpack": "^2.4.0",
+ "webpack-sources": "^3.2.3"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webpack-dev-middleware": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-4.3.0.tgz",
+ "integrity": "sha512-PjwyVY95/bhBh6VUqt6z4THplYcsvQ8YNNBTBM873xLVmw8FLeALn0qurHbs9EmcfhzQis/eoqypSnZeuUz26w==",
+ "dev": true,
+ "dependencies": {
+ "colorette": "^1.2.2",
+ "mem": "^8.1.1",
+ "memfs": "^3.2.2",
+ "mime-types": "^2.1.30",
+ "range-parser": "^1.2.1",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= v10.23.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/webpack-dev-middleware/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/webpack-hot-middleware": {
+ "version": "2.25.2",
+ "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.2.tgz",
+ "integrity": "sha512-CVgm3NAQyfdIonRvXisRwPTUYuSbyZ6BY7782tMeUzWOO7RmVI2NaBYuCp41qyD4gYCkJyTneAJdK69A13B0+A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-html-community": "0.0.8",
+ "html-entities": "^2.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "node_modules/webpack-log": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz",
+ "integrity": "sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-colors": "^3.0.0",
+ "uuid": "^3.3.2"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/webpack-sources": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
+ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.5.tgz",
+ "integrity": "sha512-8bWq0Iluiv9lVf9YaqWQ9+liNgXSHICm+rg544yRgGYaR8yXZTVBaHZkINZSB2yZSWo4b0F6MIxqJezVfOEAlg==",
+ "dev": true
+ },
+ "node_modules/webpack/node_modules/schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/webpack/node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dev": true,
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+ "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+ "dev": true,
+ "dependencies": {
+ "is-bigint": "^1.0.1",
+ "is-boolean-object": "^1.1.0",
+ "is-number-object": "^1.0.4",
+ "is-string": "^1.0.5",
+ "is-symbol": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true
+ },
+ "node_modules/worker-farm": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz",
+ "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==",
+ "dev": true,
+ "dependencies": {
+ "errno": "~0.1.7"
+ }
+ },
+ "node_modules/worker-rpc": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/worker-rpc/-/worker-rpc-0.1.1.tgz",
+ "integrity": "sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==",
+ "dev": true,
+ "dependencies": {
+ "microevent.ts": "~0.1.1"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/write-file-atomic": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+ "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "is-typedarray": "^1.0.0",
+ "signal-exit": "^3.0.2",
+ "typedarray-to-buffer": "^3.1.5"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz",
+ "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/x-default-browser": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/x-default-browser/-/x-default-browser-0.4.0.tgz",
+ "integrity": "sha512-7LKo7RtWfoFN/rHx1UELv/2zHGMx8MkZKDq1xENmOCTkfIqZJ0zZ26NEJX8czhnPXVcqS0ARjjfJB+eJ0/5Cvw==",
+ "dev": true,
+ "bin": {
+ "x-default-browser": "bin/x-default-browser.js"
+ },
+ "optionalDependencies": {
+ "default-browser-id": "^1.0.4"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "dev": true
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
+ "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ },
+ "dependencies": {
+ "@ampproject/remapping": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+ "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/gen-mapping": "^0.1.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ },
+ "@babel/code-frame": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+ "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+ "dev": true,
+ "requires": {
+ "@babel/highlight": "^7.18.6"
+ }
+ },
+ "@babel/compat-data": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz",
+ "integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==",
+ "dev": true
+ },
+ "@babel/core": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz",
+ "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==",
+ "dev": true,
+ "requires": {
+ "@ampproject/remapping": "^2.1.0",
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helpers": "^7.19.0",
+ "@babel/parser": "^7.19.3",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.1",
+ "semver": "^6.3.0"
+ }
+ },
+ "@babel/generator": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz",
+ "integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.19.3",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "jsesc": "^2.5.1"
+ },
+ "dependencies": {
+ "@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ }
+ }
+ },
+ "@babel/helper-annotate-as-pure": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+ "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-builder-binary-assignment-operator-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz",
+ "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-explode-assignable-expression": "^7.18.6",
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-compilation-targets": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz",
+ "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-validator-option": "^7.18.6",
+ "browserslist": "^4.21.3",
+ "semver": "^6.3.0"
+ }
+ },
+ "@babel/helper-create-class-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6"
+ }
+ },
+ "@babel/helper-create-regexp-features-plugin": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz",
+ "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "regexpu-core": "^5.1.0"
+ }
+ },
+ "@babel/helper-define-polyfill-provider": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz",
+ "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-compilation-targets": "^7.17.7",
+ "@babel/helper-plugin-utils": "^7.16.7",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ }
+ },
+ "@babel/helper-environment-visitor": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+ "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+ "dev": true
+ },
+ "@babel/helper-explode-assignable-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz",
+ "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-function-name": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+ "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+ "dev": true,
+ "requires": {
+ "@babel/template": "^7.18.10",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helper-hoist-variables": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+ "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-member-expression-to-functions": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz",
+ "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-module-imports": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+ "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-module-transforms": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz",
+ "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helper-optimise-call-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz",
+ "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-plugin-utils": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+ "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
+ "dev": true
+ },
+ "@babel/helper-remap-async-to-generator": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz",
+ "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-wrap-function": "^7.18.9",
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-replace-supers": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz",
+ "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-member-expression-to-functions": "^7.18.9",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/traverse": "^7.19.1",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helper-simple-access": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz",
+ "integrity": "sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz",
+ "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.9"
+ }
+ },
+ "@babel/helper-split-export-declaration": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+ "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+ "dev": true,
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-string-parser": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz",
+ "integrity": "sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==",
+ "dev": true
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+ "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+ "dev": true
+ },
+ "@babel/helper-validator-option": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+ "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+ "dev": true
+ },
+ "@babel/helper-wrap-function": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz",
+ "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/helpers": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.0.tgz",
+ "integrity": "sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==",
+ "dev": true,
+ "requires": {
+ "@babel/template": "^7.18.10",
+ "@babel/traverse": "^7.19.0",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/highlight": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+ "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "chalk": "^2.0.0",
+ "js-tokens": "^4.0.0"
+ }
+ },
+ "@babel/parser": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz",
+ "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==",
+ "dev": true
+ },
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz",
+ "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9"
+ }
+ },
+ "@babel/plugin-proposal-async-generator-functions": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz",
+ "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-remap-async-to-generator": "^7.18.9",
+ "@babel/plugin-syntax-async-generators": "^7.8.4"
+ }
+ },
+ "@babel/plugin-proposal-class-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+ "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-proposal-class-static-block": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz",
+ "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5"
+ }
+ },
+ "@babel/plugin-proposal-decorators": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.3.tgz",
+ "integrity": "sha512-MbgXtNXqo7RTKYIXVchVJGPvaVufQH3pxvQyfbGvNw1DObIhph+PesYXJTcd8J4DdWibvf6Z2eanOyItX8WnJg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.19.1",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/plugin-syntax-decorators": "^7.19.0"
+ }
+ },
+ "@babel/plugin-proposal-dynamic-import": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz",
+ "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-export-default-from": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.18.10.tgz",
+ "integrity": "sha512-5H2N3R2aQFxkV4PIBUR/i7PUSwgTZjouJKzI8eKswfIjT0PhvzkPn0t0wIS5zn6maQuvtT0t1oHtMUz61LOuow==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-default-from": "^7.18.6"
+ }
+ },
+ "@babel/plugin-proposal-export-namespace-from": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz",
+ "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-json-strings": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz",
+ "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-logical-assignment-operators": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz",
+ "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+ }
+ },
+ "@babel/plugin-proposal-nullish-coalescing-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz",
+ "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-numeric-separator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz",
+ "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+ }
+ },
+ "@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz",
+ "integrity": "sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.18.8",
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-transform-parameters": "^7.18.8"
+ }
+ },
+ "@babel/plugin-proposal-optional-catch-binding": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz",
+ "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-optional-chaining": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz",
+ "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+ }
+ },
+ "@babel/plugin-proposal-private-methods": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
+ "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz",
+ "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+ }
+ },
+ "@babel/plugin-proposal-unicode-property-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
+ "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ }
+ },
+ "@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ }
+ },
+ "@babel/plugin-syntax-decorators": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz",
+ "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.19.0"
+ }
+ },
+ "@babel/plugin-syntax-dynamic-import": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+ "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-export-default-from": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.18.6.tgz",
+ "integrity": "sha512-Kr//z3ujSVNx6E9z9ih5xXXMqK07VVTuqPmqGe6Mss/zW5XPeLZeSDZoP9ab/hT4wPKqAgjl2PnhPrcpk8Seew==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-export-namespace-from": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+ "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.3"
+ }
+ },
+ "@babel/plugin-syntax-import-assertions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz",
+ "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-jsx": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+ "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ }
+ },
+ "@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ }
+ },
+ "@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ }
+ },
+ "@babel/plugin-syntax-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz",
+ "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-arrow-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz",
+ "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-async-to-generator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz",
+ "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-remap-async-to-generator": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz",
+ "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-block-scoping": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz",
+ "integrity": "sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-classes": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz",
+ "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-compilation-targets": "^7.19.0",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-optimise-call-expression": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-replace-supers": "^7.18.9",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "globals": "^11.1.0"
+ }
+ },
+ "@babel/plugin-transform-computed-properties": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz",
+ "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-destructuring": {
+ "version": "7.18.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz",
+ "integrity": "sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-dotall-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz",
+ "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-duplicate-keys": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz",
+ "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz",
+ "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-for-of": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz",
+ "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-function-name": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz",
+ "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-compilation-targets": "^7.18.9",
+ "@babel/helper-function-name": "^7.18.9",
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz",
+ "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-member-expression-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz",
+ "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-modules-amd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz",
+ "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ }
+ },
+ "@babel/plugin-transform-modules-commonjs": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz",
+ "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-simple-access": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ }
+ },
+ "@babel/plugin-transform-modules-systemjs": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz",
+ "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-module-transforms": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-identifier": "^7.18.6",
+ "babel-plugin-dynamic-import-node": "^2.3.3"
+ }
+ },
+ "@babel/plugin-transform-modules-umd": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz",
+ "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-module-transforms": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz",
+ "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0"
+ }
+ },
+ "@babel/plugin-transform-new-target": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz",
+ "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-object-super": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz",
+ "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-replace-supers": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-parameters": {
+ "version": "7.18.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz",
+ "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-property-literals": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz",
+ "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-react-display-name": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz",
+ "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-react-jsx": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz",
+ "integrity": "sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-module-imports": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-jsx": "^7.18.6",
+ "@babel/types": "^7.19.0"
+ }
+ },
+ "@babel/plugin-transform-react-jsx-development": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz",
+ "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-transform-react-jsx": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-react-pure-annotations": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz",
+ "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-regenerator": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
+ "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "regenerator-transform": "^0.15.0"
+ }
+ },
+ "@babel/plugin-transform-reserved-words": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz",
+ "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-shorthand-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz",
+ "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-spread": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz",
+ "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-sticky-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz",
+ "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-template-literals": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz",
+ "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-typeof-symbol": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz",
+ "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-typescript": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz",
+ "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-class-features-plugin": "^7.19.0",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/plugin-syntax-typescript": "^7.18.6"
+ }
+ },
+ "@babel/plugin-transform-unicode-escapes": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz",
+ "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.9"
+ }
+ },
+ "@babel/plugin-transform-unicode-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz",
+ "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ }
+ },
+ "@babel/preset-env": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz",
+ "integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.19.3",
+ "@babel/helper-compilation-targets": "^7.19.3",
+ "@babel/helper-plugin-utils": "^7.19.0",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-async-generator-functions": "^7.19.1",
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
+ "@babel/plugin-proposal-class-static-block": "^7.18.6",
+ "@babel/plugin-proposal-dynamic-import": "^7.18.6",
+ "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
+ "@babel/plugin-proposal-json-strings": "^7.18.6",
+ "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+ "@babel/plugin-proposal-numeric-separator": "^7.18.6",
+ "@babel/plugin-proposal-object-rest-spread": "^7.18.9",
+ "@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
+ "@babel/plugin-proposal-optional-chaining": "^7.18.9",
+ "@babel/plugin-proposal-private-methods": "^7.18.6",
+ "@babel/plugin-proposal-private-property-in-object": "^7.18.6",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.18.6",
+ "@babel/plugin-syntax-async-generators": "^7.8.4",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+ "@babel/plugin-syntax-import-assertions": "^7.18.6",
+ "@babel/plugin-syntax-json-strings": "^7.8.3",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+ "@babel/plugin-syntax-top-level-await": "^7.14.5",
+ "@babel/plugin-transform-arrow-functions": "^7.18.6",
+ "@babel/plugin-transform-async-to-generator": "^7.18.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.18.6",
+ "@babel/plugin-transform-block-scoping": "^7.18.9",
+ "@babel/plugin-transform-classes": "^7.19.0",
+ "@babel/plugin-transform-computed-properties": "^7.18.9",
+ "@babel/plugin-transform-destructuring": "^7.18.13",
+ "@babel/plugin-transform-dotall-regex": "^7.18.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.18.9",
+ "@babel/plugin-transform-exponentiation-operator": "^7.18.6",
+ "@babel/plugin-transform-for-of": "^7.18.8",
+ "@babel/plugin-transform-function-name": "^7.18.9",
+ "@babel/plugin-transform-literals": "^7.18.9",
+ "@babel/plugin-transform-member-expression-literals": "^7.18.6",
+ "@babel/plugin-transform-modules-amd": "^7.18.6",
+ "@babel/plugin-transform-modules-commonjs": "^7.18.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.19.0",
+ "@babel/plugin-transform-modules-umd": "^7.18.6",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1",
+ "@babel/plugin-transform-new-target": "^7.18.6",
+ "@babel/plugin-transform-object-super": "^7.18.6",
+ "@babel/plugin-transform-parameters": "^7.18.8",
+ "@babel/plugin-transform-property-literals": "^7.18.6",
+ "@babel/plugin-transform-regenerator": "^7.18.6",
+ "@babel/plugin-transform-reserved-words": "^7.18.6",
+ "@babel/plugin-transform-shorthand-properties": "^7.18.6",
+ "@babel/plugin-transform-spread": "^7.19.0",
+ "@babel/plugin-transform-sticky-regex": "^7.18.6",
+ "@babel/plugin-transform-template-literals": "^7.18.9",
+ "@babel/plugin-transform-typeof-symbol": "^7.18.9",
+ "@babel/plugin-transform-unicode-escapes": "^7.18.10",
+ "@babel/plugin-transform-unicode-regex": "^7.18.6",
+ "@babel/preset-modules": "^0.1.5",
+ "@babel/types": "^7.19.3",
+ "babel-plugin-polyfill-corejs2": "^0.3.3",
+ "babel-plugin-polyfill-corejs3": "^0.6.0",
+ "babel-plugin-polyfill-regenerator": "^0.4.1",
+ "core-js-compat": "^3.25.1",
+ "semver": "^6.3.0"
+ }
+ },
+ "@babel/preset-modules": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz",
+ "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
+ "@babel/plugin-transform-dotall-regex": "^7.4.4",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ }
+ },
+ "@babel/preset-react": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz",
+ "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-react-display-name": "^7.18.6",
+ "@babel/plugin-transform-react-jsx": "^7.18.6",
+ "@babel/plugin-transform-react-jsx-development": "^7.18.6",
+ "@babel/plugin-transform-react-pure-annotations": "^7.18.6"
+ }
+ },
+ "@babel/preset-typescript": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
+ "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.18.6",
+ "@babel/helper-validator-option": "^7.18.6",
+ "@babel/plugin-transform-typescript": "^7.18.6"
+ }
+ },
+ "@babel/register": {
+ "version": "7.18.9",
+ "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.18.9.tgz",
+ "integrity": "sha512-ZlbnXDcNYHMR25ITwwNKT88JiaukkdVj/nG7r3wnuXkOTHc60Uy05PwMCPre0hSkY68E6zK3xz+vUJSP2jWmcw==",
+ "dev": true,
+ "requires": {
+ "clone-deep": "^4.0.1",
+ "find-cache-dir": "^2.0.0",
+ "make-dir": "^2.1.0",
+ "pirates": "^4.0.5",
+ "source-map-support": "^0.5.16"
+ }
+ },
+ "@babel/runtime": {
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
+ "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
+ "dev": true,
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "@babel/template": {
+ "version": "7.18.10",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
+ "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/parser": "^7.18.10",
+ "@babel/types": "^7.18.10"
+ }
+ },
+ "@babel/traverse": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz",
+ "integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.18.6",
+ "@babel/generator": "^7.19.3",
+ "@babel/helper-environment-visitor": "^7.18.9",
+ "@babel/helper-function-name": "^7.19.0",
+ "@babel/helper-hoist-variables": "^7.18.6",
+ "@babel/helper-split-export-declaration": "^7.18.6",
+ "@babel/parser": "^7.19.3",
+ "@babel/types": "^7.19.3",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ }
+ },
+ "@babel/types": {
+ "version": "7.19.3",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz",
+ "integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-string-parser": "^7.18.10",
+ "@babel/helper-validator-identifier": "^7.19.1",
+ "to-fast-properties": "^2.0.0"
+ }
+ },
+ "@cnakazawa/watch": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
+ "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==",
+ "dev": true,
+ "requires": {
+ "exec-sh": "^0.3.2",
+ "minimist": "^1.2.0"
+ }
+ },
+ "@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true
+ },
+ "@fluent/bundle": {
+ "version": "0.17.1",
+ "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz",
+ "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==",
+ "dev": true
+ },
+ "@fluent/dom": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@fluent/dom/-/dom-0.8.1.tgz",
+ "integrity": "sha512-wlQ3vHgioDL8dC0wcZ9AyCSpOgor0OREKXJMvvnx6bzk/PT2SZNA5frslmSdbEaiBQIVy2MhVvAIDtbKbdoVCg==",
+ "dev": true,
+ "requires": {
+ "cached-iterable": "^0.3"
+ }
+ },
+ "@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "dev": true
+ },
+ "@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ }
+ }
+ },
+ "@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true
+ },
+ "@jest/transform": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz",
+ "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.1.0",
+ "@jest/types": "^26.6.2",
+ "babel-plugin-istanbul": "^6.0.0",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^1.4.0",
+ "fast-json-stable-stringify": "^2.0.0",
+ "graceful-fs": "^4.2.4",
+ "jest-haste-map": "^26.6.2",
+ "jest-regex-util": "^26.0.0",
+ "jest-util": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "pirates": "^4.0.1",
+ "slash": "^3.0.0",
+ "source-map": "^0.6.1",
+ "write-file-atomic": "^3.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@jest/types": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
+ "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^15.0.0",
+ "chalk": "^4.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@jridgewell/gen-mapping": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+ "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/set-array": "^1.0.0",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "@jridgewell/resolve-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+ "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+ "dev": true
+ },
+ "@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "dev": true
+ },
+ "@jridgewell/source-map": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+ "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/gen-mapping": "^0.3.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "dependencies": {
+ "@jridgewell/gen-mapping": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+ "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ }
+ }
+ },
+ "@jridgewell/sourcemap-codec": {
+ "version": "1.4.14",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+ "dev": true
+ },
+ "@jridgewell/trace-mapping": {
+ "version": "0.3.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz",
+ "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "@lit/reactive-element": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.1.tgz",
+ "integrity": "sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==",
+ "dev": true
+ },
+ "@mdx-js/mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz",
+ "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "7.12.9",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@babel/plugin-syntax-object-rest-spread": "7.8.3",
+ "@mdx-js/util": "1.6.22",
+ "babel-plugin-apply-mdx-type-prop": "1.6.22",
+ "babel-plugin-extract-import-names": "1.6.22",
+ "camelcase-css": "2.0.1",
+ "detab": "2.0.4",
+ "hast-util-raw": "6.0.1",
+ "lodash.uniq": "4.5.0",
+ "mdast-util-to-hast": "10.0.1",
+ "remark-footnotes": "2.0.0",
+ "remark-mdx": "1.6.22",
+ "remark-parse": "8.0.3",
+ "remark-squeeze-paragraphs": "4.0.0",
+ "style-to-object": "0.3.0",
+ "unified": "9.2.0",
+ "unist-builder": "2.0.3",
+ "unist-util-visit": "2.0.3"
+ },
+ "dependencies": {
+ "@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ }
+ },
+ "@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true
+ }
+ }
+ },
+ "@mdx-js/util": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz",
+ "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==",
+ "dev": true
+ },
+ "@mrmlnc/readdir-enhanced": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
+ "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==",
+ "dev": true,
+ "requires": {
+ "call-me-maybe": "^1.0.1",
+ "glob-to-regexp": "^0.3.0"
+ },
+ "dependencies": {
+ "glob-to-regexp": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz",
+ "integrity": "sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==",
+ "dev": true
+ }
+ }
+ },
+ "@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ }
+ },
+ "@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true
+ },
+ "@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ }
+ },
+ "@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "dev": true,
+ "requires": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "dev": true,
+ "requires": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ }
+ },
+ "@storybook/addon-actions": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.5.12.tgz",
+ "integrity": "sha512-yEbyKjBsSRUr61SlS+SOTqQwdumO8Wa3GoHO3AfmvoKfzdGrM7w8G5Zs9Iev16khWg/7bQvoH3KZsg/hQuKnNg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "polished": "^4.2.2",
+ "prop-types": "^15.7.2",
+ "react-inspector": "^5.1.0",
+ "regenerator-runtime": "^0.13.7",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "uuid-browser": "^3.1.0"
+ },
+ "dependencies": {
+ "react-inspector": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz",
+ "integrity": "sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.0.0",
+ "is-dom": "^1.0.0",
+ "prop-types": "^15.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/addon-backgrounds": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-6.5.12.tgz",
+ "integrity": "sha512-S0QThY1jnU7Q+HY+g9JgpAJszzNmNkigZ4+X/4qlUXE0WYYn9i2YG5H6me1+57QmIXYddcWWqqgF9HUXl667NA==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/addon-controls": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.5.12.tgz",
+ "integrity": "sha512-UoaamkGgAQXplr0kixkPhROdzkY+ZJQpG7VFDU6kmZsIgPRNfX/QoJFR5vV6TpDArBIjWaUUqWII+GHgPRzLgQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "lodash": "^4.17.21",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-docs": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-6.5.12.tgz",
+ "integrity": "sha512-T+QTkmF7QlMVfXHXEberP8CYti/XMTo9oi6VEbZLx+a2N3qY4GZl7X2g26Sf5V4Za+xnapYKBMEIiJ5SvH9weQ==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@jest/transform": "^26.6.2",
+ "@mdx-js/react": "^1.6.22",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/postinstall": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/source-loader": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "babel-loader": "^8.0.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7",
+ "remark-external-links": "^8.0.0",
+ "remark-slug": "^6.0.0",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "dependencies": {
+ "@mdx-js/react": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz",
+ "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==",
+ "dev": true,
+ "requires": {}
+ }
+ }
+ },
+ "@storybook/addon-essentials": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-6.5.12.tgz",
+ "integrity": "sha512-4AAV0/mQPSk3V0Pie1NIqqgBgScUc0VtBEXDm8BgPeuDNVhPEupnaZgVt+I3GkzzPPo6JjdCsp2L11f3bBSEjw==",
+ "dev": true,
+ "requires": {
+ "@storybook/addon-actions": "6.5.12",
+ "@storybook/addon-backgrounds": "6.5.12",
+ "@storybook/addon-controls": "6.5.12",
+ "@storybook/addon-docs": "6.5.12",
+ "@storybook/addon-measure": "6.5.12",
+ "@storybook/addon-outline": "6.5.12",
+ "@storybook/addon-toolbars": "6.5.12",
+ "@storybook/addon-viewport": "6.5.12",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-links": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.5.12.tgz",
+ "integrity": "sha512-Dyt922J5nTBwM/9KtuuDIt3sX8xdTkKh+aXSoOX6OzT04Xwm5NumFOvuQ2YA00EM+3Ihn7Ayc3urvxnHTixmKg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "prop-types": "^15.7.2",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-measure": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-6.5.12.tgz",
+ "integrity": "sha512-zmolO6+VG4ov2620G7f1myqLQLztfU+ykN+U5y52GXMFsCOyB7fMoVWIMrZwsNlinDu+CnUvelXHUNbqqnjPRg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ }
+ },
+ "@storybook/addon-outline": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-6.5.12.tgz",
+ "integrity": "sha512-jXwLz2rF/CZt6Cgy+QUTa+pNW0IevSONYwS3D533E9z5h0T5ZKJbbxG5jxM+oC+FpZ/nFk5mEmUaYNkxgIVdpw==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/addon-toolbars": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-6.5.12.tgz",
+ "integrity": "sha512-+QjoEHkekz4wTy8zqxYdV9ijDJ5YcjDc/qdnV8wx22zkoVU93FQlo0CHHVjpyvc3ilQliZbdQDJx62BcHXw30Q==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/addon-viewport": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-6.5.12.tgz",
+ "integrity": "sha512-eQ1UrmbiMiPmWe+fdMWIc0F6brh/S2z4ADfwFz0tTd+vOLWRZp1xw8JYQ9P2ZasE+PM3WFOVT9jvNjZj/cHnfw==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "memoizerific": "^1.11.3",
+ "prop-types": "^15.7.2",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/addons": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.12.tgz",
+ "integrity": "sha512-y3cgxZq41YGnuIlBJEuJjSFdMsm8wnvlNOGUP9Q+Er2dgfx8rJz4Q22o4hPjpvpaj4XdBtxCJXI2NeFpN59+Cw==",
+ "dev": true,
+ "requires": {
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.12.tgz",
+ "integrity": "sha512-DuUZmMlQxkFNU9Vgkp9aNfCkAongU76VVmygvCuSpMVDI9HQ2lG0ydL+ppL4XKoSMCCoXTY6+rg4hJANnH+1AQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/builder-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.5.12.tgz",
+ "integrity": "sha512-TsthT5jm9ZxQPNOZJbF5AV24me3i+jjYD7gbdKdSHrOVn1r3ydX4Z8aD6+BjLCtTn3T+e8NMvUkL4dInEo1x6g==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "autoprefixer": "^9.8.6",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^4.1.6",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "global": "^4.4.0",
+ "html-webpack-plugin": "^4.0.0",
+ "pnp-webpack-plugin": "1.6.4",
+ "postcss": "^7.0.36",
+ "postcss-flexbugs-fixes": "^4.2.1",
+ "postcss-loader": "^4.2.0",
+ "raw-loader": "^4.0.2",
+ "stable": "^0.1.8",
+ "style-loader": "^1.3.0",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-filter-warnings-plugin": "^1.2.1",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "dependencies": {
+ "@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "requires": {
+ "source-map": "~0.6.0"
+ }
+ },
+ "commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true
+ },
+ "css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ }
+ },
+ "fork-ts-checker-webpack-plugin": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz",
+ "integrity": "sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.5.5",
+ "chalk": "^2.4.1",
+ "micromatch": "^3.1.10",
+ "minimatch": "^3.0.4",
+ "semver": "^5.6.0",
+ "tapable": "^1.0.0",
+ "worker-rpc": "^0.1.0"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "requires": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "dependencies": {
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ }
+ }
+ },
+ "html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "requires": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ },
+ "dependencies": {
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ }
+ }
+ },
+ "pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ }
+ }
+ },
+ "pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "requires": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "requires": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "dependencies": {
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ }
+ },
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "requires": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ }
+ }
+ },
+ "webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "requires": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ }
+ },
+ "webpack-filter-warnings-plugin": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz",
+ "integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==",
+ "dev": true,
+ "requires": {}
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "requires": {
+ "debug": "^3.0.0"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/builder-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-6.5.12.tgz",
+ "integrity": "sha512-jK5jWxhSbMAM/onPB6WN7xVqwZnAmzJljOG24InO/YIjW8pQof7MeAXCYBM4rYM+BbK61gkZ/RKxwlkqXBWv+Q==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-named-exports-order": "^0.0.2",
+ "browser-assert": "^1.2.1",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "glob": "^7.1.6",
+ "glob-promise": "^3.4.0",
+ "html-webpack-plugin": "^5.0.0",
+ "path-browserify": "^1.0.1",
+ "process": "^0.11.10",
+ "stable": "^0.1.8",
+ "style-loader": "^2.0.0",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-hot-middleware": "^2.25.1",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/channel-postmessage": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.5.12.tgz",
+ "integrity": "sha512-SL/tJBLOdDlbUAAxhiZWOEYd5HI4y8rN50r6jeed5nD8PlocZjxJ6mO0IxnePqIL9Yu3nSrQRHrtp8AJvPX0Yg==",
+ "dev": true,
+ "requires": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "qs": "^6.10.0",
+ "telejson": "^6.0.8"
+ }
+ },
+ "@storybook/channel-websocket": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channel-websocket/-/channel-websocket-6.5.12.tgz",
+ "integrity": "sha512-0t5dLselHVKTRYaphxx1dRh4pmOFCfR7h8oNJlOvJ29Qy5eNyVujDG9nhwWbqU6IKayuP4nZrAbe9Req9YZYlQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "telejson": "^6.0.8"
+ }
+ },
+ "@storybook/channels": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.12.tgz",
+ "integrity": "sha512-X5XaKbe4b7LXJ4sUakBo00x6pXnW78JkOonHoaKoWsccHLlEzwfBZpVVekhVZnqtCoLT23dB8wjKgA71RYWoiw==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/client-api": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.5.12.tgz",
+ "integrity": "sha512-+JiRSgiU829KPc25nG/k0+Ao2nUelHUe8Y/9cRoKWbCAGzi4xd0JLhHAOr9Oi2szWx/OI1L08lxVv1+WTveAeA==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "@types/qs": "^6.9.5",
+ "@types/webpack-env": "^1.16.0",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "store2": "^2.12.0",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/client-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.12.tgz",
+ "integrity": "sha512-IrkMr5KZcudX935/C2balFbxLHhkvQnJ78rbVThHDVckQ7l3oIXTh66IMzldeOabVFDZEMiW8AWuGEYof+JtLw==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2",
+ "global": "^4.4.0"
+ }
+ },
+ "@storybook/components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.12.tgz",
+ "integrity": "sha512-NAAGl5PDXaHdVLd6hA+ttmLwH3zAVGXeUmEubzKZ9bJzb+duhFKxDa9blM4YEkI+palumvgAMm0UgS7ou680Ig==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/core": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core/-/core-6.5.12.tgz",
+ "integrity": "sha512-+o3psAVWL+5LSwyJmEbvhgxKO1Et5uOX8ujNVt/f1fgwJBIf6BypxyPKu9YGQDRzcRssESQQZWNrZCCAZlFeuQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-server": "6.5.12"
+ }
+ },
+ "@storybook/core-client": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.5.12.tgz",
+ "integrity": "sha512-jyAd0ud6zO+flpLv0lEHbbt1Bv9Ms225M6WTQLrfe7kN/7j1pVKZEoeVCLZwkJUtSKcNiWQxZbS15h31pcYwqg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/channel-websocket": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "airbnb-js-shims": "^2.2.1",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/core-common": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.5.12.tgz",
+ "integrity": "sha512-gG20+eYdIhwQNu6Xs805FLrOCWtkoc8Rt8gJiRt8yXzZh9EZkU4xgCRoCxrrJ03ys/gTiCFbBOfRi749uM3z4w==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-proposal-class-properties": "^7.12.1",
+ "@babel/plugin-proposal-decorators": "^7.12.12",
+ "@babel/plugin-proposal-export-default-from": "^7.12.1",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
+ "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
+ "@babel/plugin-proposal-optional-chaining": "^7.12.7",
+ "@babel/plugin-proposal-private-methods": "^7.12.1",
+ "@babel/plugin-proposal-private-property-in-object": "^7.12.1",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-transform-arrow-functions": "^7.12.1",
+ "@babel/plugin-transform-block-scoping": "^7.12.12",
+ "@babel/plugin-transform-classes": "^7.12.1",
+ "@babel/plugin-transform-destructuring": "^7.12.1",
+ "@babel/plugin-transform-for-of": "^7.12.1",
+ "@babel/plugin-transform-parameters": "^7.12.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.12.1",
+ "@babel/plugin-transform-spread": "^7.12.1",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/preset-react": "^7.12.10",
+ "@babel/preset-typescript": "^7.12.7",
+ "@babel/register": "^7.12.1",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/pretty-hrtime": "^1.0.0",
+ "babel-loader": "^8.0.0",
+ "babel-plugin-macros": "^3.0.1",
+ "babel-plugin-polyfill-corejs3": "^0.1.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "express": "^4.17.1",
+ "file-system-cache": "^1.0.5",
+ "find-up": "^5.0.0",
+ "fork-ts-checker-webpack-plugin": "^6.0.4",
+ "fs-extra": "^9.0.1",
+ "glob": "^7.1.6",
+ "handlebars": "^4.7.7",
+ "interpret": "^2.2.0",
+ "json5": "^2.1.3",
+ "lazy-universal-dotenv": "^3.0.1",
+ "picomatch": "^2.3.0",
+ "pkg-dir": "^5.0.0",
+ "pretty-hrtime": "^1.0.3",
+ "resolve-from": "^5.0.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4"
+ },
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz",
+ "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-compilation-targets": "^7.13.0",
+ "@babel/helper-module-imports": "^7.12.13",
+ "@babel/helper-plugin-utils": "^7.13.0",
+ "@babel/traverse": "^7.13.0",
+ "debug": "^4.1.1",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.14.2",
+ "semver": "^6.1.2"
+ }
+ },
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "babel-plugin-polyfill-corejs3": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz",
+ "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-define-polyfill-provider": "^0.1.5",
+ "core-js-compat": "^3.8.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "dependencies": {
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ }
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ }
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/core-events": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.12.tgz",
+ "integrity": "sha512-0AMyMM19R/lHsYRfWqM8zZTXthasTAK2ExkSRzYi2GkIaVMxRKtM33YRwxKIpJ6KmIKIs8Ru3QCXu1mfCmGzNg==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2"
+ }
+ },
+ "@storybook/core-server": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.5.12.tgz",
+ "integrity": "sha512-q1b/XKwoLUcCoCQ+8ndPD5THkEwXZYJ9ROv16i2VGUjjjAuSqpEYBq5GMGQUgxlWp1bkxtdGL2Jz+6pZfvldzA==",
+ "dev": true,
+ "requires": {
+ "@discoveryjs/json-ext": "^0.5.3",
+ "@storybook/builder-webpack4": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/csf-tools": "6.5.12",
+ "@storybook/manager-webpack4": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/store": "6.5.12",
+ "@storybook/telemetry": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/node-fetch": "^2.5.7",
+ "@types/pretty-hrtime": "^1.0.0",
+ "@types/webpack": "^4.41.26",
+ "better-opn": "^2.1.1",
+ "boxen": "^5.1.2",
+ "chalk": "^4.1.0",
+ "cli-table3": "^0.6.1",
+ "commander": "^6.2.1",
+ "compression": "^1.7.4",
+ "core-js": "^3.8.2",
+ "cpy": "^8.1.2",
+ "detect-port": "^1.3.0",
+ "express": "^4.17.1",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "globby": "^11.0.2",
+ "ip": "^2.0.0",
+ "lodash": "^4.17.21",
+ "node-fetch": "^2.6.7",
+ "open": "^8.4.0",
+ "pretty-hrtime": "^1.0.3",
+ "prompts": "^2.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "serve-favicon": "^2.5.0",
+ "slash": "^3.0.0",
+ "telejson": "^6.0.8",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "watchpack": "^2.2.0",
+ "webpack": "4",
+ "ws": "^8.2.3",
+ "x-default-browser": "^0.4.0"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "dependencies": {
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ }
+ }
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/csf": {
+ "version": "0.0.2--canary.4566f4d.1",
+ "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz",
+ "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.15"
+ }
+ },
+ "@storybook/csf-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.5.12.tgz",
+ "integrity": "sha512-BPhnB1xJtBVOzXuCURzQRdXcstE27ht4qoTgQkbwUTy4MEtUZ/f1AnHSYRdzrgukXdUFWseNIK4RkNdJpfOfNQ==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/plugin-transform-react-jsx": "^7.12.12",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/traverse": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/mdx1-csf": "^0.0.1",
+ "core-js": "^3.8.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/docs-tools": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/docs-tools/-/docs-tools-6.5.12.tgz",
+ "integrity": "sha512-8brf8W89KVk95flVqW0sYEqkL+FBwb5W9CnwI+Ggd6r2cqXe9jyg+0vDZFdYp6kYNQKrPr4fbXGrGVXQG18/QQ==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "core-js": "^3.8.2",
+ "doctrine": "^3.0.0",
+ "lodash": "^4.17.21",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/manager-webpack4": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.5.12.tgz",
+ "integrity": "sha512-LH3e6qfvq2znEdxe2kaWtmdDPTnvSkufzoC9iwOgNvo3YrTGrYNyUTDegvW293TOTVfUn7j6TBcsOxIgRnt28g==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "@types/webpack": "^4.41.26",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^3.6.0",
+ "express": "^4.17.1",
+ "file-loader": "^6.2.0",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^4.0.0",
+ "node-fetch": "^2.6.7",
+ "pnp-webpack-plugin": "1.6.4",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^1.3.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^4.2.3",
+ "ts-dedent": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "util-deprecate": "^1.0.2",
+ "webpack": "4",
+ "webpack-dev-middleware": "^3.7.3",
+ "webpack-virtual-modules": "^0.2.2"
+ },
+ "dependencies": {
+ "@types/html-minifier-terser": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
+ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
+ "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
+ "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
+ "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
+ "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
+ "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
+ "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/helper-wasm-section": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-opt": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "@webassemblyjs/wast-printer": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
+ "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
+ "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-buffer": "1.9.0",
+ "@webassemblyjs/wasm-gen": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
+ "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/ieee754": "1.9.0",
+ "@webassemblyjs/leb128": "1.9.0",
+ "@webassemblyjs/utf8": "1.9.0"
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "acorn": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
+ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true
+ },
+ "clean-css": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+ "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+ "dev": true,
+ "requires": {
+ "source-map": "~0.6.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true
+ },
+ "css-loader": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
+ "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^5.3.1",
+ "cssesc": "^3.0.0",
+ "icss-utils": "^4.1.1",
+ "loader-utils": "^1.2.3",
+ "normalize-path": "^3.0.0",
+ "postcss": "^7.0.32",
+ "postcss-modules-extract-imports": "^2.0.0",
+ "postcss-modules-local-by-default": "^3.0.2",
+ "postcss-modules-scope": "^2.2.0",
+ "postcss-modules-values": "^3.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^2.7.0",
+ "semver": "^6.3.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "enhanced-resolve": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
+ "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.5.0",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "memory-fs": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
+ "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
+ "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "html-minifier-terser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+ "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
+ "dev": true,
+ "requires": {
+ "camel-case": "^4.1.1",
+ "clean-css": "^4.2.3",
+ "commander": "^4.1.1",
+ "he": "^1.2.0",
+ "param-case": "^3.0.3",
+ "relateurl": "^0.2.7",
+ "terser": "^4.6.3"
+ },
+ "dependencies": {
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ }
+ }
+ },
+ "html-webpack-plugin": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz",
+ "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==",
+ "dev": true,
+ "requires": {
+ "@types/html-minifier-terser": "^5.0.0",
+ "@types/tapable": "^1.0.5",
+ "@types/webpack": "^4.41.8",
+ "html-minifier-terser": "^5.0.1",
+ "loader-utils": "^1.2.3",
+ "lodash": "^4.17.20",
+ "pretty-error": "^2.1.1",
+ "tapable": "^1.1.3",
+ "util.promisify": "1.0.0"
+ },
+ "dependencies": {
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-wsl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
+ "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "loader-runner": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
+ "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
+ "dev": true
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ },
+ "dependencies": {
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ }
+ }
+ },
+ "pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ }
+ }
+ },
+ "pretty-error": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz",
+ "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.20",
+ "renderkid": "^2.0.4"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "renderkid": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz",
+ "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==",
+ "dev": true,
+ "requires": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^3.0.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "serialize-javascript": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
+ "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "ssri": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
+ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
+ "dev": true,
+ "requires": {
+ "figgy-pudding": "^3.5.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "style-loader": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz",
+ "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz",
+ "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==",
+ "dev": true,
+ "requires": {
+ "cacache": "^15.0.5",
+ "find-cache-dir": "^3.3.1",
+ "jest-worker": "^26.5.0",
+ "p-limit": "^3.0.2",
+ "schema-utils": "^3.0.0",
+ "serialize-javascript": "^5.0.1",
+ "source-map": "^0.6.1",
+ "terser": "^5.3.4",
+ "webpack-sources": "^1.4.3"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "watchpack": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
+ "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.4.1",
+ "graceful-fs": "^4.1.2",
+ "neo-async": "^2.5.0",
+ "watchpack-chokidar2": "^2.0.1"
+ }
+ },
+ "webpack": {
+ "version": "4.46.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz",
+ "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/wasm-edit": "1.9.0",
+ "@webassemblyjs/wasm-parser": "1.9.0",
+ "acorn": "^6.4.1",
+ "ajv": "^6.10.2",
+ "ajv-keywords": "^3.4.1",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^4.5.0",
+ "eslint-scope": "^4.0.3",
+ "json-parse-better-errors": "^1.0.2",
+ "loader-runner": "^2.4.0",
+ "loader-utils": "^1.2.3",
+ "memory-fs": "^0.4.1",
+ "micromatch": "^3.1.10",
+ "mkdirp": "^0.5.3",
+ "neo-async": "^2.6.1",
+ "node-libs-browser": "^2.2.1",
+ "schema-utils": "^1.0.0",
+ "tapable": "^1.1.3",
+ "terser-webpack-plugin": "^1.4.3",
+ "watchpack": "^1.7.4",
+ "webpack-sources": "^1.4.1"
+ },
+ "dependencies": {
+ "cacache": {
+ "version": "12.0.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
+ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.5.5",
+ "chownr": "^1.1.1",
+ "figgy-pudding": "^3.5.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.1.15",
+ "infer-owner": "^1.0.3",
+ "lru-cache": "^5.1.1",
+ "mississippi": "^3.0.0",
+ "mkdirp": "^0.5.1",
+ "move-concurrently": "^1.0.1",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^2.6.3",
+ "ssri": "^6.0.1",
+ "unique-filename": "^1.1.1",
+ "y18n": "^4.0.0"
+ }
+ },
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ }
+ },
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "requires": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
+ "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.1.0",
+ "ajv-errors": "^1.0.0",
+ "ajv-keywords": "^3.1.0"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "terser": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+ "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.20.0",
+ "source-map": "~0.6.1",
+ "source-map-support": "~0.5.12"
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
+ "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
+ "dev": true,
+ "requires": {
+ "cacache": "^12.0.2",
+ "find-cache-dir": "^2.1.0",
+ "is-wsl": "^1.1.0",
+ "schema-utils": "^1.0.0",
+ "serialize-javascript": "^4.0.0",
+ "source-map": "^0.6.1",
+ "terser": "^4.1.2",
+ "webpack-sources": "^1.4.0",
+ "worker-farm": "^1.7.0"
+ }
+ }
+ }
+ },
+ "webpack-dev-middleware": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz",
+ "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==",
+ "dev": true,
+ "requires": {
+ "memory-fs": "^0.4.1",
+ "mime": "^2.4.4",
+ "mkdirp": "^0.5.1",
+ "range-parser": "^1.2.1",
+ "webpack-log": "^2.0.0"
+ }
+ },
+ "webpack-sources": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
+ "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
+ "dev": true,
+ "requires": {
+ "source-list-map": "^2.0.0",
+ "source-map": "~0.6.1"
+ }
+ },
+ "webpack-virtual-modules": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz",
+ "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==",
+ "dev": true,
+ "requires": {
+ "debug": "^3.0.0"
+ }
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+ },
+ "@storybook/manager-webpack5": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-webpack5/-/manager-webpack5-6.5.12.tgz",
+ "integrity": "sha512-F+KgoINhfo1ArbirCc9L+EyADYD8Z4t0LyZYDVcBiZ8DlRIMIoUSye6tDsnyEm+OPloLVAcGwRMYgFhuHB70Lg==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-transform-template-literals": "^7.12.1",
+ "@babel/preset-react": "^7.12.10",
+ "@storybook/addons": "6.5.12",
+ "@storybook/core-client": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/node-logger": "6.5.12",
+ "@storybook/theming": "6.5.12",
+ "@storybook/ui": "6.5.12",
+ "@types/node": "^14.0.10 || ^16.0.0",
+ "babel-loader": "^8.0.0",
+ "case-sensitive-paths-webpack-plugin": "^2.3.0",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "css-loader": "^5.0.1",
+ "express": "^4.17.1",
+ "find-up": "^5.0.0",
+ "fs-extra": "^9.0.1",
+ "html-webpack-plugin": "^5.0.0",
+ "node-fetch": "^2.6.7",
+ "process": "^0.11.10",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0",
+ "style-loader": "^2.0.0",
+ "telejson": "^6.0.8",
+ "terser-webpack-plugin": "^5.0.3",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2",
+ "webpack": "^5.9.0",
+ "webpack-dev-middleware": "^4.1.0",
+ "webpack-virtual-modules": "^0.4.1"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/mdx1-csf": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@storybook/mdx1-csf/-/mdx1-csf-0.0.1.tgz",
+ "integrity": "sha512-4biZIWWzoWlCarMZmTpqcJNgo/RBesYZwGFbQeXiGYsswuvfWARZnW9RE9aUEMZ4XPn7B1N3EKkWcdcWe/K2tg==",
+ "dev": true,
+ "requires": {
+ "@babel/generator": "^7.12.11",
+ "@babel/parser": "^7.12.11",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/types": "^7.12.11",
+ "@mdx-js/mdx": "^1.6.22",
+ "@types/lodash": "^4.14.167",
+ "js-string-escape": "^1.0.1",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "ts-dedent": "^2.0.0"
+ }
+ },
+ "@storybook/node-logger": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.5.12.tgz",
+ "integrity": "sha512-jdLtT3mX5GQKa+0LuX0q0sprKxtCGf6HdXlKZGD5FEuz4MgJUGaaiN0Hgi+U7Z4tVNOtSoIbYBYXHqfUgJrVZw==",
+ "dev": true,
+ "requires": {
+ "@types/npmlog": "^4.1.2",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "npmlog": "^5.0.1",
+ "pretty-hrtime": "^1.0.3"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/postinstall": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/postinstall/-/postinstall-6.5.12.tgz",
+ "integrity": "sha512-6K73f9c2UO+w4Wtyo2BxEpEsnhPvMgqHSaJ9Yt6Tc90LaDGUbcVgy6PNibsRyuJ/KQ543WeiRO5rSZfm2uJU9A==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.8.2"
+ }
+ },
+ "@storybook/preview-web": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/preview-web/-/preview-web-6.5.12.tgz",
+ "integrity": "sha512-Q5mduCJsY9zhmlsrhHvtOBA3Jt2n45bhfVkiUEqtj8fDit45/GW+eLoffv8GaVTGjV96/Y1JFwDZUwU6mEfgGQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/channel-postmessage": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/store": "6.5.12",
+ "ansi-to-html": "^0.6.11",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "unfetch": "^4.2.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/router": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.12.tgz",
+ "integrity": "sha512-xHubde9YnBbpkDY5+zGO4Pr6VPxP8H9J2v4OTF3H82uaxCIKR0PKG0utS9pFKIsEiP3aM62Hb9qB8nU+v1nj3w==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/semver": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz",
+ "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==",
+ "dev": true,
+ "requires": {
+ "core-js": "^3.6.5",
+ "find-up": "^4.1.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ }
+ }
+ },
+ "@storybook/source-loader": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.5.12.tgz",
+ "integrity": "sha512-4iuILFsKNV70sEyjzIkOqgzgQx7CJ8kTEFz590vkmWXQNKz7YQzjgISIwL7GBw/myJgeb04bl5psVgY0cbG5vg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "estraverse": "^5.2.0",
+ "global": "^4.4.0",
+ "loader-utils": "^2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": ">=2.2.1 <=2.3.0",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/store": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/store/-/store-6.5.12.tgz",
+ "integrity": "sha512-SMQOr0XvV0mhTuqj3XOwGGc4kTPVjh3xqrG1fqkj9RGs+2jRdmO6mnwzda5gPwUmWNTorZ7FxZ1iEoyfYNtuiQ==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "core-js": "^3.8.2",
+ "fast-deep-equal": "^3.1.3",
+ "global": "^4.4.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7",
+ "slash": "^3.0.0",
+ "stable": "^0.1.8",
+ "synchronous-promise": "^2.0.15",
+ "ts-dedent": "^2.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "@storybook/telemetry": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-6.5.12.tgz",
+ "integrity": "sha512-mCHxx7NmQ3n7gx0nmblNlZE5ZgrjQm6B08mYeWg6Y7r4GZnqS6wZbvAwVhZZ3Gg/9fdqaBApHsdAXp0d5BrlxA==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "chalk": "^4.1.0",
+ "core-js": "^3.8.2",
+ "detect-package-manager": "^2.0.1",
+ "fetch-retry": "^5.0.2",
+ "fs-extra": "^9.0.1",
+ "global": "^4.4.0",
+ "isomorphic-unfetch": "^3.1.0",
+ "nanoid": "^3.3.1",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@storybook/theming": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.12.tgz",
+ "integrity": "sha512-uWOo84qMQ2R6c1C0faZ4Q0nY01uNaX7nXoJKieoiJ6ZqY9PSYxJl1kZLi3uPYnrxLZjzjVyXX8MgdxzbppYItA==",
+ "dev": true,
+ "requires": {
+ "@storybook/client-logger": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "regenerator-runtime": "^0.13.7"
+ }
+ },
+ "@storybook/ui": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/ui/-/ui-6.5.12.tgz",
+ "integrity": "sha512-P7+ARI5NvaEYkrbIciT/UMgy3kxMt4WCtHMXss2T01UMCIWh1Ws4BJaDNqtQSpKuwjjS4eqZL3aQWhlUpYAUEg==",
+ "dev": true,
+ "requires": {
+ "@storybook/addons": "6.5.12",
+ "@storybook/api": "6.5.12",
+ "@storybook/channels": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/components": "6.5.12",
+ "@storybook/core-events": "6.5.12",
+ "@storybook/router": "6.5.12",
+ "@storybook/semver": "^7.3.2",
+ "@storybook/theming": "6.5.12",
+ "core-js": "^3.8.2",
+ "memoizerific": "^1.11.3",
+ "qs": "^6.10.0",
+ "regenerator-runtime": "^0.13.7",
+ "resolve-from": "^5.0.0"
+ }
+ },
+ "@storybook/web-components": {
+ "version": "6.5.12",
+ "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-6.5.12.tgz",
+ "integrity": "sha512-SkaLdaCYNXiKKtXoDcZ7sDvONkIB/NNfe39Kkijm/sotymi+7iDzbywwyAZY6tFxSy9DUkVZWQgexpmFpogWkw==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/preset-env": "^7.12.11",
+ "@storybook/addons": "6.5.12",
+ "@storybook/client-api": "6.5.12",
+ "@storybook/client-logger": "6.5.12",
+ "@storybook/core": "6.5.12",
+ "@storybook/core-common": "6.5.12",
+ "@storybook/csf": "0.0.2--canary.4566f4d.1",
+ "@storybook/docs-tools": "6.5.12",
+ "@storybook/preview-web": "6.5.12",
+ "@storybook/store": "6.5.12",
+ "@types/node": "^14.14.20 || ^16.0.0",
+ "@types/webpack-env": "^1.16.0",
+ "babel-plugin-bundled-import-meta": "^0.3.1",
+ "core-js": "^3.8.2",
+ "global": "^4.4.0",
+ "react": "16.14.0",
+ "react-dom": "16.14.0",
+ "read-pkg-up": "^7.0.1",
+ "regenerator-runtime": "^0.13.7",
+ "ts-dedent": "^2.0.0"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.11.64",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.64.tgz",
+ "integrity": "sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q==",
+ "dev": true
+ },
+ "react": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
+ "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2"
+ }
+ },
+ "react-dom": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
+ "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2",
+ "scheduler": "^0.19.1"
+ }
+ },
+ "scheduler": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
+ "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1"
+ }
+ }
+ }
+ },
+ "@types/eslint": {
+ "version": "8.4.6",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz",
+ "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==",
+ "dev": true,
+ "requires": {
+ "@types/estree": "*",
+ "@types/json-schema": "*"
+ }
+ },
+ "@types/eslint-scope": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz",
+ "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==",
+ "dev": true,
+ "requires": {
+ "@types/eslint": "*",
+ "@types/estree": "*"
+ }
+ },
+ "@types/estree": {
+ "version": "0.0.51",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
+ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
+ "dev": true
+ },
+ "@types/glob": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.0.0.tgz",
+ "integrity": "sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==",
+ "dev": true,
+ "requires": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "@types/graceful-fs": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
+ "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
+ "@types/hast": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz",
+ "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "*"
+ }
+ },
+ "@types/html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==",
+ "dev": true
+ },
+ "@types/is-function": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.1.tgz",
+ "integrity": "sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==",
+ "dev": true
+ },
+ "@types/istanbul-lib-coverage": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+ "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+ "dev": true
+ },
+ "@types/istanbul-lib-report": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+ "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "@types/istanbul-reports": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
+ "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "@types/json-schema": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
+ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
+ "dev": true
+ },
+ "@types/lodash": {
+ "version": "4.14.186",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz",
+ "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==",
+ "dev": true
+ },
+ "@types/mdast": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
+ "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "*"
+ }
+ },
+ "@types/minimatch": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
+ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "18.8.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.1.tgz",
+ "integrity": "sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ==",
+ "dev": true
+ },
+ "@types/node-fetch": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz",
+ "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "form-data": "^3.0.0"
+ }
+ },
+ "@types/normalize-package-data": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
+ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
+ "dev": true
+ },
+ "@types/npmlog": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-4.1.4.tgz",
+ "integrity": "sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==",
+ "dev": true
+ },
+ "@types/parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
+ "dev": true
+ },
+ "@types/parse5": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz",
+ "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==",
+ "dev": true
+ },
+ "@types/pretty-hrtime": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz",
+ "integrity": "sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==",
+ "dev": true
+ },
+ "@types/qs": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
+ "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
+ "dev": true
+ },
+ "@types/source-list-map": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
+ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==",
+ "dev": true
+ },
+ "@types/tapable": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
+ "integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==",
+ "dev": true
+ },
+ "@types/trusted-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
+ "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==",
+ "dev": true
+ },
+ "@types/uglify-js": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.0.tgz",
+ "integrity": "sha512-3HO6rm0y+/cqvOyA8xcYLweF0TKXlAxmQASjbOi49Co51A1N4nR4bEwBgRoD9kNM+rqFGArjKr654SLp2CoGmQ==",
+ "dev": true,
+ "requires": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "@types/unist": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
+ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
+ "dev": true
+ },
+ "@types/webpack": {
+ "version": "4.41.32",
+ "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz",
+ "integrity": "sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/tapable": "^1",
+ "@types/uglify-js": "*",
+ "@types/webpack-sources": "*",
+ "anymatch": "^3.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "@types/webpack-env": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.0.tgz",
+ "integrity": "sha512-56/MAlX5WMsPVbOg7tAxnYvNYMMWr/QJiIp6BxVSW3JJXUVzzOn64qW8TzQyMSqSUFM2+PVI4aUHcHOzIz/1tg==",
+ "dev": true
+ },
+ "@types/webpack-sources": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz",
+ "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/source-list-map": "*",
+ "source-map": "^0.7.3"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
+ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "dev": true
+ }
+ }
+ },
+ "@types/yargs": {
+ "version": "15.0.14",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz",
+ "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==",
+ "dev": true,
+ "requires": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "@types/yargs-parser": {
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
+ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
+ "dev": true
+ },
+ "@webassemblyjs/ast": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
+ "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-numbers": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1"
+ }
+ },
+ "@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz",
+ "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz",
+ "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-buffer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz",
+ "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-code-frame": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz",
+ "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/wast-printer": "1.9.0"
+ },
+ "dependencies": {
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
+ "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ }
+ }
+ }
+ },
+ "@webassemblyjs/helper-fsm": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz",
+ "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-module-context": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz",
+ "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0"
+ },
+ "dependencies": {
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ }
+ }
+ },
+ "@webassemblyjs/helper-numbers": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz",
+ "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/floating-point-hex-parser": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz",
+ "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-section": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz",
+ "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1"
+ }
+ },
+ "@webassemblyjs/ieee754": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz",
+ "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==",
+ "dev": true,
+ "requires": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "@webassemblyjs/leb128": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz",
+ "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==",
+ "dev": true,
+ "requires": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@webassemblyjs/utf8": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz",
+ "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==",
+ "dev": true
+ },
+ "@webassemblyjs/wasm-edit": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz",
+ "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/helper-wasm-section": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-opt": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "@webassemblyjs/wast-printer": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wasm-gen": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz",
+ "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wasm-opt": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
+ "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-buffer": "1.11.1",
+ "@webassemblyjs/wasm-gen": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wasm-parser": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz",
+ "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/helper-api-error": "1.11.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+ "@webassemblyjs/ieee754": "1.11.1",
+ "@webassemblyjs/leb128": "1.11.1",
+ "@webassemblyjs/utf8": "1.11.1"
+ }
+ },
+ "@webassemblyjs/wast-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz",
+ "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.9.0",
+ "@webassemblyjs/floating-point-hex-parser": "1.9.0",
+ "@webassemblyjs/helper-api-error": "1.9.0",
+ "@webassemblyjs/helper-code-frame": "1.9.0",
+ "@webassemblyjs/helper-fsm": "1.9.0",
+ "@xtuc/long": "4.2.2"
+ },
+ "dependencies": {
+ "@webassemblyjs/ast": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
+ "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/helper-module-context": "1.9.0",
+ "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
+ "@webassemblyjs/wast-parser": "1.9.0"
+ }
+ },
+ "@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz",
+ "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-api-error": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
+ "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
+ "dev": true
+ },
+ "@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
+ "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+ "dev": true
+ }
+ }
+ },
+ "@webassemblyjs/wast-printer": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz",
+ "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==",
+ "dev": true,
+ "requires": {
+ "@webassemblyjs/ast": "1.11.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "@xtuc/ieee754": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+ "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+ "dev": true
+ },
+ "@xtuc/long": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+ "dev": true
+ },
+ "accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "requires": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ }
+ },
+ "acorn": {
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
+ "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
+ "dev": true
+ },
+ "acorn-import-assertions": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
+ "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==",
+ "dev": true,
+ "requires": {}
+ },
+ "address": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/address/-/address-1.2.1.tgz",
+ "integrity": "sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==",
+ "dev": true
+ },
+ "aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "requires": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ }
+ },
+ "airbnb-js-shims": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz",
+ "integrity": "sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==",
+ "dev": true,
+ "requires": {
+ "array-includes": "^3.0.3",
+ "array.prototype.flat": "^1.2.1",
+ "array.prototype.flatmap": "^1.2.1",
+ "es5-shim": "^4.5.13",
+ "es6-shim": "^0.35.5",
+ "function.prototype.name": "^1.1.0",
+ "globalthis": "^1.0.0",
+ "object.entries": "^1.1.0",
+ "object.fromentries": "^2.0.0 || ^1.0.0",
+ "object.getownpropertydescriptors": "^2.0.3",
+ "object.values": "^1.1.0",
+ "promise.allsettled": "^1.0.0",
+ "promise.prototype.finally": "^3.1.0",
+ "string.prototype.matchall": "^4.0.0 || ^3.0.1",
+ "string.prototype.padend": "^3.0.0",
+ "string.prototype.padstart": "^3.0.0",
+ "symbol.prototype.description": "^1.0.0"
+ }
+ },
+ "ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ajv-errors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
+ "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
+ "dev": true,
+ "requires": {}
+ },
+ "ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "requires": {}
+ },
+ "ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "requires": {
+ "string-width": "^4.1.0"
+ }
+ },
+ "ansi-colors": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz",
+ "integrity": "sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==",
+ "dev": true
+ },
+ "ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "ansi-to-html": {
+ "version": "0.6.15",
+ "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.15.tgz",
+ "integrity": "sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==",
+ "dev": true,
+ "requires": {
+ "entities": "^2.0.0"
+ }
+ },
+ "anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "app-root-dir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz",
+ "integrity": "sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==",
+ "dev": true
+ },
+ "aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "dev": true
+ },
+ "are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "dev": true,
+ "requires": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ }
+ },
+ "argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "requires": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "arr-diff": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
+ "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==",
+ "dev": true
+ },
+ "arr-flatten": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+ "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==",
+ "dev": true
+ },
+ "arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
+ "dev": true
+ },
+ "array-find-index": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
+ "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==",
+ "dev": true,
+ "optional": true
+ },
+ "array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true
+ },
+ "array-includes": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz",
+ "integrity": "sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5",
+ "get-intrinsic": "^1.1.1",
+ "is-string": "^1.0.7"
+ }
+ },
+ "array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true
+ },
+ "array-uniq": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
+ "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
+ "dev": true
+ },
+ "array-unique": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
+ "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==",
+ "dev": true
+ },
+ "array.prototype.flat": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz",
+ "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ }
+ },
+ "array.prototype.flatmap": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz",
+ "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ }
+ },
+ "array.prototype.map": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.4.tgz",
+ "integrity": "sha512-Qds9QnX7A0qISY7JT5WuJO0NJPE9CMlC6JzHQfhpqAAQQzufVRoeH7EzUY5GcPTx72voG8LV/5eo+b8Qi8hmhA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ }
+ },
+ "array.prototype.reduce": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.4.tgz",
+ "integrity": "sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-array-method-boxes-properly": "^1.0.0",
+ "is-string": "^1.0.7"
+ }
+ },
+ "arrify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
+ "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
+ "dev": true
+ },
+ "asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "assert": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz",
+ "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==",
+ "dev": true,
+ "requires": {
+ "object-assign": "^4.1.1",
+ "util": "0.10.3"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
+ "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==",
+ "dev": true
+ },
+ "util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "2.0.1"
+ }
+ }
+ }
+ },
+ "assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "dev": true
+ },
+ "async-each": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
+ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==",
+ "dev": true,
+ "optional": true
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true
+ },
+ "at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true
+ },
+ "atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "dev": true
+ },
+ "autoprefixer": {
+ "version": "9.8.8",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz",
+ "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
+ "dev": true,
+ "requires": {
+ "browserslist": "^4.12.0",
+ "caniuse-lite": "^1.0.30001109",
+ "normalize-range": "^0.1.2",
+ "num2fraction": "^1.2.2",
+ "picocolors": "^0.2.1",
+ "postcss": "^7.0.32",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "babel-loader": {
+ "version": "8.2.5",
+ "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz",
+ "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==",
+ "dev": true,
+ "requires": {
+ "find-cache-dir": "^3.3.1",
+ "loader-utils": "^2.0.0",
+ "make-dir": "^3.1.0",
+ "schema-utils": "^2.6.5"
+ },
+ "dependencies": {
+ "find-cache-dir": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+ "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^3.0.2",
+ "pkg-dir": "^4.1.0"
+ }
+ },
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ },
+ "pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.0.0"
+ }
+ }
+ }
+ },
+ "babel-plugin-apply-mdx-type-prop": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz",
+ "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@mdx-js/util": "1.6.22"
+ },
+ "dependencies": {
+ "@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ }
+ }
+ },
+ "babel-plugin-bundled-import-meta": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-bundled-import-meta/-/babel-plugin-bundled-import-meta-0.3.2.tgz",
+ "integrity": "sha512-RMXzsnWoFHDSUc1X/QiejEwQBtQ0Y68HQZ542JQ4voFa5Sgl5f/D4T7+EOocUeSbiT4XIDbrhfxbH5OmcV8Ibw==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-syntax-import-meta": "^7.2.0",
+ "@babel/template": "^7.7.0"
+ }
+ },
+ "babel-plugin-dynamic-import-node": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz",
+ "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==",
+ "dev": true,
+ "requires": {
+ "object.assign": "^4.1.0"
+ }
+ },
+ "babel-plugin-extract-import-names": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz",
+ "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "7.10.4"
+ },
+ "dependencies": {
+ "@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ }
+ }
+ },
+ "babel-plugin-istanbul": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@istanbuljs/load-nyc-config": "^1.0.0",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-instrument": "^5.0.4",
+ "test-exclude": "^6.0.0"
+ }
+ },
+ "babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ }
+ },
+ "babel-plugin-named-exports-order": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-named-exports-order/-/babel-plugin-named-exports-order-0.0.2.tgz",
+ "integrity": "sha512-OgOYHOLoRK+/mvXU9imKHlG6GkPLYrUCvFXG/CM93R/aNNO8pOOF4aS+S8CCHMDQoNSeiOYEZb/G6RwL95Jktw==",
+ "dev": true
+ },
+ "babel-plugin-polyfill-corejs2": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
+ "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==",
+ "dev": true,
+ "requires": {
+ "@babel/compat-data": "^7.17.7",
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "semver": "^6.1.1"
+ }
+ },
+ "babel-plugin-polyfill-corejs3": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz",
+ "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3",
+ "core-js-compat": "^3.25.1"
+ }
+ },
+ "babel-plugin-polyfill-regenerator": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz",
+ "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-define-polyfill-provider": "^0.3.3"
+ }
+ },
+ "bail": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
+ "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==",
+ "dev": true
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "base": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
+ "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==",
+ "dev": true,
+ "requires": {
+ "cache-base": "^1.0.1",
+ "class-utils": "^0.3.5",
+ "component-emitter": "^1.2.1",
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.1",
+ "mixin-deep": "^1.2.0",
+ "pascalcase": "^0.1.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ }
+ }
+ },
+ "base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true
+ },
+ "better-opn": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz",
+ "integrity": "sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==",
+ "dev": true,
+ "requires": {
+ "open": "^7.0.3"
+ },
+ "dependencies": {
+ "open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "requires": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ }
+ }
+ }
+ },
+ "big-integer": {
+ "version": "1.6.51",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
+ "dev": true,
+ "optional": true
+ },
+ "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
+ },
+ "binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true
+ },
+ "bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "bn.js": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
+ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==",
+ "dev": true
+ },
+ "body-parser": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
+ "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==",
+ "dev": true,
+ "requires": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.4",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.10.3",
+ "raw-body": "2.5.1",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "dependencies": {
+ "bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ }
+ }
+ },
+ "boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "boxen": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
+ "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
+ "dev": true,
+ "requires": {
+ "ansi-align": "^3.0.0",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.1.0",
+ "cli-boxes": "^2.2.1",
+ "string-width": "^4.2.2",
+ "type-fest": "^0.20.2",
+ "widest-line": "^3.1.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "bplist-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.1.1.tgz",
+ "integrity": "sha512-2AEM0FXy8ZxVLBuqX0hqt1gDwcnz2zygEkQ6zaD5Wko/sB9paUNwlpawrFtKeHUAQUOzjVy9AO4oeonqIHKA9Q==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "big-integer": "^1.6.7"
+ }
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "dev": true
+ },
+ "browser-assert": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz",
+ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==",
+ "dev": true
+ },
+ "browserify-aes": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+ "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
+ "dev": true,
+ "requires": {
+ "buffer-xor": "^1.0.3",
+ "cipher-base": "^1.0.0",
+ "create-hash": "^1.1.0",
+ "evp_bytestokey": "^1.0.3",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "browserify-cipher": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
+ "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+ "dev": true,
+ "requires": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "browserify-des": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
+ "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+ "dev": true,
+ "requires": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "browserify-rsa": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz",
+ "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^5.0.0",
+ "randombytes": "^2.0.1"
+ }
+ },
+ "browserify-sign": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz",
+ "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^5.1.1",
+ "browserify-rsa": "^4.0.1",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.3",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.5",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "dev": true,
+ "requires": {
+ "pako": "~1.0.5"
+ }
+ },
+ "browserslist": {
+ "version": "4.21.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
+ "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+ "dev": true,
+ "requires": {
+ "caniuse-lite": "^1.0.30001400",
+ "electron-to-chromium": "^1.4.251",
+ "node-releases": "^2.0.6",
+ "update-browserslist-db": "^1.0.9"
+ }
+ },
+ "bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "requires": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "buffer": {
+ "version": "4.9.2",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
+ "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
+ "dev": true,
+ "requires": {
+ "base64-js": "^1.0.2",
+ "ieee754": "^1.1.4",
+ "isarray": "^1.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ }
+ }
+ },
+ "buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "buffer-xor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
+ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==",
+ "dev": true
+ },
+ "builtin-status-codes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
+ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
+ "dev": true
+ },
+ "bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+ "dev": true
+ },
+ "cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "dev": true,
+ "requires": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "dependencies": {
+ "p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "dev": true,
+ "requires": {
+ "aggregate-error": "^3.0.0"
+ }
+ }
+ }
+ },
+ "cache-base": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
+ "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==",
+ "dev": true,
+ "requires": {
+ "collection-visit": "^1.0.0",
+ "component-emitter": "^1.2.1",
+ "get-value": "^2.0.6",
+ "has-value": "^1.0.0",
+ "isobject": "^3.0.1",
+ "set-value": "^2.0.0",
+ "to-object-path": "^0.3.0",
+ "union-value": "^1.0.0",
+ "unset-value": "^1.0.0"
+ }
+ },
+ "cached-iterable": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz",
+ "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==",
+ "dev": true
+ },
+ "call-bind": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+ "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.0.2"
+ }
+ },
+ "call-me-maybe": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
+ "integrity": "sha512-wCyFsDQkKPwwF8BDwOiWNx/9K45L/hvggQiDbve+viMNMQnWhrlYIuBk09offfwCRtCO9P6XwUttufzU11WCVw==",
+ "dev": true
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true
+ },
+ "camel-case": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+ "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+ "dev": true,
+ "requires": {
+ "pascal-case": "^3.1.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true
+ },
+ "camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true
+ },
+ "camelcase-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+ "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "camelcase": "^2.0.0",
+ "map-obj": "^1.0.0"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
+ "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==",
+ "dev": true,
+ "optional": true
+ }
+ }
+ },
+ "caniuse-lite": {
+ "version": "1.0.30001415",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001415.tgz",
+ "integrity": "sha512-ER+PfgCJUe8BqunLGWd/1EY4g8AzQcsDAVzdtMGKVtQEmKAwaFfU6vb7EAVIqTMYsqxBorYZi2+22Iouj/y7GQ==",
+ "dev": true
+ },
+ "capture-exit": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
+ "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==",
+ "dev": true,
+ "requires": {
+ "rsvp": "^4.8.4"
+ }
+ },
+ "case-sensitive-paths-webpack-plugin": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz",
+ "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==",
+ "dev": true
+ },
+ "ccount": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz",
+ "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "character-entities": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
+ "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
+ "dev": true
+ },
+ "character-entities-legacy": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
+ "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
+ "dev": true
+ },
+ "character-reference-invalid": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
+ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
+ "dev": true
+ },
+ "chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "fsevents": "~2.3.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ }
+ },
+ "chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "dev": true
+ },
+ "chrome-trace-event": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
+ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
+ "dev": true
+ },
+ "ci-info": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
+ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
+ "dev": true
+ },
+ "cipher-base": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
+ "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "class-utils": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
+ "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==",
+ "dev": true,
+ "requires": {
+ "arr-union": "^3.1.0",
+ "define-property": "^0.2.5",
+ "isobject": "^3.0.0",
+ "static-extend": "^0.1.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ }
+ }
+ },
+ "clean-css": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz",
+ "integrity": "sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==",
+ "dev": true,
+ "requires": {
+ "source-map": "~0.6.0"
+ }
+ },
+ "clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true
+ },
+ "cli-boxes": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
+ "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
+ "dev": true
+ },
+ "cli-table3": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz",
+ "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==",
+ "dev": true,
+ "requires": {
+ "@colors/colors": "1.5.0",
+ "string-width": "^4.2.0"
+ }
+ },
+ "clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "dev": true,
+ "requires": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ }
+ },
+ "collapse-white-space": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz",
+ "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==",
+ "dev": true
+ },
+ "collection-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
+ "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==",
+ "dev": true,
+ "requires": {
+ "map-visit": "^1.0.0",
+ "object-visit": "^1.0.0"
+ }
+ },
+ "color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "dev": true
+ },
+ "colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "comma-separated-tokens": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
+ "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
+ "dev": true
+ },
+ "commander": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+ "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+ "dev": true
+ },
+ "commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true
+ },
+ "component-emitter": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
+ "dev": true
+ },
+ "compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "requires": {
+ "mime-db": ">= 1.43.0 < 2"
+ }
+ },
+ "compression": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
+ "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+ "dev": true,
+ "requires": {
+ "accepts": "~1.3.5",
+ "bytes": "3.0.0",
+ "compressible": "~2.0.16",
+ "debug": "2.6.9",
+ "on-headers": "~1.0.2",
+ "safe-buffer": "5.1.2",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "dev": true,
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "console-browserify": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
+ "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==",
+ "dev": true
+ },
+ "console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "dev": true
+ },
+ "constants-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
+ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==",
+ "dev": true
+ },
+ "content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "5.2.1"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
+ "dev": true
+ },
+ "convert-source-map": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+ "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.1"
+ }
+ },
+ "cookie": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
+ "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
+ "dev": true
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "dev": true
+ },
+ "copy-concurrently": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
+ "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.1.1",
+ "fs-write-stream-atomic": "^1.0.8",
+ "iferr": "^0.1.5",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.0"
+ },
+ "dependencies": {
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
+ "copy-descriptor": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
+ "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==",
+ "dev": true
+ },
+ "core-js": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
+ "integrity": "sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw==",
+ "dev": true
+ },
+ "core-js-compat": {
+ "version": "3.25.5",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.5.tgz",
+ "integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==",
+ "dev": true,
+ "requires": {
+ "browserslist": "^4.21.4"
+ }
+ },
+ "core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true
+ },
+ "cosmiconfig": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+ "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+ "dev": true,
+ "requires": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ }
+ },
+ "cp-file": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-7.0.0.tgz",
+ "integrity": "sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "make-dir": "^3.0.0",
+ "nested-error-stacks": "^2.0.0",
+ "p-event": "^4.1.0"
+ },
+ "dependencies": {
+ "make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dev": true,
+ "requires": {
+ "semver": "^6.0.0"
+ }
+ }
+ }
+ },
+ "cpy": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/cpy/-/cpy-8.1.2.tgz",
+ "integrity": "sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg==",
+ "dev": true,
+ "requires": {
+ "arrify": "^2.0.1",
+ "cp-file": "^7.0.0",
+ "globby": "^9.2.0",
+ "has-glob": "^1.0.0",
+ "junk": "^3.1.0",
+ "nested-error-stacks": "^2.1.0",
+ "p-all": "^2.1.0",
+ "p-filter": "^2.1.0",
+ "p-map": "^3.0.0"
+ },
+ "dependencies": {
+ "@nodelib/fs.stat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
+ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==",
+ "dev": true
+ },
+ "@types/glob": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
+ "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==",
+ "dev": true,
+ "requires": {
+ "@types/minimatch": "*",
+ "@types/node": "*"
+ }
+ },
+ "array-union": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
+ "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
+ "dev": true,
+ "requires": {
+ "array-uniq": "^1.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "dir-glob": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz",
+ "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==",
+ "dev": true,
+ "requires": {
+ "path-type": "^3.0.0"
+ }
+ },
+ "fast-glob": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz",
+ "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==",
+ "dev": true,
+ "requires": {
+ "@mrmlnc/readdir-enhanced": "^2.2.1",
+ "@nodelib/fs.stat": "^1.1.2",
+ "glob-parent": "^3.1.0",
+ "is-glob": "^4.0.0",
+ "merge2": "^1.2.3",
+ "micromatch": "^3.1.10"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ },
+ "dependencies": {
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.0"
+ }
+ }
+ }
+ },
+ "globby": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz",
+ "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==",
+ "dev": true,
+ "requires": {
+ "@types/glob": "^7.1.1",
+ "array-union": "^1.0.2",
+ "dir-glob": "^2.2.2",
+ "fast-glob": "^2.2.6",
+ "glob": "^7.1.3",
+ "ignore": "^4.0.3",
+ "pify": "^4.0.1",
+ "slash": "^2.0.0"
+ }
+ },
+ "ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "path-type": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+ "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+ "dev": true,
+ "requires": {
+ "pify": "^3.0.0"
+ },
+ "dependencies": {
+ "pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+ "dev": true
+ }
+ }
+ },
+ "slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ }
+ }
+ },
+ "create-ecdh": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
+ "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "dev": true,
+ "requires": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
+ "create-hmac": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+ "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
+ "dev": true,
+ "requires": {
+ "cipher-base": "^1.0.3",
+ "create-hash": "^1.1.0",
+ "inherits": "^2.0.1",
+ "ripemd160": "^2.0.0",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ }
+ },
+ "crypto-browserify": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
+ "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
+ "dev": true,
+ "requires": {
+ "browserify-cipher": "^1.0.0",
+ "browserify-sign": "^4.0.0",
+ "create-ecdh": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "create-hmac": "^1.1.0",
+ "diffie-hellman": "^5.0.0",
+ "inherits": "^2.0.1",
+ "pbkdf2": "^3.0.3",
+ "public-encrypt": "^4.0.0",
+ "randombytes": "^2.0.0",
+ "randomfill": "^1.0.3"
+ }
+ },
+ "css-loader": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz",
+ "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.1.0",
+ "loader-utils": "^2.0.0",
+ "postcss": "^8.2.15",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "postcss-value-parser": "^4.1.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.5"
+ },
+ "dependencies": {
+ "icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "dev": true,
+ "requires": {}
+ },
+ "picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "postcss": {
+ "version": "8.4.17",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz",
+ "integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==",
+ "dev": true,
+ "requires": {
+ "nanoid": "^3.3.4",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ }
+ },
+ "postcss-modules-extract-imports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+ "dev": true,
+ "requires": {}
+ },
+ "postcss-modules-local-by-default": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+ "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "postcss-modules-scope": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+ "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+ "dev": true,
+ "requires": {
+ "postcss-selector-parser": "^6.0.4"
+ }
+ },
+ "postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "css-select": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
+ "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.0.1",
+ "domhandler": "^4.3.1",
+ "domutils": "^2.8.0",
+ "nth-check": "^2.0.1"
+ }
+ },
+ "css-what": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+ "dev": true
+ },
+ "cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true
+ },
+ "currently-unhandled": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
+ "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "array-find-index": "^1.0.1"
+ }
+ },
+ "cyclist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
+ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
+ "dev": true
+ },
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "optional": true
+ },
+ "decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
+ "dev": true
+ },
+ "deepmerge": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+ "dev": true
+ },
+ "default-browser-id": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz",
+ "integrity": "sha512-qPy925qewwul9Hifs+3sx1ZYn14obHxpkX+mPD369w4Rzg+YkJBgi3SOvwUq81nWSjqGUegIgEPwD8u+HUnxlw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "bplist-parser": "^0.1.0",
+ "meow": "^3.1.0",
+ "untildify": "^2.0.0"
+ }
+ },
+ "define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true
+ },
+ "define-properties": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
+ "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
+ "dev": true,
+ "requires": {
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "define-property": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
+ "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.2",
+ "isobject": "^3.0.1"
+ }
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true
+ },
+ "delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "dev": true
+ },
+ "depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true
+ },
+ "des.js": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
+ "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true
+ },
+ "detab": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz",
+ "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==",
+ "dev": true,
+ "requires": {
+ "repeat-string": "^1.5.4"
+ }
+ },
+ "detect-package-manager": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/detect-package-manager/-/detect-package-manager-2.0.1.tgz",
+ "integrity": "sha512-j/lJHyoLlWi6G1LDdLgvUtz60Zo5GEj+sVYtTVXnYLDPuzgC3llMxonXym9zIwhhUII8vjdw0LXxavpLqTbl1A==",
+ "dev": true,
+ "requires": {
+ "execa": "^5.1.1"
+ }
+ },
+ "detect-port": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz",
+ "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==",
+ "dev": true,
+ "requires": {
+ "address": "^1.0.1",
+ "debug": "4"
+ }
+ },
+ "diffie-hellman": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+ "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "requires": {
+ "path-type": "^4.0.0"
+ }
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "dom-converter": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
+ "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==",
+ "dev": true,
+ "requires": {
+ "utila": "~0.4"
+ }
+ },
+ "dom-serializer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
+ "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ }
+ },
+ "dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
+ "dev": true
+ },
+ "domain-browser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
+ "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==",
+ "dev": true
+ },
+ "domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true
+ },
+ "domhandler": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
+ "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.2.0"
+ }
+ },
+ "domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "dev": true,
+ "requires": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ }
+ },
+ "dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "dev": true,
+ "requires": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "dotenv": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
+ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==",
+ "dev": true
+ },
+ "dotenv-expand": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
+ "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==",
+ "dev": true
+ },
+ "duplexify": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
+ "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0",
+ "stream-shift": "^1.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
+ "electron-to-chromium": {
+ "version": "1.4.271",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.271.tgz",
+ "integrity": "sha512-BCPBtK07xR1/uY2HFDtl3wK2De66AW4MSiPlLrnPNxKC/Qhccxd59W73654S3y6Rb/k3hmuGJOBnhjfoutetXA==",
+ "dev": true
+ },
+ "elliptic": {
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
+ "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "emojis-list": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+ "dev": true
+ },
+ "encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "dev": true
+ },
+ "end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "dev": true,
+ "requires": {
+ "once": "^1.4.0"
+ }
+ },
+ "enhanced-resolve": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz",
+ "integrity": "sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "dependencies": {
+ "tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true
+ }
+ }
+ },
+ "entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "dev": true
+ },
+ "errno": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+ "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+ "dev": true,
+ "requires": {
+ "prr": "~1.0.1"
+ }
+ },
+ "error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "requires": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "es-abstract": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz",
+ "integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "function.prototype.name": "^1.1.5",
+ "get-intrinsic": "^1.1.3",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-property-descriptors": "^1.0.0",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.6",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.2",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.4.3",
+ "safe-regex-test": "^1.0.0",
+ "string.prototype.trimend": "^1.0.5",
+ "string.prototype.trimstart": "^1.0.5",
+ "unbox-primitive": "^1.0.2"
+ }
+ },
+ "es-array-method-boxes-properly": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz",
+ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==",
+ "dev": true
+ },
+ "es-get-iterator": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
+ "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.0",
+ "has-symbols": "^1.0.1",
+ "is-arguments": "^1.1.0",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.5",
+ "isarray": "^2.0.5"
+ }
+ },
+ "es-module-lexer": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
+ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
+ "dev": true
+ },
+ "es-shim-unscopables": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
+ "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "dev": true,
+ "requires": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ }
+ },
+ "es5-shim": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz",
+ "integrity": "sha512-jg21/dmlrNQI7JyyA2w7n+yifSxBng0ZralnSfVZjoCawgNTCnS+yBCyVM9DL5itm7SUnDGgv7hcq2XCZX4iRQ==",
+ "dev": true
+ },
+ "es6-shim": {
+ "version": "0.35.6",
+ "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz",
+ "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
+ "dev": true
+ },
+ "escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "dev": true
+ },
+ "escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true
+ },
+ "eslint-scope": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^4.1.1"
+ },
+ "dependencies": {
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ }
+ }
+ },
+ "esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true
+ },
+ "esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.2.0"
+ }
+ },
+ "estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true
+ },
+ "etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true
+ },
+ "events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true
+ },
+ "evp_bytestokey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
+ "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
+ "dev": true,
+ "requires": {
+ "md5.js": "^1.3.4",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "exec-sh": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz",
+ "integrity": "sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==",
+ "dev": true
+ },
+ "execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ }
+ },
+ "expand-brackets": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
+ "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==",
+ "dev": true,
+ "requires": {
+ "debug": "^2.3.3",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "posix-character-classes": "^0.1.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "express": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz",
+ "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==",
+ "dev": true,
+ "requires": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.0",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.5.0",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.2.0",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.1",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.10.3",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.18.0",
+ "serve-static": "1.15.0",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
+ "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "dev": true
+ },
+ "extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "dev": true,
+ "requires": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ }
+ },
+ "extglob": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
+ "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==",
+ "dev": true,
+ "requires": {
+ "array-unique": "^0.3.2",
+ "define-property": "^1.0.0",
+ "expand-brackets": "^2.1.4",
+ "extend-shallow": "^2.0.1",
+ "fragment-cache": "^0.2.1",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ }
+ }
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "fast-glob": {
+ "version": "3.2.12",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
+ "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ }
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "fastq": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+ "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+ "dev": true,
+ "requires": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "requires": {
+ "bser": "2.1.1"
+ }
+ },
+ "fetch-retry": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz",
+ "integrity": "sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==",
+ "dev": true
+ },
+ "figgy-pudding": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
+ "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==",
+ "dev": true
+ },
+ "file-loader": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
+ "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "file-system-cache": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz",
+ "integrity": "sha512-IzF5MBq+5CR0jXx5RxPe4BICl/oEhBSXKaL9fLhAXrIfIUS77Hr4vzrYyqYMHN6uTt+BOqi3fDCTjjEBCjERKw==",
+ "dev": true,
+ "requires": {
+ "fs-extra": "^10.1.0",
+ "ramda": "^0.28.0"
+ },
+ "dependencies": {
+ "fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ }
+ }
+ }
+ },
+ "file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "optional": true
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "finalhandler": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
+ "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
+ "dev": true,
+ "requires": {
+ "debug": "2.6.9",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "requires": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ }
+ }
+ },
+ "find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "flush-write-stream": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
+ "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.3.6"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
+ "dev": true
+ },
+ "fork-ts-checker-webpack-plugin": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz",
+ "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.8.3",
+ "@types/json-schema": "^7.0.5",
+ "chalk": "^4.1.0",
+ "chokidar": "^3.4.2",
+ "cosmiconfig": "^6.0.0",
+ "deepmerge": "^4.2.2",
+ "fs-extra": "^9.0.0",
+ "glob": "^7.1.6",
+ "memfs": "^3.1.2",
+ "minimatch": "^3.0.4",
+ "schema-utils": "2.7.0",
+ "semver": "^7.3.2",
+ "tapable": "^1.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "cosmiconfig": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+ "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+ "dev": true,
+ "requires": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.1.0",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.7.2"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "schema-utils": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
+ "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.4",
+ "ajv": "^6.12.2",
+ "ajv-keywords": "^3.4.1"
+ }
+ },
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "form-data": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+ "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+ "dev": true,
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true
+ },
+ "fragment-cache": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
+ "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==",
+ "dev": true,
+ "requires": {
+ "map-cache": "^0.2.2"
+ }
+ },
+ "fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true
+ },
+ "from2": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz",
+ "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "requires": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ }
+ },
+ "fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "fs-monkey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz",
+ "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==",
+ "dev": true
+ },
+ "fs-write-stream-atomic": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
+ "integrity": "sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "iferr": "^0.1.5",
+ "imurmurhash": "^0.1.4",
+ "readable-stream": "1 || 2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "function.prototype.name": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz",
+ "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.0",
+ "functions-have-names": "^1.2.2"
+ }
+ },
+ "functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true
+ },
+ "gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ }
+ },
+ "gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true
+ },
+ "get-intrinsic": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
+ "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3"
+ }
+ },
+ "get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "dev": true
+ },
+ "get-stdin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
+ "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==",
+ "dev": true,
+ "optional": true
+ },
+ "get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true
+ },
+ "get-symbol-description": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+ "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.1"
+ }
+ },
+ "get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "dev": true
+ },
+ "github-slugger": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz",
+ "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==",
+ "dev": true
+ },
+ "glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "glob-promise": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-3.4.0.tgz",
+ "integrity": "sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==",
+ "dev": true,
+ "requires": {
+ "@types/glob": "*"
+ }
+ },
+ "glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true
+ },
+ "global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "dev": true,
+ "requires": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
+ "globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true
+ },
+ "globalthis": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
+ "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
+ "dev": true,
+ "requires": {
+ "define-properties": "^1.1.3"
+ }
+ },
+ "globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "requires": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ }
+ },
+ "graceful-fs": {
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+ "dev": true
+ },
+ "handlebars": {
+ "version": "4.7.7",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+ "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.0",
+ "source-map": "^0.6.1",
+ "uglify-js": "^3.1.4",
+ "wordwrap": "^1.0.0"
+ }
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "has-bigints": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
+ "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true
+ },
+ "has-glob": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz",
+ "integrity": "sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^3.0.0"
+ },
+ "dependencies": {
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.0"
+ }
+ }
+ }
+ },
+ "has-property-descriptors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
+ "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
+ "dev": true,
+ "requires": {
+ "get-intrinsic": "^1.1.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "has-tostringtag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+ "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+ "dev": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "dev": true
+ },
+ "has-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
+ "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==",
+ "dev": true,
+ "requires": {
+ "get-value": "^2.0.6",
+ "has-values": "^1.0.0",
+ "isobject": "^3.0.0"
+ }
+ },
+ "has-values": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz",
+ "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "kind-of": "^4.0.0"
+ },
+ "dependencies": {
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "kind-of": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
+ "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "hash-base": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
+ "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "hast-to-hyperscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz",
+ "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.3",
+ "comma-separated-tokens": "^1.0.0",
+ "property-information": "^5.3.0",
+ "space-separated-tokens": "^1.0.0",
+ "style-to-object": "^0.3.0",
+ "unist-util-is": "^4.0.0",
+ "web-namespaces": "^1.0.0"
+ }
+ },
+ "hast-util-from-parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz",
+ "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==",
+ "dev": true,
+ "requires": {
+ "@types/parse5": "^5.0.0",
+ "hastscript": "^6.0.0",
+ "property-information": "^5.0.0",
+ "vfile": "^4.0.0",
+ "vfile-location": "^3.2.0",
+ "web-namespaces": "^1.0.0"
+ }
+ },
+ "hast-util-parse-selector": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
+ "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
+ "dev": true
+ },
+ "hast-util-raw": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz",
+ "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==",
+ "dev": true,
+ "requires": {
+ "@types/hast": "^2.0.0",
+ "hast-util-from-parse5": "^6.0.0",
+ "hast-util-to-parse5": "^6.0.0",
+ "html-void-elements": "^1.0.0",
+ "parse5": "^6.0.0",
+ "unist-util-position": "^3.0.0",
+ "vfile": "^4.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ }
+ },
+ "hast-util-to-parse5": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz",
+ "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==",
+ "dev": true,
+ "requires": {
+ "hast-to-hyperscript": "^9.0.0",
+ "property-information": "^5.0.0",
+ "web-namespaces": "^1.0.0",
+ "xtend": "^4.0.0",
+ "zwitch": "^1.0.0"
+ }
+ },
+ "hastscript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
+ "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
+ "dev": true,
+ "requires": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ }
+ },
+ "he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true
+ },
+ "hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "dev": true,
+ "requires": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "hosted-git-info": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "dev": true
+ },
+ "html-entities": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
+ "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==",
+ "dev": true
+ },
+ "html-minifier-terser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
+ "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
+ "dev": true,
+ "requires": {
+ "camel-case": "^4.1.2",
+ "clean-css": "^5.2.2",
+ "commander": "^8.3.0",
+ "he": "^1.2.0",
+ "param-case": "^3.0.4",
+ "relateurl": "^0.2.7",
+ "terser": "^5.10.0"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "dev": true
+ }
+ }
+ },
+ "html-void-elements": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz",
+ "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==",
+ "dev": true
+ },
+ "html-webpack-plugin": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz",
+ "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==",
+ "dev": true,
+ "requires": {
+ "@types/html-minifier-terser": "^6.0.0",
+ "html-minifier-terser": "^6.0.2",
+ "lodash": "^4.17.21",
+ "pretty-error": "^4.0.0",
+ "tapable": "^2.0.0"
+ },
+ "dependencies": {
+ "tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true
+ }
+ }
+ },
+ "htmlparser2": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
+ "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.0.0",
+ "domutils": "^2.5.2",
+ "entities": "^2.0.0"
+ }
+ },
+ "http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dev": true,
+ "requires": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ }
+ },
+ "https-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
+ "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==",
+ "dev": true
+ },
+ "human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true
+ },
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ },
+ "icss-utils": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
+ "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.14"
+ }
+ },
+ "ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true
+ },
+ "iferr": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz",
+ "integrity": "sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==",
+ "dev": true
+ },
+ "ignore": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+ "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+ "dev": true
+ },
+ "import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "dependencies": {
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true
+ }
+ }
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true
+ },
+ "indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true
+ },
+ "infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "inline-style-parser": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
+ "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
+ "dev": true
+ },
+ "internal-slot": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+ "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+ "dev": true,
+ "requires": {
+ "get-intrinsic": "^1.1.0",
+ "has": "^1.0.3",
+ "side-channel": "^1.0.4"
+ }
+ },
+ "interpret": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
+ "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
+ "dev": true
+ },
+ "ip": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
+ "dev": true
+ },
+ "ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true
+ },
+ "is-absolute-url": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz",
+ "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==",
+ "dev": true
+ },
+ "is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-alphabetical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
+ "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
+ "dev": true
+ },
+ "is-alphanumerical": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
+ "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
+ "dev": true,
+ "requires": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ }
+ },
+ "is-arguments": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+ "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "is-bigint": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+ "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+ "dev": true,
+ "requires": {
+ "has-bigints": "^1.0.1"
+ }
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-boolean-object": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+ "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+ "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true
+ },
+ "is-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
+ "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
+ "dev": true,
+ "requires": {
+ "ci-info": "^2.0.0"
+ }
+ },
+ "is-core-module": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz",
+ "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-date-object": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+ "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-decimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
+ "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
+ "dev": true
+ },
+ "is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ }
+ },
+ "is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true
+ },
+ "is-dom": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-dom/-/is-dom-1.1.0.tgz",
+ "integrity": "sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==",
+ "dev": true,
+ "requires": {
+ "is-object": "^1.0.1",
+ "is-window": "^1.0.2"
+ }
+ },
+ "is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "dev": true,
+ "requires": {
+ "is-plain-object": "^2.0.4"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true
+ },
+ "is-finite": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz",
+ "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==",
+ "dev": true,
+ "optional": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
+ },
+ "is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-hexadecimal": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
+ "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
+ "dev": true
+ },
+ "is-map": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
+ "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "is-number-object": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
+ "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+ "dev": true
+ },
+ "is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "dev": true
+ },
+ "is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-set": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
+ "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+ "dev": true
+ },
+ "is-shared-array-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz",
+ "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-symbol": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+ "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+ "dev": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+ "dev": true
+ },
+ "is-utf8": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
+ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==",
+ "dev": true,
+ "optional": true
+ },
+ "is-weakref": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+ "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "is-whitespace-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz",
+ "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==",
+ "dev": true
+ },
+ "is-window": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz",
+ "integrity": "sha512-uj00kdXyZb9t9RcAUAwMZAnkBUwdYGhYlt7djMXhfyhUCzwNba50tIiBKR7q0l7tdoBtFVw/3JmLY6fI3rmZmg==",
+ "dev": true
+ },
+ "is-windows": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+ "dev": true
+ },
+ "is-word-character": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz",
+ "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==",
+ "dev": true
+ },
+ "is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "requires": {
+ "is-docker": "^2.0.0"
+ }
+ },
+ "isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true
+ },
+ "isomorphic-unfetch": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz",
+ "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==",
+ "dev": true,
+ "requires": {
+ "node-fetch": "^2.6.1",
+ "unfetch": "^4.2.0"
+ }
+ },
+ "istanbul-lib-coverage": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+ "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+ "dev": true
+ },
+ "istanbul-lib-instrument": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz",
+ "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ }
+ },
+ "iterate-iterator": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz",
+ "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==",
+ "dev": true
+ },
+ "iterate-value": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz",
+ "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==",
+ "dev": true,
+ "requires": {
+ "es-get-iterator": "^1.0.2",
+ "iterate-iterator": "^1.0.1"
+ }
+ },
+ "jest-haste-map": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz",
+ "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==",
+ "dev": true,
+ "requires": {
+ "@jest/types": "^26.6.2",
+ "@types/graceful-fs": "^4.1.2",
+ "@types/node": "*",
+ "anymatch": "^3.0.3",
+ "fb-watchman": "^2.0.0",
+ "fsevents": "^2.1.2",
+ "graceful-fs": "^4.2.4",
+ "jest-regex-util": "^26.0.0",
+ "jest-serializer": "^26.6.2",
+ "jest-util": "^26.6.2",
+ "jest-worker": "^26.6.2",
+ "micromatch": "^4.0.2",
+ "sane": "^4.0.3",
+ "walker": "^1.0.7"
+ }
+ },
+ "jest-regex-util": {
+ "version": "26.0.0",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz",
+ "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==",
+ "dev": true
+ },
+ "jest-serializer": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz",
+ "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "graceful-fs": "^4.2.4"
+ }
+ },
+ "jest-util": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz",
+ "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==",
+ "dev": true,
+ "requires": {
+ "@jest/types": "^26.6.2",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.4",
+ "is-ci": "^2.0.0",
+ "micromatch": "^4.0.2"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "jest-worker": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
+ "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "js-string-escape": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
+ "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==",
+ "dev": true
+ },
+ "js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "requires": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ }
+ },
+ "jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true
+ },
+ "json-parse-better-errors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+ "dev": true
+ },
+ "json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "json5": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
+ "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+ "dev": true
+ },
+ "jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.6",
+ "universalify": "^2.0.0"
+ }
+ },
+ "junk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
+ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true
+ },
+ "kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true
+ },
+ "klona": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+ "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+ "dev": true
+ },
+ "lazy-universal-dotenv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-3.0.1.tgz",
+ "integrity": "sha512-prXSYk799h3GY3iOWnC6ZigYzMPjxN2svgjJ9shk7oMadSNX3wXy0B6F32PMJv7qtMnrIbUxoEHzbutvxR2LBQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.5.0",
+ "app-root-dir": "^1.0.2",
+ "core-js": "^3.0.4",
+ "dotenv": "^8.0.0",
+ "dotenv-expand": "^5.1.0"
+ }
+ },
+ "lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "lit": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-2.3.1.tgz",
+ "integrity": "sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==",
+ "dev": true,
+ "requires": {
+ "@lit/reactive-element": "^1.4.0",
+ "lit-element": "^3.2.0",
+ "lit-html": "^2.3.0"
+ }
+ },
+ "lit-element": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.2.tgz",
+ "integrity": "sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==",
+ "dev": true,
+ "requires": {
+ "@lit/reactive-element": "^1.3.0",
+ "lit-html": "^2.2.0"
+ }
+ },
+ "lit-html": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.3.1.tgz",
+ "integrity": "sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==",
+ "dev": true,
+ "requires": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
+ "load-json-file": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
+ "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "parse-json": "^2.2.0",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0",
+ "strip-bom": "^2.0.0"
+ },
+ "dependencies": {
+ "parse-json": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
+ "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "error-ex": "^1.2.0"
+ }
+ },
+ "pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true
+ }
+ }
+ },
+ "loader-runner": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
+ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+ "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^2.1.2"
+ }
+ },
+ "locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^5.0.0"
+ }
+ },
+ "lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true
+ },
+ "lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+ "dev": true
+ },
+ "loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "requires": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "loud-rejection": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz",
+ "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "currently-unhandled": "^0.4.1",
+ "signal-exit": "^3.0.0"
+ }
+ },
+ "lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "dev": true,
+ "requires": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "requires": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "makeerror": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+ "dev": true,
+ "requires": {
+ "tmpl": "1.0.5"
+ }
+ },
+ "map-age-cleaner": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz",
+ "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==",
+ "dev": true,
+ "requires": {
+ "p-defer": "^1.0.0"
+ }
+ },
+ "map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
+ "dev": true
+ },
+ "map-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+ "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
+ "dev": true,
+ "optional": true
+ },
+ "map-or-similar": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz",
+ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==",
+ "dev": true
+ },
+ "map-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
+ "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==",
+ "dev": true,
+ "requires": {
+ "object-visit": "^1.0.0"
+ }
+ },
+ "markdown-escapes": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz",
+ "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==",
+ "dev": true
+ },
+ "md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "dev": true,
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "mdast-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==",
+ "dev": true,
+ "requires": {
+ "unist-util-remove": "^2.0.0"
+ }
+ },
+ "mdast-util-definitions": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz",
+ "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==",
+ "dev": true,
+ "requires": {
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "mdast-util-to-hast": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz",
+ "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==",
+ "dev": true,
+ "requires": {
+ "@types/mdast": "^3.0.0",
+ "@types/unist": "^2.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "mdurl": "^1.0.0",
+ "unist-builder": "^2.0.0",
+ "unist-util-generated": "^1.0.0",
+ "unist-util-position": "^3.0.0",
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "mdast-util-to-string": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz",
+ "integrity": "sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==",
+ "dev": true
+ },
+ "mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+ "dev": true
+ },
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true
+ },
+ "mem": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz",
+ "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==",
+ "dev": true,
+ "requires": {
+ "map-age-cleaner": "^0.1.3",
+ "mimic-fn": "^3.1.0"
+ },
+ "dependencies": {
+ "mimic-fn": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
+ "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
+ "dev": true
+ }
+ }
+ },
+ "memfs": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.7.tgz",
+ "integrity": "sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==",
+ "dev": true,
+ "requires": {
+ "fs-monkey": "^1.0.3"
+ }
+ },
+ "memoizerific": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
+ "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==",
+ "dev": true,
+ "requires": {
+ "map-or-similar": "^1.5.0"
+ }
+ },
+ "memory-fs": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
+ "integrity": "sha512-cda4JKCxReDXFXRqOHPQscuIYg1PvxbE2S2GP45rnwfEK+vZaXC8C1OFvdHIbgw0DLzowXGVoxLaAmlgRy14GQ==",
+ "dev": true,
+ "requires": {
+ "errno": "^0.1.3",
+ "readable-stream": "^2.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "meow": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+ "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "camelcase-keys": "^2.0.0",
+ "decamelize": "^1.1.2",
+ "loud-rejection": "^1.0.0",
+ "map-obj": "^1.0.1",
+ "minimist": "^1.1.3",
+ "normalize-package-data": "^2.3.4",
+ "object-assign": "^4.0.1",
+ "read-pkg-up": "^1.0.1",
+ "redent": "^1.0.0",
+ "trim-newlines": "^1.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
+ "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "path-exists": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
+ "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "pinkie-promise": "^2.0.0"
+ }
+ },
+ "path-type": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
+ "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "pify": "^2.0.0",
+ "pinkie-promise": "^2.0.0"
+ }
+ },
+ "pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "optional": true
+ },
+ "read-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
+ "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "load-json-file": "^1.0.0",
+ "normalize-package-data": "^2.3.2",
+ "path-type": "^1.0.0"
+ }
+ },
+ "read-pkg-up": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
+ "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "find-up": "^1.0.0",
+ "read-pkg": "^1.0.0"
+ }
+ }
+ }
+ },
+ "merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
+ "dev": true
+ },
+ "merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true
+ },
+ "methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true
+ },
+ "microevent.ts": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz",
+ "integrity": "sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==",
+ "dev": true
+ },
+ "micromatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+ "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "dev": true,
+ "requires": {
+ "braces": "^3.0.2",
+ "picomatch": "^2.3.1"
+ }
+ },
+ "miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true
+ },
+ "mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true
+ },
+ "mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "requires": {
+ "mime-db": "1.52.0"
+ }
+ },
+ "mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true
+ },
+ "min-document": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+ "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
+ "dev": true,
+ "requires": {
+ "dom-walk": "^0.1.0"
+ }
+ },
+ "minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "dev": true
+ },
+ "minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "dev": true
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "minimist": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+ "dev": true
+ },
+ "minipass": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz",
+ "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0"
+ }
+ },
+ "minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ }
+ },
+ "mississippi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
+ "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==",
+ "dev": true,
+ "requires": {
+ "concat-stream": "^1.5.0",
+ "duplexify": "^3.4.2",
+ "end-of-stream": "^1.1.0",
+ "flush-write-stream": "^1.0.0",
+ "from2": "^2.1.0",
+ "parallel-transform": "^1.1.0",
+ "pump": "^3.0.0",
+ "pumpify": "^1.3.3",
+ "stream-each": "^1.1.0",
+ "through2": "^2.0.0"
+ }
+ },
+ "mixin-deep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
+ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
+ "dev": true,
+ "requires": {
+ "for-in": "^1.0.2",
+ "is-extendable": "^1.0.1"
+ }
+ },
+ "mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true
+ },
+ "move-concurrently": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
+ "integrity": "sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.1.1",
+ "copy-concurrently": "^1.0.0",
+ "fs-write-stream-atomic": "^1.0.8",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.4",
+ "run-queue": "^1.0.3"
+ },
+ "dependencies": {
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.6"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "nan": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.16.0.tgz",
+ "integrity": "sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==",
+ "dev": true,
+ "optional": true
+ },
+ "nanoid": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+ "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "dev": true
+ },
+ "nanomatch": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
+ "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "fragment-cache": "^0.2.1",
+ "is-windows": "^1.0.2",
+ "kind-of": "^6.0.2",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ }
+ },
+ "negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true
+ },
+ "neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true
+ },
+ "nested-error-stacks": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz",
+ "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==",
+ "dev": true
+ },
+ "nice-try": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+ "dev": true
+ },
+ "no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "dev": true,
+ "requires": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node-fetch": {
+ "version": "2.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+ "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+ "dev": true,
+ "requires": {
+ "whatwg-url": "^5.0.0"
+ }
+ },
+ "node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true
+ },
+ "node-libs-browser": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
+ "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==",
+ "dev": true,
+ "requires": {
+ "assert": "^1.1.1",
+ "browserify-zlib": "^0.2.0",
+ "buffer": "^4.3.0",
+ "console-browserify": "^1.1.0",
+ "constants-browserify": "^1.0.0",
+ "crypto-browserify": "^3.11.0",
+ "domain-browser": "^1.1.1",
+ "events": "^3.0.0",
+ "https-browserify": "^1.0.0",
+ "os-browserify": "^0.3.0",
+ "path-browserify": "0.0.1",
+ "process": "^0.11.10",
+ "punycode": "^1.2.4",
+ "querystring-es3": "^0.2.0",
+ "readable-stream": "^2.3.3",
+ "stream-browserify": "^2.0.1",
+ "stream-http": "^2.7.2",
+ "string_decoder": "^1.0.0",
+ "timers-browserify": "^2.0.4",
+ "tty-browserify": "0.0.0",
+ "url": "^0.11.0",
+ "util": "^0.11.0",
+ "vm-browserify": "^1.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "path-browserify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
+ "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==",
+ "dev": true
+ },
+ "punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "node-releases": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
+ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
+ "dev": true
+ },
+ "normalize-package-data": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+ "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+ "dev": true,
+ "requires": {
+ "hosted-git-info": "^2.1.4",
+ "resolve": "^1.10.0",
+ "semver": "2 || 3 || 4 || 5",
+ "validate-npm-package-license": "^3.0.1"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true
+ },
+ "npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.0.0"
+ }
+ },
+ "npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "dev": true,
+ "requires": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0"
+ }
+ },
+ "num2fraction": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
+ "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==",
+ "dev": true
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true
+ },
+ "object-copy": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
+ "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==",
+ "dev": true,
+ "requires": {
+ "copy-descriptor": "^0.1.0",
+ "define-property": "^0.2.5",
+ "kind-of": "^3.0.3"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ }
+ }
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz",
+ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==",
+ "dev": true
+ },
+ "object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true
+ },
+ "object-visit": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
+ "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==",
+ "dev": true,
+ "requires": {
+ "isobject": "^3.0.0"
+ }
+ },
+ "object.assign": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz",
+ "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "has-symbols": "^1.0.3",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "object.entries": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+ "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "object.fromentries": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+ "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "object.getownpropertydescriptors": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.4.tgz",
+ "integrity": "sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ==",
+ "dev": true,
+ "requires": {
+ "array.prototype.reduce": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.20.1"
+ }
+ },
+ "object.pick": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
+ "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
+ "dev": true,
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "object.values": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+ "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "requires": {
+ "ee-first": "1.1.1"
+ }
+ },
+ "on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+ "dev": true
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "requires": {
+ "mimic-fn": "^2.1.0"
+ }
+ },
+ "open": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz",
+ "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==",
+ "dev": true,
+ "requires": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ }
+ },
+ "os-browserify": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
+ "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==",
+ "dev": true
+ },
+ "os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "dev": true,
+ "optional": true
+ },
+ "p-all": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-all/-/p-all-2.1.0.tgz",
+ "integrity": "sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==",
+ "dev": true,
+ "requires": {
+ "p-map": "^2.0.0"
+ },
+ "dependencies": {
+ "p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true
+ }
+ }
+ },
+ "p-defer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
+ "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==",
+ "dev": true
+ },
+ "p-event": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
+ "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
+ "dev": true,
+ "requires": {
+ "p-timeout": "^3.1.0"
+ }
+ },
+ "p-filter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz",
+ "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==",
+ "dev": true,
+ "requires": {
+ "p-map": "^2.0.0"
+ },
+ "dependencies": {
+ "p-map": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+ "dev": true
+ }
+ }
+ },
+ "p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "dev": true
+ },
+ "p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "requires": {
+ "yocto-queue": "^0.1.0"
+ }
+ },
+ "p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^3.0.2"
+ }
+ },
+ "p-map": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+ "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+ "dev": true,
+ "requires": {
+ "aggregate-error": "^3.0.0"
+ }
+ },
+ "p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "dev": true,
+ "requires": {
+ "p-finally": "^1.0.0"
+ }
+ },
+ "p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true
+ },
+ "pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true
+ },
+ "parallel-transform": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
+ "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==",
+ "dev": true,
+ "requires": {
+ "cyclist": "^1.0.1",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.1.5"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "param-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+ "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+ "dev": true,
+ "requires": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "parse-asn1": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
+ "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==",
+ "dev": true,
+ "requires": {
+ "asn1.js": "^5.2.0",
+ "browserify-aes": "^1.0.0",
+ "evp_bytestokey": "^1.0.0",
+ "pbkdf2": "^3.0.3",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "parse-entities": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
+ "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
+ "dev": true,
+ "requires": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ }
+ },
+ "parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ }
+ },
+ "parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "dev": true
+ },
+ "parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true
+ },
+ "pascal-case": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+ "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+ "dev": true,
+ "requires": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "pascalcase": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
+ "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==",
+ "dev": true
+ },
+ "path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true
+ },
+ "path-dirname": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
+ "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==",
+ "dev": true
+ },
+ "path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true
+ },
+ "path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true
+ },
+ "path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
+ "dev": true
+ },
+ "path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true
+ },
+ "pbkdf2": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
+ "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
+ "dev": true,
+ "requires": {
+ "create-hash": "^1.1.2",
+ "create-hmac": "^1.1.4",
+ "ripemd160": "^2.0.1",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "picocolors": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
+ "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
+ "dev": true
+ },
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true
+ },
+ "pify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+ "dev": true
+ },
+ "pinkie": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+ "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==",
+ "dev": true,
+ "optional": true
+ },
+ "pinkie-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "pinkie": "^2.0.0"
+ }
+ },
+ "pirates": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
+ "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz",
+ "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==",
+ "dev": true,
+ "requires": {
+ "find-up": "^5.0.0"
+ }
+ },
+ "pnp-webpack-plugin": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz",
+ "integrity": "sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==",
+ "dev": true,
+ "requires": {
+ "ts-pnp": "^1.1.6"
+ }
+ },
+ "polished": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz",
+ "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.17.8"
+ }
+ },
+ "posix-character-classes": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
+ "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==",
+ "dev": true
+ },
+ "postcss": {
+ "version": "7.0.39",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
+ "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
+ "dev": true,
+ "requires": {
+ "picocolors": "^0.2.1",
+ "source-map": "^0.6.1"
+ }
+ },
+ "postcss-flexbugs-fixes": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-4.2.1.tgz",
+ "integrity": "sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.26"
+ }
+ },
+ "postcss-loader": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz",
+ "integrity": "sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==",
+ "dev": true,
+ "requires": {
+ "cosmiconfig": "^7.0.0",
+ "klona": "^2.0.4",
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0",
+ "semver": "^7.3.4"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "semver": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "postcss-modules-extract-imports": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
+ "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.5"
+ }
+ },
+ "postcss-modules-local-by-default": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
+ "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^4.1.1",
+ "postcss": "^7.0.32",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "postcss-modules-scope": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
+ "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.6",
+ "postcss-selector-parser": "^6.0.0"
+ }
+ },
+ "postcss-modules-values": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
+ "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^4.0.0",
+ "postcss": "^7.0.6"
+ }
+ },
+ "postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "requires": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "prettier": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.0.tgz",
+ "integrity": "sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==",
+ "dev": true
+ },
+ "pretty-error": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
+ "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.20",
+ "renderkid": "^3.0.0"
+ }
+ },
+ "pretty-hrtime": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+ "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==",
+ "dev": true
+ },
+ "process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "dev": true
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "dev": true
+ },
+ "promise.allsettled": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.5.tgz",
+ "integrity": "sha512-tVDqeZPoBC0SlzJHzWGZ2NKAguVq2oiYj7gbggbiTvH2itHohijTp7njOUA0aQ/nl+0lr/r6egmhoYu63UZ/pQ==",
+ "dev": true,
+ "requires": {
+ "array.prototype.map": "^1.0.4",
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "iterate-value": "^1.0.2"
+ }
+ },
+ "promise.prototype.finally": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/promise.prototype.finally/-/promise.prototype.finally-3.1.3.tgz",
+ "integrity": "sha512-EXRF3fC9/0gz4qkt/f5EP5iW4kj9oFpBICNpCNOb/52+8nlHIX07FPLbi/q4qYBQ1xZqivMzTpNQSnArVASolQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "requires": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ }
+ },
+ "prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "requires": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "property-information": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
+ "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
+ "dev": true,
+ "requires": {
+ "xtend": "^4.0.0"
+ }
+ },
+ "proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "requires": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ }
+ },
+ "prr": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
+ "dev": true
+ },
+ "public-encrypt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
+ "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
+ "dev": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
+ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
+ "dev": true
+ }
+ }
+ },
+ "pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "pumpify": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz",
+ "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==",
+ "dev": true,
+ "requires": {
+ "duplexify": "^3.6.0",
+ "inherits": "^2.0.3",
+ "pump": "^2.0.0"
+ },
+ "dependencies": {
+ "pump": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",
+ "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ }
+ }
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+ "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ },
+ "querystring": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+ "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
+ "dev": true
+ },
+ "querystring-es3": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
+ "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==",
+ "dev": true
+ },
+ "queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true
+ },
+ "ramda": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
+ "integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==",
+ "dev": true
+ },
+ "randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true
+ },
+ "raw-body": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
+ "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
+ "dev": true,
+ "requires": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "dependencies": {
+ "bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true
+ }
+ }
+ },
+ "raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "react": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "dev": true,
+ "peer": true,
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "react-dom": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "dev": true,
+ "peer": true,
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ }
+ },
+ "react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
+ "read-pkg": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
+ "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+ "dev": true,
+ "requires": {
+ "@types/normalize-package-data": "^2.4.0",
+ "normalize-package-data": "^2.5.0",
+ "parse-json": "^5.0.0",
+ "type-fest": "^0.6.0"
+ },
+ "dependencies": {
+ "type-fest": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
+ "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+ "dev": true
+ }
+ }
+ },
+ "read-pkg-up": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+ "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+ "dev": true,
+ "requires": {
+ "find-up": "^4.1.0",
+ "read-pkg": "^5.2.0",
+ "type-fest": "^0.8.1"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ },
+ "type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ },
+ "readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "redent": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
+ "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "indent-string": "^2.1.0",
+ "strip-indent": "^1.0.1"
+ },
+ "dependencies": {
+ "indent-string": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
+ "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "repeating": "^2.0.0"
+ }
+ }
+ }
+ },
+ "regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true
+ },
+ "regenerate-unicode-properties": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+ "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+ "dev": true,
+ "requires": {
+ "regenerate": "^1.4.2"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.9",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
+ "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==",
+ "dev": true
+ },
+ "regenerator-transform": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz",
+ "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.8.4"
+ }
+ },
+ "regex-not": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
+ "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^3.0.2",
+ "safe-regex": "^1.1.0"
+ }
+ },
+ "regexp.prototype.flags": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
+ "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "functions-have-names": "^1.2.2"
+ }
+ },
+ "regexpu-core": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz",
+ "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==",
+ "dev": true,
+ "requires": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.1.0",
+ "regjsgen": "^0.7.1",
+ "regjsparser": "^0.9.1",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.0.0"
+ }
+ },
+ "regjsgen": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz",
+ "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==",
+ "dev": true
+ },
+ "regjsparser": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+ "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+ "dev": true,
+ "requires": {
+ "jsesc": "~0.5.0"
+ },
+ "dependencies": {
+ "jsesc": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+ "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+ "dev": true
+ }
+ }
+ },
+ "relateurl": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+ "dev": true
+ },
+ "remark-external-links": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz",
+ "integrity": "sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==",
+ "dev": true,
+ "requires": {
+ "extend": "^3.0.0",
+ "is-absolute-url": "^3.0.0",
+ "mdast-util-definitions": "^4.0.0",
+ "space-separated-tokens": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "remark-footnotes": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz",
+ "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==",
+ "dev": true
+ },
+ "remark-mdx": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz",
+ "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==",
+ "dev": true,
+ "requires": {
+ "@babel/core": "7.12.9",
+ "@babel/helper-plugin-utils": "7.10.4",
+ "@babel/plugin-proposal-object-rest-spread": "7.12.1",
+ "@babel/plugin-syntax-jsx": "7.12.1",
+ "@mdx-js/util": "1.6.22",
+ "is-alphabetical": "1.0.4",
+ "remark-parse": "8.0.3",
+ "unified": "9.2.0"
+ },
+ "dependencies": {
+ "@babel/core": {
+ "version": "7.12.9",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz",
+ "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/generator": "^7.12.5",
+ "@babel/helper-module-transforms": "^7.12.1",
+ "@babel/helpers": "^7.12.5",
+ "@babel/parser": "^7.12.7",
+ "@babel/template": "^7.12.7",
+ "@babel/traverse": "^7.12.9",
+ "@babel/types": "^7.12.7",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.1",
+ "json5": "^2.1.2",
+ "lodash": "^4.17.19",
+ "resolve": "^1.3.2",
+ "semver": "^5.4.1",
+ "source-map": "^0.5.0"
+ }
+ },
+ "@babel/helper-plugin-utils": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz",
+ "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==",
+ "dev": true
+ },
+ "@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz",
+ "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.0",
+ "@babel/plugin-transform-parameters": "^7.12.1"
+ }
+ },
+ "@babel/plugin-syntax-jsx": {
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz",
+ "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true
+ }
+ }
+ },
+ "remark-parse": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz",
+ "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==",
+ "dev": true,
+ "requires": {
+ "ccount": "^1.0.0",
+ "collapse-white-space": "^1.0.2",
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-whitespace-character": "^1.0.0",
+ "is-word-character": "^1.0.0",
+ "markdown-escapes": "^1.0.0",
+ "parse-entities": "^2.0.0",
+ "repeat-string": "^1.5.4",
+ "state-toggle": "^1.0.0",
+ "trim": "0.0.1",
+ "trim-trailing-lines": "^1.0.0",
+ "unherit": "^1.0.4",
+ "unist-util-remove-position": "^2.0.0",
+ "vfile-location": "^3.0.0",
+ "xtend": "^4.0.1"
+ }
+ },
+ "remark-slug": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/remark-slug/-/remark-slug-6.1.0.tgz",
+ "integrity": "sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==",
+ "dev": true,
+ "requires": {
+ "github-slugger": "^1.0.0",
+ "mdast-util-to-string": "^1.0.0",
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "remark-squeeze-paragraphs": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz",
+ "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==",
+ "dev": true,
+ "requires": {
+ "mdast-squeeze-paragraphs": "^4.0.0"
+ }
+ },
+ "remove-trailing-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
+ "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==",
+ "dev": true
+ },
+ "renderkid": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
+ "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==",
+ "dev": true,
+ "requires": {
+ "css-select": "^4.1.3",
+ "dom-converter": "^0.2.0",
+ "htmlparser2": "^6.1.0",
+ "lodash": "^4.17.21",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "repeat-element": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
+ "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==",
+ "dev": true
+ },
+ "repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
+ "dev": true
+ },
+ "repeating": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
+ "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-finite": "^1.0.0"
+ }
+ },
+ "resolve": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+ "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+ "dev": true,
+ "requires": {
+ "is-core-module": "^2.9.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ }
+ },
+ "resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true
+ },
+ "resolve-url": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+ "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==",
+ "dev": true
+ },
+ "ret": {
+ "version": "0.1.15",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+ "dev": true
+ },
+ "reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "ripemd160": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
+ "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
+ "dev": true,
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "rsvp": {
+ "version": "4.8.5",
+ "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
+ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==",
+ "dev": true
+ },
+ "run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "requires": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "run-queue": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
+ "integrity": "sha512-ntymy489o0/QQplUDnpYAYUsO50K9SBrIVaKCWDOJzYJts0f9WH9RFJkyagebkw5+y1oi00R7ynNW/d12GBumg==",
+ "dev": true,
+ "requires": {
+ "aproba": "^1.1.1"
+ },
+ "dependencies": {
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "dev": true
+ }
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "safe-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+ "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==",
+ "dev": true,
+ "requires": {
+ "ret": "~0.1.10"
+ }
+ },
+ "safe-regex-test": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
+ "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "is-regex": "^1.1.4"
+ }
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "sane": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz",
+ "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==",
+ "dev": true,
+ "requires": {
+ "@cnakazawa/watch": "^1.0.3",
+ "anymatch": "^2.0.0",
+ "capture-exit": "^2.0.0",
+ "exec-sh": "^0.3.2",
+ "execa": "^1.0.0",
+ "fb-watchman": "^2.0.0",
+ "micromatch": "^3.1.4",
+ "minimist": "^1.1.1",
+ "walker": "~1.0.5"
+ },
+ "dependencies": {
+ "anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "requires": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cross-spawn": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+ "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+ "dev": true,
+ "requires": {
+ "nice-try": "^1.0.4",
+ "path-key": "^2.0.1",
+ "semver": "^5.5.0",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ }
+ },
+ "execa": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+ "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^6.0.0",
+ "get-stream": "^4.0.0",
+ "is-stream": "^1.1.0",
+ "npm-run-path": "^2.0.0",
+ "p-finally": "^1.0.0",
+ "signal-exit": "^3.0.0",
+ "strip-eof": "^1.0.0"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "get-stream": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+ "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+ "dev": true,
+ "requires": {
+ "pump": "^3.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==",
+ "dev": true
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "requires": {
+ "remove-trailing-separator": "^1.0.1"
+ }
+ },
+ "npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==",
+ "dev": true,
+ "requires": {
+ "path-key": "^2.0.0"
+ }
+ },
+ "path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==",
+ "dev": true
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ },
+ "shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^1.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
+ "scheduler": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "dev": true,
+ "peer": true,
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "schema-utils": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
+ "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.5",
+ "ajv": "^6.12.4",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "semver": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "dev": true
+ },
+ "send": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
+ "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
+ "dev": true,
+ "requires": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ }
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ }
+ }
+ },
+ "serialize-javascript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+ "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "serve-favicon": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz",
+ "integrity": "sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==",
+ "dev": true,
+ "requires": {
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "ms": "2.1.1",
+ "parseurl": "~1.3.2",
+ "safe-buffer": "5.1.1"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
+ "dev": true
+ },
+ "safe-buffer": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
+ "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
+ "dev": true
+ }
+ }
+ },
+ "serve-static": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
+ "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
+ "dev": true,
+ "requires": {
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.18.0"
+ }
+ },
+ "set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "dev": true
+ },
+ "set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ }
+ }
+ },
+ "setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true
+ },
+ "setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
+ "sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^6.0.2"
+ }
+ },
+ "shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^3.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true
+ },
+ "side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ }
+ },
+ "signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true
+ },
+ "slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true
+ },
+ "snapdragon": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
+ "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==",
+ "dev": true,
+ "requires": {
+ "base": "^0.11.1",
+ "debug": "^2.2.0",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "map-cache": "^0.2.2",
+ "source-map": "^0.5.6",
+ "source-map-resolve": "^0.5.0",
+ "use": "^3.1.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "dev": true
+ }
+ }
+ },
+ "snapdragon-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz",
+ "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==",
+ "dev": true,
+ "requires": {
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.0",
+ "snapdragon-util": "^3.0.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ }
+ }
+ },
+ "snapdragon-util": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz",
+ "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.2.0"
+ },
+ "dependencies": {
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "source-list-map": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
+ "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true
+ },
+ "source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true
+ },
+ "source-map-resolve": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
+ "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+ "dev": true,
+ "requires": {
+ "atob": "^2.1.2",
+ "decode-uri-component": "^0.2.0",
+ "resolve-url": "^0.2.1",
+ "source-map-url": "^0.4.0",
+ "urix": "^0.1.0"
+ }
+ },
+ "source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "source-map-url": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
+ "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+ "dev": true
+ },
+ "space-separated-tokens": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
+ "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
+ "dev": true
+ },
+ "spdx-correct": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+ "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+ "dev": true,
+ "requires": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "spdx-exceptions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+ "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+ "dev": true
+ },
+ "spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "requires": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "spdx-license-ids": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz",
+ "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==",
+ "dev": true
+ },
+ "split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^3.0.0"
+ }
+ },
+ "sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ },
+ "ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "dev": true,
+ "requires": {
+ "minipass": "^3.1.1"
+ }
+ },
+ "stable": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
+ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
+ "dev": true
+ },
+ "state-toggle": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",
+ "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==",
+ "dev": true
+ },
+ "static-extend": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
+ "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==",
+ "dev": true,
+ "requires": {
+ "define-property": "^0.2.5",
+ "object-copy": "^0.1.0"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==",
+ "dev": true,
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "dev": true,
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ }
+ },
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "dev": true
+ }
+ }
+ },
+ "statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "dev": true
+ },
+ "store2": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.2.tgz",
+ "integrity": "sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==",
+ "dev": true
+ },
+ "stream-browserify": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
+ "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==",
+ "dev": true,
+ "requires": {
+ "inherits": "~2.0.1",
+ "readable-stream": "^2.0.2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "stream-each": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
+ "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "stream-shift": "^1.0.0"
+ }
+ },
+ "stream-http": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz",
+ "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==",
+ "dev": true,
+ "requires": {
+ "builtin-status-codes": "^3.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.3.6",
+ "to-arraybuffer": "^1.0.0",
+ "xtend": "^4.0.0"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "stream-shift": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
+ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==",
+ "dev": true
+ },
+ "string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.2.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "string.prototype.matchall": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+ "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "regexp.prototype.flags": "^1.4.1",
+ "side-channel": "^1.0.4"
+ }
+ },
+ "string.prototype.padend": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz",
+ "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "string.prototype.padstart": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/string.prototype.padstart/-/string.prototype.padstart-3.1.3.tgz",
+ "integrity": "sha512-NZydyOMtYxpTjGqp0VN5PYUF/tsU15yDMZnUdj16qRUIUiMJkHHSDElYyQFrMu+/WloTpA7MQSiADhBicDfaoA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "string.prototype.trimend": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz",
+ "integrity": "sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ }
+ },
+ "string.prototype.trimstart": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz",
+ "integrity": "sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "es-abstract": "^1.19.5"
+ }
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-utf8": "^0.2.0"
+ }
+ },
+ "strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
+ "dev": true
+ },
+ "strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true
+ },
+ "strip-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz",
+ "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "get-stdin": "^4.0.1"
+ }
+ },
+ "style-loader": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
+ "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "style-to-object": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
+ "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
+ "dev": true,
+ "requires": {
+ "inline-style-parser": "0.1.1"
+ }
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ },
+ "supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true
+ },
+ "symbol.prototype.description": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/symbol.prototype.description/-/symbol.prototype.description-1.0.5.tgz",
+ "integrity": "sha512-x738iXRYsrAt9WBhRCVG5BtIC3B7CUkFwbHW2zOvGtwM33s7JjrCDyq8V0zgMYVb5ymsL8+qkzzpANH63CPQaQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-symbol-description": "^1.0.0",
+ "has-symbols": "^1.0.2",
+ "object.getownpropertydescriptors": "^2.1.2"
+ }
+ },
+ "synchronous-promise": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.16.tgz",
+ "integrity": "sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==",
+ "dev": true
+ },
+ "tapable": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
+ "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
+ "dev": true
+ },
+ "tar": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+ "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
+ "dev": true,
+ "requires": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^3.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ }
+ },
+ "telejson": {
+ "version": "6.0.8",
+ "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz",
+ "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==",
+ "dev": true,
+ "requires": {
+ "@types/is-function": "^1.0.0",
+ "global": "^4.4.0",
+ "is-function": "^1.0.2",
+ "is-regex": "^1.1.2",
+ "is-symbol": "^1.0.3",
+ "isobject": "^4.0.0",
+ "lodash": "^4.17.21",
+ "memoizerific": "^1.11.3"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
+ "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==",
+ "dev": true
+ }
+ }
+ },
+ "terser": {
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz",
+ "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/source-map": "^0.3.2",
+ "acorn": "^8.5.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ }
+ }
+ },
+ "terser-webpack-plugin": {
+ "version": "5.3.6",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz",
+ "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/trace-mapping": "^0.3.14",
+ "jest-worker": "^27.4.5",
+ "schema-utils": "^3.1.1",
+ "serialize-javascript": "^6.0.0",
+ "terser": "^5.14.1"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "jest-worker": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
+ }
+ },
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "requires": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ }
+ },
+ "through2": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
+ "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
+ "dev": true,
+ "requires": {
+ "readable-stream": "~2.3.6",
+ "xtend": "~4.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
+ "timers-browserify": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
+ "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
+ "dev": true,
+ "requires": {
+ "setimmediate": "^1.0.4"
+ }
+ },
+ "tmpl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+ "dev": true
+ },
+ "to-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==",
+ "dev": true
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true
+ },
+ "to-object-path": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
+ "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==",
+ "dev": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "to-regex": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
+ "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==",
+ "dev": true,
+ "requires": {
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "regex-not": "^1.0.2",
+ "safe-regex": "^1.1.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ },
+ "toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true
+ },
+ "tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "dev": true
+ },
+ "trim": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
+ "integrity": "sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==",
+ "dev": true
+ },
+ "trim-newlines": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
+ "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==",
+ "dev": true,
+ "optional": true
+ },
+ "trim-trailing-lines": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz",
+ "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==",
+ "dev": true
+ },
+ "trough": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
+ "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
+ "dev": true
+ },
+ "ts-dedent": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
+ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
+ "dev": true
+ },
+ "ts-pnp": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
+ "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==",
+ "dev": true
+ },
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
+ "dev": true
+ },
+ "tty-browserify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==",
+ "dev": true
+ },
+ "type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true
+ },
+ "type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "requires": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ }
+ },
+ "typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "dev": true
+ },
+ "typedarray-to-buffer": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+ "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+ "dev": true,
+ "requires": {
+ "is-typedarray": "^1.0.0"
+ }
+ },
+ "typescript": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
+ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+ "dev": true,
+ "peer": true
+ },
+ "uglify-js": {
+ "version": "3.17.2",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.2.tgz",
+ "integrity": "sha512-bbxglRjsGQMchfvXZNusUcYgiB9Hx2K4AHYXQy2DITZ9Rd+JzhX7+hoocE5Winr7z2oHvPsekkBwXtigvxevXg==",
+ "dev": true,
+ "optional": true
+ },
+ "unbox-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
+ "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.0.3",
+ "which-boxed-primitive": "^1.0.2"
+ }
+ },
+ "unfetch": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
+ "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
+ "dev": true
+ },
+ "unherit": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz",
+ "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+ "dev": true
+ },
+ "unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "requires": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ }
+ },
+ "unicode-match-property-value-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==",
+ "dev": true
+ },
+ "unicode-property-aliases-ecmascript": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+ "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+ "dev": true
+ },
+ "unified": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz",
+ "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==",
+ "dev": true,
+ "requires": {
+ "bail": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-buffer": "^2.0.0",
+ "is-plain-obj": "^2.0.0",
+ "trough": "^1.0.0",
+ "vfile": "^4.0.0"
+ }
+ },
+ "union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "dev": true,
+ "requires": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ },
+ "dependencies": {
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ }
+ }
+ },
+ "unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "dev": true,
+ "requires": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "dev": true,
+ "requires": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
+ "unist-builder": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz",
+ "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==",
+ "dev": true
+ },
+ "unist-util-generated": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz",
+ "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==",
+ "dev": true
+ },
+ "unist-util-is": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz",
+ "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==",
+ "dev": true
+ },
+ "unist-util-position": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz",
+ "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==",
+ "dev": true
+ },
+ "unist-util-remove": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz",
+ "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==",
+ "dev": true,
+ "requires": {
+ "unist-util-is": "^4.0.0"
+ }
+ },
+ "unist-util-remove-position": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz",
+ "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==",
+ "dev": true,
+ "requires": {
+ "unist-util-visit": "^2.0.0"
+ }
+ },
+ "unist-util-stringify-position": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
+ "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.2"
+ }
+ },
+ "unist-util-visit": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz",
+ "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0",
+ "unist-util-visit-parents": "^3.0.0"
+ }
+ },
+ "unist-util-visit-parents": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz",
+ "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "unist-util-is": "^4.0.0"
+ }
+ },
+ "universalify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+ "dev": true
+ },
+ "unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true
+ },
+ "unset-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
+ "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==",
+ "dev": true,
+ "requires": {
+ "has-value": "^0.3.1",
+ "isobject": "^3.0.0"
+ },
+ "dependencies": {
+ "has-value": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz",
+ "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==",
+ "dev": true,
+ "requires": {
+ "get-value": "^2.0.3",
+ "has-values": "^0.1.4",
+ "isobject": "^2.0.0"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+ "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==",
+ "dev": true,
+ "requires": {
+ "isarray": "1.0.0"
+ }
+ }
+ }
+ },
+ "has-values": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz",
+ "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ }
+ }
+ },
+ "untildify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/untildify/-/untildify-2.1.0.tgz",
+ "integrity": "sha512-sJjbDp2GodvkB0FZZcn7k6afVisqX5BZD7Yq3xp4nN2O15BBK0cLm3Vwn2vQaF7UDS0UUsrQMkkplmDI5fskig==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "os-homedir": "^1.0.0"
+ }
+ },
+ "upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "optional": true
+ },
+ "update-browserslist-db": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz",
+ "integrity": "sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==",
+ "dev": true,
+ "requires": {
+ "escalade": "^3.1.1",
+ "picocolors": "^1.0.0"
+ },
+ "dependencies": {
+ "picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ }
+ }
+ },
+ "uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "urix": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+ "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==",
+ "dev": true
+ },
+ "url": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+ "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
+ "dev": true,
+ "requires": {
+ "punycode": "1.3.2",
+ "querystring": "0.2.0"
+ },
+ "dependencies": {
+ "punycode": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+ "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
+ "dev": true
+ }
+ }
+ },
+ "url-loader": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
+ "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^2.0.0",
+ "mime-types": "^2.1.27",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "use": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
+ "dev": true
+ },
+ "util": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
+ "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==",
+ "dev": true,
+ "requires": {
+ "inherits": "2.0.3"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "dev": true
+ }
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "util.promisify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",
+ "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==",
+ "dev": true,
+ "requires": {
+ "define-properties": "^1.1.2",
+ "object.getownpropertydescriptors": "^2.0.3"
+ }
+ },
+ "utila": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
+ "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
+ "dev": true
+ },
+ "utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true
+ },
+ "uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+ "dev": true
+ },
+ "uuid-browser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/uuid-browser/-/uuid-browser-3.1.0.tgz",
+ "integrity": "sha512-dsNgbLaTrd6l3MMxTtouOCFw4CBFc/3a+GgYA2YyrJvyQ1u6q4pcu3ktLoUZ/VN/Aw9WsauazbgsgdfVWgAKQg==",
+ "dev": true
+ },
+ "validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "requires": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true
+ },
+ "vfile": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz",
+ "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "is-buffer": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0",
+ "vfile-message": "^2.0.0"
+ }
+ },
+ "vfile-location": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz",
+ "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==",
+ "dev": true
+ },
+ "vfile-message": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz",
+ "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==",
+ "dev": true,
+ "requires": {
+ "@types/unist": "^2.0.0",
+ "unist-util-stringify-position": "^2.0.0"
+ }
+ },
+ "vm-browserify": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
+ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
+ "dev": true
+ },
+ "walker": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+ "dev": true,
+ "requires": {
+ "makeerror": "1.0.12"
+ }
+ },
+ "watchpack": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+ "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
+ "dev": true,
+ "requires": {
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.1.2"
+ }
+ },
+ "watchpack-chokidar2": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz",
+ "integrity": "sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "chokidar": "^2.1.8"
+ },
+ "dependencies": {
+ "anymatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
+ "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "micromatch": "^3.1.4",
+ "normalize-path": "^2.1.1"
+ },
+ "dependencies": {
+ "normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "remove-trailing-separator": "^1.0.1"
+ }
+ }
+ }
+ },
+ "binary-extensions": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
+ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==",
+ "dev": true,
+ "optional": true
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "chokidar": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
+ "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "anymatch": "^2.0.0",
+ "async-each": "^1.0.1",
+ "braces": "^2.3.2",
+ "fsevents": "^1.2.7",
+ "glob-parent": "^3.1.0",
+ "inherits": "^2.0.3",
+ "is-binary-path": "^1.0.0",
+ "is-glob": "^4.0.0",
+ "normalize-path": "^3.0.0",
+ "path-is-absolute": "^1.0.0",
+ "readdirp": "^2.2.1",
+ "upath": "^1.1.1"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "fsevents": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
+ "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
+ },
+ "glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-glob": "^3.1.0",
+ "path-dirname": "^1.0.0"
+ },
+ "dependencies": {
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extglob": "^2.1.0"
+ }
+ }
+ }
+ },
+ "is-binary-path": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz",
+ "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "binary-extensions": "^1.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true,
+ "optional": true
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "optional": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "optional": true
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "readdirp": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz",
+ "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.11",
+ "micromatch": "^3.1.10",
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ }
+ }
+ },
+ "web-namespaces": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz",
+ "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==",
+ "dev": true
+ },
+ "webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "dev": true
+ },
+ "webpack": {
+ "version": "5.74.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
+ "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
+ "dev": true,
+ "requires": {
+ "@types/eslint-scope": "^3.7.3",
+ "@types/estree": "^0.0.51",
+ "@webassemblyjs/ast": "1.11.1",
+ "@webassemblyjs/wasm-edit": "1.11.1",
+ "@webassemblyjs/wasm-parser": "1.11.1",
+ "acorn": "^8.7.1",
+ "acorn-import-assertions": "^1.7.6",
+ "browserslist": "^4.14.5",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^5.10.0",
+ "es-module-lexer": "^0.9.0",
+ "eslint-scope": "5.1.1",
+ "events": "^3.2.0",
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.2.9",
+ "json-parse-even-better-errors": "^2.3.1",
+ "loader-runner": "^4.2.0",
+ "mime-types": "^2.1.27",
+ "neo-async": "^2.6.2",
+ "schema-utils": "^3.1.0",
+ "tapable": "^2.1.1",
+ "terser-webpack-plugin": "^5.1.3",
+ "watchpack": "^2.4.0",
+ "webpack-sources": "^3.2.3"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ },
+ "tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true
+ }
+ }
+ },
+ "webpack-dev-middleware": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-4.3.0.tgz",
+ "integrity": "sha512-PjwyVY95/bhBh6VUqt6z4THplYcsvQ8YNNBTBM873xLVmw8FLeALn0qurHbs9EmcfhzQis/eoqypSnZeuUz26w==",
+ "dev": true,
+ "requires": {
+ "colorette": "^1.2.2",
+ "mem": "^8.1.1",
+ "memfs": "^3.2.2",
+ "mime-types": "^2.1.30",
+ "range-parser": "^1.2.1",
+ "schema-utils": "^3.0.0"
+ },
+ "dependencies": {
+ "schema-utils": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+ "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+ "dev": true,
+ "requires": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ }
+ }
+ }
+ },
+ "webpack-hot-middleware": {
+ "version": "2.25.2",
+ "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.2.tgz",
+ "integrity": "sha512-CVgm3NAQyfdIonRvXisRwPTUYuSbyZ6BY7782tMeUzWOO7RmVI2NaBYuCp41qyD4gYCkJyTneAJdK69A13B0+A==",
+ "dev": true,
+ "requires": {
+ "ansi-html-community": "0.0.8",
+ "html-entities": "^2.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "webpack-log": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz",
+ "integrity": "sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==",
+ "dev": true,
+ "requires": {
+ "ansi-colors": "^3.0.0",
+ "uuid": "^3.3.2"
+ }
+ },
+ "webpack-sources": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
+ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
+ "dev": true
+ },
+ "webpack-virtual-modules": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.5.tgz",
+ "integrity": "sha512-8bWq0Iluiv9lVf9YaqWQ9+liNgXSHICm+rg544yRgGYaR8yXZTVBaHZkINZSB2yZSWo4b0F6MIxqJezVfOEAlg==",
+ "dev": true
+ },
+ "whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dev": true,
+ "requires": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "which-boxed-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+ "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+ "dev": true,
+ "requires": {
+ "is-bigint": "^1.0.1",
+ "is-boolean-object": "^1.1.0",
+ "is-number-object": "^1.0.4",
+ "is-string": "^1.0.5",
+ "is-symbol": "^1.0.3"
+ }
+ },
+ "wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "dev": true,
+ "requires": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "dev": true,
+ "requires": {
+ "string-width": "^4.0.0"
+ }
+ },
+ "wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true
+ },
+ "worker-farm": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz",
+ "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==",
+ "dev": true,
+ "requires": {
+ "errno": "~0.1.7"
+ }
+ },
+ "worker-rpc": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/worker-rpc/-/worker-rpc-0.1.1.tgz",
+ "integrity": "sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==",
+ "dev": true,
+ "requires": {
+ "microevent.ts": "~0.1.1"
+ }
+ },
+ "wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ }
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "write-file-atomic": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+ "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+ "dev": true,
+ "requires": {
+ "imurmurhash": "^0.1.4",
+ "is-typedarray": "^1.0.0",
+ "signal-exit": "^3.0.2",
+ "typedarray-to-buffer": "^3.1.5"
+ }
+ },
+ "ws": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz",
+ "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==",
+ "dev": true,
+ "requires": {}
+ },
+ "x-default-browser": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/x-default-browser/-/x-default-browser-0.4.0.tgz",
+ "integrity": "sha512-7LKo7RtWfoFN/rHx1UELv/2zHGMx8MkZKDq1xENmOCTkfIqZJ0zZ26NEJX8czhnPXVcqS0ARjjfJB+eJ0/5Cvw==",
+ "dev": true,
+ "requires": {
+ "default-browser-id": "^1.0.4"
+ }
+ },
+ "xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true
+ },
+ "y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "dev": true
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true
+ },
+ "yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true
+ },
+ "zwitch": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
+ "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==",
+ "dev": true
+ }
+ }
+}
diff --git a/comm/mail/components/storybook/package.json b/comm/mail/components/storybook/package.json
new file mode 100644
index 0000000000..a414da8101
--- /dev/null
+++ b/comm/mail/components/storybook/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "mail-storybook",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build-storybook": "build-storybook",
+ "storybook": "start-storybook -p 5703 --no-open"
+ },
+ "author": "",
+ "license": "MPL-2.0",
+ "private": true,
+ "dependencies": {},
+ "devDependencies": {
+ "@babel/core": "^7.19.3",
+ "@fluent/bundle": "^0.17.1",
+ "@fluent/dom": "^0.8.1",
+ "@storybook/addon-actions": "^6.5.12",
+ "@storybook/addon-essentials": "^6.5.12",
+ "@storybook/addon-links": "^6.5.12",
+ "@storybook/builder-webpack5": "^6.5.12",
+ "@storybook/manager-webpack5": "^6.5.12",
+ "@storybook/web-components": "^6.5.12",
+ "babel-loader": "^8.2.5",
+ "lit": "^2.3.1"
+ }
+}
diff --git a/comm/mail/components/storybook/stories/colors.stories.mjs b/comm/mail/components/storybook/stories/colors.stories.mjs
new file mode 100644
index 0000000000..a61c49a560
--- /dev/null
+++ b/comm/mail/components/storybook/stories/colors.stories.mjs
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html } from "lit";
+import "mail/themes/shared/mail/colors.css"; //eslint-disable-line import/no-unassigned-import
+
+const FORMATTER = new Intl.NumberFormat("en", {
+ numberingSystem: "latn",
+ style: "decimal",
+ minimumIntegerDigits: 2,
+ maximumFractionDigits: 0,
+});
+
+const VARIANT_RANGE = {
+ white: [],
+ gray: [10, 90],
+ red: [30, 90],
+ orange: [30, 90],
+ amber: [30, 90],
+ yellow: [30, 90],
+ green: [30, 90],
+ teal: [30, 90],
+ blue: [0, 90],
+ purple: [0, 90],
+ magenta: [30, 90],
+ brown: [30, 90],
+ ink: [30, 90],
+};
+
+const ALL_COLORS = Object.entries(VARIANT_RANGE).flatMap(([color, range]) => {
+ if (!range.length) {
+ return [color];
+ }
+ const colors = [];
+ for (let variant = range[0]; variant <= range[1]; variant += 10) {
+ colors.push(`${color}-${FORMATTER.format(variant)}`);
+ }
+ return colors;
+});
+
+export default {
+ title: "Design System/Colors",
+ argTypes: {
+ color1: {
+ options: ALL_COLORS,
+ control: { type: "select" },
+ },
+ color2: {
+ options: ALL_COLORS,
+ control: { type: "select" },
+ },
+ },
+};
+
+function createColor(colorName) {
+ const cssVariableName = `--color-${colorName}`;
+ const color = document.createElement("div");
+ color.style.padding = "0.5em";
+ const preview = document.createElement("div");
+ preview.style.width = "200px";
+ preview.style.height = "50px";
+ preview.style.background = `var(${cssVariableName})`;
+ const legend = document.createElement("span");
+ legend.textContent = cssVariableName;
+ color.append(preview, legend);
+ return color;
+}
+
+export const Colors = {
+ render: () => {
+ const container = document.createElement("div");
+ container.append(...ALL_COLORS.map(createColor));
+ return container;
+ },
+};
+
+const Template = ({ color1, color2 }) => html`
+ <div style="display: grid">
+ <div style="height: 40vh; background: var(--color-${color1})"></div>
+ <div style="height: 40vh; background: var(--color-${color2})"></div>
+ </div>
+`;
+
+export const CompareColors = Template.bind({});
+CompareColors.args = {
+ color1: "white",
+ color2: "ink-90",
+};
diff --git a/comm/mail/components/storybook/stories/pane-splitter.stories.mjs b/comm/mail/components/storybook/stories/pane-splitter.stories.mjs
new file mode 100644
index 0000000000..6cc0418e06
--- /dev/null
+++ b/comm/mail/components/storybook/stories/pane-splitter.stories.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html } from "lit";
+import "mail/base/content/widgets/pane-splitter.js"; //eslint-disable-line import/no-unassigned-import
+
+export default {
+ title: "Widgets/Pane Splitter",
+ argTypes: {
+ resizeDirection: {
+ options: ["", "vertical", "horizontal"],
+ control: { type: "radio" },
+ },
+ },
+};
+
+const Template = ({ resizeDirection, collapseWidth, collapseHeight }) => html`
+ <style>
+ hr[is="pane-splitter"] {
+ border: none;
+ z-index: 1;
+ margin: ${resizeDirection === "horizontal" ? "0 -3px" : "-3px 0"};
+ opacity: .4;
+ background-color: red;
+ }
+
+ .wrapper {
+ display: inline-grid;
+ grid-template-${
+ resizeDirection === "horizontal" ? "columns" : "rows"
+ }: minmax(auto, var(--splitter-${
+ resizeDirection === "horizontal" ? "width" : "height"
+})) 0 auto;
+ width: 500px;
+ height: 500px;
+ margin: 1em;
+ --splitter-width: 200px;
+ --splitter-height: 200px;
+ }
+ </style>
+ <div class="wrapper">
+ <div id="resizeme" style="background: lightblue"></div>
+ <hr is="pane-splitter"
+ resize-direction="${resizeDirection}"
+ resize-id="resizeme"
+ collapse-width="${collapseWidth}"
+ collapse-height="${collapseHeight}"
+ id="splitter"
+ ></hr>
+ <div id="fill" style="background: lightslategrey"></div>
+ </div>
+`;
+
+export const PaneSplitter = Template.bind({});
+PaneSplitter.args = {
+ resizeDirection: "",
+ collapseWidth: 0,
+ collapseHeight: 0,
+};
diff --git a/comm/mail/components/storybook/stories/search-bar.stories.mjs b/comm/mail/components/storybook/stories/search-bar.stories.mjs
new file mode 100644
index 0000000000..40bf74f28f
--- /dev/null
+++ b/comm/mail/components/storybook/stories/search-bar.stories.mjs
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html } from "lit";
+import { action } from "@storybook/addon-actions";
+import "mail/components/unifiedtoolbar/content/search-bar.mjs"; //eslint-disable-line import/no-unassigned-import
+
+export default {
+ title: "Widgets/Search Bar",
+};
+
+export const SearchBar = () => html`
+ <template id="searchBarTemplate">
+ <form>
+ <input type="search" placeholder="" required="required" />
+ <div aria-hidden="true"><slot name="placeholder"></slot></div>
+ <button class="button button-flat icon-button">
+ <slot name="button"></slot>
+ </button>
+ </form>
+ </template>
+ <search-bar
+ @search="${action("search")}"
+ @autocomplete="${action("autocomplete")}"
+ >
+ <span slot="placeholder"
+ >Search Field Placeholder <kbd>Ctrl</kbd> + <kbd>K</kbd>
+ </span>
+ <img
+ alt="Search"
+ slot="button"
+ class="search-button"
+ src="chrome://messenger/skin/icons/new/compact/search.svg"
+ />
+ </search-bar>
+`;
diff --git a/comm/mail/components/telemetry/Events.yaml b/comm/mail/components/telemetry/Events.yaml
new file mode 100644
index 0000000000..bc3772ee46
--- /dev/null
+++ b/comm/mail/components/telemetry/Events.yaml
@@ -0,0 +1,25 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This file contains Thunderbird-specific telemetry Event definitions, which
+# are added on top of the Firefox ones (in /toolkit/components/telemetry).
+# To avoid name clashes, all the Thunderbird events will be under a "tb"
+# category.
+
+# A category used for unit tests.
+# Under normal operation, these won't be invoked.
+tb.test:
+ test:
+ objects: ["object1", "object2", "object3"]
+ bug_numbers: [1427877]
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes: ["main"]
+ description: This is a test entry for Telemetry.
+ expiry_version: never
+ extra_keys:
+ key1: This is just a test description.
+ products:
+ - thunderbird
+
diff --git a/comm/mail/components/telemetry/Histograms.json b/comm/mail/components/telemetry/Histograms.json
new file mode 100644
index 0000000000..568887be1b
--- /dev/null
+++ b/comm/mail/components/telemetry/Histograms.json
@@ -0,0 +1,40 @@
+{
+ "TELEMETRY_TEST_TB_CATEGORICAL": {
+ "record_in_processes": ["main", "content"],
+ "products": ["thunderbird"],
+ "alert_emails": ["telemetry-client-dev@thunderbird.net"],
+ "bug_numbers": [1427877],
+ "expires_in_version": "never",
+ "kind": "categorical",
+ "labels": ["CommonLabel", "Label2", "Label3"],
+ "description": "A testing histogram; not meant to be touched"
+ },
+ "TB_COMPOSE_TYPE": {
+ "record_in_processes": ["main"],
+ "products": ["thunderbird"],
+ "alert_emails": ["telemetry-client-dev@thunderbird.net"],
+ "bug_numbers": [1615996],
+ "expires_in_version": "never",
+ "kind": "categorical",
+ "labels": [
+ "New",
+ "Reply",
+ "ReplyAll",
+ "ForwardAsAttachment",
+ "ForwardInline",
+ "NewsPost",
+ "ReplyToSender",
+ "ReplyToGroup",
+ "ReplyToSenderGroup",
+ "Draft",
+ "Template",
+ "MailToUrl",
+ "ReplyWithTemplate",
+ "ReplyToList",
+ "Redirect",
+ "EditAsNew",
+ "EditTemplate"
+ ],
+ "description": "Histogram of different message compose types used"
+ }
+}
diff --git a/comm/mail/components/telemetry/README.md b/comm/mail/components/telemetry/README.md
new file mode 100644
index 0000000000..45b7dda9fe
--- /dev/null
+++ b/comm/mail/components/telemetry/README.md
@@ -0,0 +1,175 @@
+# Notes on telemetry in Thunderbird
+
+## Hooking into the build process
+
+The comm-central probe definitions in this directory (`Events.yaml`,
+`Scalars.yaml` and `Histograms.json`) are used _in addition_ to
+their mozilla-central counterparts (in `toolkit/components/telemetry/`).
+
+As part of the mozilla-central telemetry build process, scripts are used to
+generate the C++ files which define the probe registry (enums, string tables
+etc).
+
+Because of this code generation, the extra comm-central probe definitions
+need to be included when the mozilla-central telemetry is built.
+
+This is done by setting `MOZ_TELEMETRY_EXTRA_*` config values. You can
+see these in `comm/mail/moz.configure`.
+These config values are used by `toolkit/components/telemetry/moz.build`
+(mozilla-central) to pass the extra probe definitions to the code
+generation scripts.
+
+The build scripts can be found under `toolkit/components/telemetry/build_scripts`.
+They are written in Python.
+
+## Naming probes
+
+To avoid clashing with the mozilla-central probes, we'll be pretty liberal
+about slapping on prefixes to our definitions.
+
+For Events and Scalars, we keep everything under `tb.`.
+
+For Histograms, we use a `TB_` or `TELEMETRY_TEST_TB_` prefix.
+
+(Why not just `TB_`? Because the telemetry test helper functions
+`getSnapshotForHistograms()`/`getSnapshotForKeyedHistograms()` have an option
+to filter out histograms with a `TELEMETRY_TEST_` prefix).
+
+## Compile-time switches
+
+Telemetry is not compiled in by default. You need to add the following line
+to your mozconfig:
+
+ export MOZ_TELEMETRY_REPORTING=1
+
+The nightly and release configs have this setting already (`$ grep -r MOZ_TELEMETRY_ mail/config/mozconfigs`).
+
+## Runtime prefs for testing
+
+There are a few `user.js` settings you'll want to set up for enabling telemetry local builds:
+
+### Send telemetry to a local server
+
+You'll want to set the telemetry end point to a locally-running http server, eg:
+```
+user_pref("toolkit.telemetry.server", "http://localhost:12345");
+user_pref("toolkit.telemetry.server_owner", "TimmyTestfish");
+user_pref("datareporting.healthreport.uploadEnabled",true);
+```
+
+For a simple test server, try https://github.com/mozilla/gzipServer
+(or alternatively https://github.com/bcampbell/webhole).
+
+### Override the official-build-only check
+
+```
+user_pref("toolkit.telemetry.send.overrideOfficialCheck", true);
+```
+
+Without toolkit.telemetry.send.overrideOfficialCheck set, telemetry is only sent for official builds.
+
+### Bypass data policy checks
+
+The data policy checks make sure the user has been shown and
+has accepted the data policy. Bypass them with:
+
+```
+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification",true);
+user_pref("datareporting.policy.dataSubmissionEnabled", true);
+```
+
+### Enable telemetry tracing
+
+```
+user_pref("toolkit.telemetry.log.level", "Trace");
+```
+
+The output will show up on the DevTools console:
+
+ Menu => "Tools" => "Developer Tools" => "Error Console" (CTRL+SHIFT+J)
+
+If pings aren't showing up, look there for clues.
+
+To log to stdout as well as the console:
+```
+user_pref("toolkit.telemetry.log.dump", true);
+```
+
+### Reduce submission interval
+
+For testing it can be handy to reduce down the submission interval (it's
+usually on the order of hours), eg:
+```
+user_pref("services.sync.telemetry.submissionInterval", 20); // in seconds
+```
+
+### Example user.js file
+
+All the above suggestions in one go, for `$PROFILE/user.js`:
+
+```
+user_pref("toolkit.telemetry.server", "http://localhost:12345");
+user_pref("toolkit.telemetry.server_owner", "TimmyTestfish");
+user_pref("toolkit.telemetry.log.level", "Trace");
+user_pref("toolkit.telemetry.log.dump", true);
+user_pref("toolkit.telemetry.send.overrideOfficialCheck", true);
+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification",true);
+user_pref("services.sync.telemetry.submissionInterval", 20);
+user_pref("datareporting.policy.dataSubmissionEnabled", true);
+user_pref("datareporting.healthreport.uploadEnabled",true);
+```
+
+## Troubleshooting
+
+### Sending test pings
+
+From the DevTools console, you can send an immediate test ping:
+
+```
+const { TelemetrySession } = ChromeUtils.import(
+ "resource://gre/modules/TelemetrySession.jsm"
+);
+TelemetrySession.testPing();
+```
+
+### Trace message: "Telemetry is not allowed to send pings"
+
+This indicates `TelemetrySend.sendingEnabled()` is returning false;
+
+Fails if not an official build (override using `toolkit.telemetry.send.overrideOfficialCheck`).
+
+If `toolkit.telemetry.unified` and `datareporting.healthreport.uploadEnabled` are true, then
+`sendingEnabled()` returns true;
+
+If `toolkit.telemetry.unified` is false, then the intended-to-be-deprecated `toolkit.telemetry.enabled` controls the result.
+We're using unified telemetry, so this shouldn't be an issue.
+
+### Trace message: "can't send ping now, persisting to disk"
+
+Trace shows:
+```
+TelemetrySend::submitPing - can't send ping now, persisting to disk - canSendNow: false
+```
+
+This means `TelemetryReportingPolicy.canUpload()` is returning false.
+
+Requirements for `canUpload()`:
+
+`datareporting.policy.dataSubmissionEnabled` must be true.
+AND
+`datareporting.policy.dataSubmissionPolicyNotifiedTime` has a sane timestamp (and is > `OLDEST_ALLOWED_ACCEPTANCE_YEAR`).
+AND
+`datareporting.policy.dataSubmissionPolicyAcceptedVersion` >= `datareporting.policy.minimumPolicyVersion`
+
+Or the notification policy can be bypassed by setting:
+`datareporting.policy.dataSubmissionPolicyBypassNotification` to true.
+
+## Further documentation
+
+The Telemetry documentation index is at:
+
+https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/index.html
+
+There's a good summary of settings (both compile-time and run-time prefs):
+
+https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/internals/preferences.html
diff --git a/comm/mail/components/telemetry/Scalars.yaml b/comm/mail/components/telemetry/Scalars.yaml
new file mode 100644
index 0000000000..13ac19fcdd
--- /dev/null
+++ b/comm/mail/components/telemetry/Scalars.yaml
@@ -0,0 +1,591 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This file contains Thunderbird-specific telemetry Scalar definitions, which
+# are added on top of the Firefox ones (in /toolkit/components/telemetry).
+# To avoid name clashes, all the TB scalars will be under a "tb" section.
+# They are submitted with the "main" pings and can be inspected in about:telemetry.
+
+# The following section is for probes testing the Telemetry system.
+# Under normal operation, these won't be invoked.
+tb.test:
+ unsigned_int_kind:
+ bug_numbers:
+ - 1427877
+ description: >
+ This is a test uint type with a really long description, maybe spanning even multiple
+ lines, to just prove a point: everything works just fine.
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ string_kind:
+ bug_numbers:
+ - 1427877
+ description: A string test type with a one line comment that works just fine!
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: string
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ boolean_kind:
+ bug_numbers:
+ - 1427877
+ description: A boolean test type with a one line comment that works just fine!
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: boolean
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.account:
+ count:
+ bug_numbers:
+ - 1615981
+ description: Count of how many accounts were set up, keyed by account type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ successful_email_account_setup:
+ bug_numbers:
+ - 1615987
+ - 1644311
+ description: How many times email accounts setup succeeded, keyed by account config source.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ failed_email_account_setup:
+ bug_numbers:
+ - 1615987
+ - 1644311
+ description: How many times email accounts setup failed, keyed by account config source.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ size_on_disk:
+ bug_numbers:
+ - 1615983
+ description: How many bytes does each type of folder take on disk.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ total_messages:
+ bug_numbers:
+ - 1615983
+ description: How many messages does each type of folder have.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ opened_account_provisioner:
+ bug_numbers:
+ - 1734484
+ description: How many times the user access the account provisioner tab.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ selected_account_from_provisioner:
+ bug_numbers:
+ - 1734484
+ description:
+ How many times the user clicks on a suggested email address from the
+ account provisioner tab, keyed by the provider hostname.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ new_account_from_provisioner:
+ bug_numbers:
+ - 1734484
+ description:
+ How many times a new email address was successfully created from the
+ account provisioner tab, keyed by the provider hostname.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ oauth2_provider_count:
+ bug_numbers:
+ - 1799726
+ description:
+ A count of incoming mail accounts using OAuth2 for authentication, keyed
+ broadly by account provider.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.compose:
+ format_html:
+ bug_numbers:
+ - 1584889
+ description: How many times messages were written in HTML composition mode.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ format_plain_text:
+ bug_numbers:
+ - 1584889
+ description: How many times messages were written in plain text composition mode.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.filelink:
+ uploaded_size:
+ bug_numbers:
+ - 1615984
+ description: Accumulated file size (bytes) uploaded to filelink services, keyed by filelink provider type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ ignored:
+ bug_numbers:
+ - 1615984
+ description: How many times filelink suggestion are ignored.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.mails:
+ sent:
+ bug_numbers:
+ - 1615989
+ description: How many emails are sent.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ read:
+ bug_numbers:
+ - 1615990
+ description: How many emails are read.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ read_secure:
+ bug_numbers:
+ - 1615994
+ description: How many times different kinds of secure emails are read (for the first time).
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ keys:
+ - 'signed-smime'
+ - 'signed-openpgp'
+ - 'encrypted-smime'
+ - 'encrypted-openpgp'
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ folder_opened:
+ bug_numbers:
+ - 1800775
+ description: How many times folders of each type are opened.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ keyed: true
+ keys:
+ - Inbox
+ - Drafts
+ - Trash
+ - SentMail
+ - Templates
+ - Junk
+ - Archive
+ - Queue
+ - Virtual
+ - Other
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.preferences:
+ boolean:
+ bug_numbers:
+ - 1757993
+ description: Values of boolean preferences.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: boolean
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ integer:
+ bug_numbers:
+ - 1800775
+ description: Values of integer preferences.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.websearch:
+ usage:
+ bug_numbers:
+ - 1641773
+ description: How many times search the web was used, keyed by search engine name.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.addressbook:
+ addressbook_count:
+ bug_numbers:
+ - 1615986
+ description: How many addressbooks were set up, keyed by addressbook directory URI scheme.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ contact_count:
+ bug_numbers:
+ - 1615986
+ description: Count of contacts in all addressbooks, keyed by addressbook directory URI scheme.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.calendar:
+ calendar_count:
+ bug_numbers:
+ - 1615985
+ description: How many calendars were set up, keyed by calendar type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ read_only_calendar_count:
+ bug_numbers:
+ - 1615985
+ description: How many read only calendars were set up, keyed by calendar type.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ keyed: true
+ kind: uint
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.ui.configuration:
+ folder_tree_modes:
+ bug_numbers:
+ - 1800775
+ description: Configuration of the folder tree.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: string
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ pane_visibility:
+ bug_numbers:
+ - 1800775
+ description: Configuration of the folder and message panes.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: boolean
+ keyed: true
+ keys:
+ - folderPane
+ - messagePane
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+ message_header:
+ bug_numbers:
+ - 1800775
+ description: Configuration of the message header display.
+ release_channel_collection: opt-out
+ expires: never
+ products:
+ - 'thunderbird'
+ kind: uint
+ keyed: true
+ keys:
+ - showAvatar
+ - showBigAvatar
+ - showFullAddress
+ - hideLabels
+ - subjectLarge
+ - buttonStyle
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ record_in_processes:
+ - 'main'
+
+tb.ui.interaction:
+ calendar:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in the calendar.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ chat:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in chat.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ keyboard:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with keyboard shortcuts.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ message_display:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in the message display.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+ toolbox:
+ bug_numbers:
+ - 1736739
+ description: >
+ Records a count of interactions with items in the main window toolbox.
+ expires: never
+ kind: uint
+ keyed: true
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
+
+tb.chat:
+ active_message_theme:
+ bug_numbers:
+ - 1767004
+ description: >
+ Records the currently active chat message theme and variant.
+ expires: "117"
+ kind: string
+ keyed: false
+ notification_emails:
+ - "telemetry-client-dev@thunderbird.net"
+ products:
+ - "thunderbird"
+ release_channel_collection: opt-out
+ record_in_processes:
+ - "main"
diff --git a/comm/mail/components/test/unit/head_mailcomponents.js b/comm/mail/components/test/unit/head_mailcomponents.js
new file mode 100644
index 0000000000..0c275d8abb
--- /dev/null
+++ b/comm/mail/components/test/unit/head_mailcomponents.js
@@ -0,0 +1,20 @@
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+var CC = Components.Constructor;
+
+// Ensure the profile directory is set up
+do_get_profile();
+
+var gDEPTH = "../../../../";
+
+registerCleanupFunction(function () {
+ load(gDEPTH + "mailnews/resources/mailShutdown.js");
+});
diff --git a/comm/mail/components/test/unit/test_about_support.js b/comm/mail/components/test/unit/test_about_support.js
new file mode 100644
index 0000000000..5626cbbe83
--- /dev/null
+++ b/comm/mail/components/test/unit/test_about_support.js
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// mail/components/about-support/content/accounts.js
+/* globals AboutSupport, AboutSupportPlatform */
+
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+
+/*
+ * Test the about:support module.
+ */
+
+var gAccountList = [
+ {
+ type: "pop3",
+ port: 1234,
+ user: "pop3user",
+ password: "pop3password",
+ socketType: Ci.nsMsgSocketType.plain,
+ authMethod: Ci.nsMsgAuthMethod.old,
+ smtpServers: [],
+ },
+ {
+ type: "imap",
+ port: 2345,
+ user: "imapuser",
+ password: "imappassword",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: true,
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ {
+ type: "nntp",
+ port: 4567,
+ user: null,
+ password: null,
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.GSSAPI,
+ smtpServers: [
+ {
+ port: 5678,
+ user: "newsout1",
+ password: "newsoutpassword1",
+ isDefault: true,
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.NTLM,
+ },
+ {
+ port: 6789,
+ user: "newsout2",
+ password: "newsoutpassword2",
+ isDefault: false,
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.External,
+ },
+ ],
+ },
+];
+
+// A map of account keys to servers. Populated by setup_accounts.
+var gAccountMap = new Map();
+// A map of SMTP server names to SMTP servers. Populated by setup_accounts.
+var gSMTPMap = new Map();
+
+/**
+ * A list of sensitive data: it shouldn't be present in the account
+ * details. Populated by setup_accounts.
+ */
+var gSensitiveData = [];
+
+/**
+ * Set up accounts based on the given data.
+ */
+function setup_accounts() {
+ // First make sure the local folders account is set up.
+ localAccountUtils.loadLocalMailAccount();
+
+ // Now run through the details and set up accounts accordingly.
+ for (let details of gAccountList) {
+ let server = localAccountUtils.create_incoming_server(
+ details.type,
+ details.port,
+ details.user,
+ details.password
+ );
+ server.socketType = details.socketType;
+ server.authMethod = details.authMethod;
+ gSensitiveData.push(details.password);
+ let account = MailServices.accounts.FindAccountForServer(server);
+ for (let smtpDetails of details.smtpServers) {
+ let outgoing = localAccountUtils.create_outgoing_server(
+ smtpDetails.port,
+ smtpDetails.user,
+ smtpDetails.password
+ );
+ outgoing.socketType = smtpDetails.socketType;
+ outgoing.authMethod = smtpDetails.authMethod;
+ localAccountUtils.associate_servers(
+ account,
+ outgoing,
+ smtpDetails.isDefault
+ );
+ gSensitiveData.push(smtpDetails.password);
+
+ // Add the SMTP server to our server name -> server map
+ gSMTPMap.set("localhost:" + smtpDetails.port, smtpDetails);
+ }
+
+ // Add the server to our account -> server map
+ gAccountMap.set(account.key, details);
+ }
+}
+
+/**
+ * Verify that the given account's details match our details for the key.
+ */
+function verify_account_details(aDetails) {
+ let expectedDetails = gAccountMap.get(aDetails.key);
+ // All our servers are at localhost
+ let expectedHostDetails =
+ "(" + expectedDetails.type + ") localhost:" + expectedDetails.port;
+ Assert.equal(aDetails.hostDetails, expectedHostDetails);
+ Assert.equal(aDetails.socketType, expectedDetails.socketType);
+ Assert.equal(aDetails.authMethod, expectedDetails.authMethod);
+
+ let smtpToSee = expectedDetails.smtpServers.map(
+ smtpDetails => "localhost:" + smtpDetails.port
+ );
+
+ for (let smtpDetails of aDetails.smtpServers) {
+ // Check that we're expecting to see this server
+ let toSeeIndex = smtpToSee.indexOf(smtpDetails.name);
+ Assert.notEqual(toSeeIndex, -1);
+ smtpToSee.splice(toSeeIndex, 1);
+
+ let expectedSMTPDetails = gSMTPMap.get(smtpDetails.name);
+ Assert.equal(smtpDetails.socketType, expectedSMTPDetails.socketType);
+ Assert.equal(smtpDetails.authMethod, expectedSMTPDetails.authMethod);
+ Assert.equal(smtpDetails.isDefault, expectedSMTPDetails.isDefault);
+ }
+
+ // Check that we saw all the SMTP servers we wanted to see
+ Assert.equal(smtpToSee.length, 0);
+}
+
+/**
+ * Tests the getFileSystemType function. This is more a check to make sure the
+ * function returns something meaningful and doesn't throw an exception, since
+ * we don't have any information about what sort of file system we're running
+ * on.
+ */
+function test_get_file_system_type() {
+ let fsType = AboutSupportPlatform.getFileSystemType(do_get_cwd());
+ if ("nsILocalFileMac" in Ci) {
+ // Mac should return null
+ Assert.equal(fsType, null);
+ } else {
+ // Windows and Linux should return a string
+ Assert.ok(["local", "network", "unknown"].includes(fsType));
+ }
+}
+
+/**
+ * Test the getAccountDetails function.
+ */
+function test_get_account_details() {
+ let accountDetails = AboutSupport.getAccountDetails();
+ let accountDetailsText = uneval(accountDetails);
+ // The list of accounts we are looking for
+ let accountsToSee = [...gAccountMap.keys()];
+
+ // Our first check is to see that no sensitive data has crept in
+ for (let data of gSensitiveData) {
+ Assert.ok(!accountDetailsText.includes(data));
+ }
+
+ for (let details of accountDetails) {
+ // We're going to make one exception: for the local folders server. We don't
+ // care too much about its details.
+ if (details.key == localAccountUtils.msgAccount.key) {
+ continue;
+ }
+
+ // Check that we're expecting to see this server
+ let toSeeIndex = accountsToSee.indexOf(details.key);
+ Assert.notEqual(toSeeIndex, -1);
+ accountsToSee.splice(toSeeIndex, 1);
+
+ verify_account_details(details);
+ }
+ // Check that we got all the accounts we wanted to see
+ Assert.equal(accountsToSee.length, 0);
+}
+
+var tests = [test_get_file_system_type, test_get_account_details];
+
+function run_test() {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/about-support/accounts.js"
+ );
+
+ setup_accounts();
+
+ for (let test of tests) {
+ test();
+ }
+}
diff --git a/comm/mail/components/test/unit/test_telemetry_buildconfig.js b/comm/mail/components/test/unit/test_telemetry_buildconfig.js
new file mode 100644
index 0000000000..aa8d20bbb8
--- /dev/null
+++ b/comm/mail/components/test/unit/test_telemetry_buildconfig.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test is a copy of parts of the following tests:
+//
+// * toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
+// * toolkit/components/telemetry/tests/unit/test_TelemetryHistograms.js
+// * toolkit/components/telemetry/tests/unit/test_TelemetryScalars.js
+//
+// The probe names have been changed to probes that only exist in a Thunderbird build.
+// If this test begins to fail, check for recent changes in toolkit/components/telemetry.
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const Telemetry = Services.telemetry;
+
+const UINT_SCALAR = "tb.test.unsigned_int_kind";
+const STRING_SCALAR = "tb.test.string_kind";
+const BOOLEAN_SCALAR = "tb.test.boolean_kind";
+
+/**
+ * Check that stored events correspond to expectations.
+ *
+ * @param {Array} summaries - Summary of the expected events.
+ * @param {boolean} clearScalars - Whether to clear out data after snapshotting.
+ */
+function checkEventSummary(summaries, clearScalars) {
+ let scalars = Telemetry.getSnapshotForKeyedScalars("main", clearScalars);
+
+ for (let [process, [category, eObject, method], count] of summaries) {
+ let uniqueEventName = `${category}#${eObject}#${method}`;
+ let summaryCount;
+ if (process === "dynamic") {
+ summaryCount =
+ scalars.dynamic["telemetry.dynamic_event_counts"][uniqueEventName];
+ } else {
+ summaryCount =
+ scalars[process]["telemetry.event_counts"][uniqueEventName];
+ }
+ Assert.equal(
+ summaryCount,
+ count,
+ `${uniqueEventName} had wrong summary count`
+ );
+ }
+}
+
+/**
+ * Test Thunderbird events are included in the build.
+ */
+add_task(async function test_recording_state() {
+ Telemetry.clearEvents();
+ Telemetry.clearScalars();
+
+ const events = [["tb.test", "test", "object1"]];
+
+ // Recording off by default.
+ events.forEach(e => Telemetry.recordEvent(...e));
+ TelemetryTestUtils.assertEvents([]);
+ // But still expect a non-zero summary count.
+ checkEventSummary(
+ events.map(e => ["parent", e, 1]),
+ true
+ );
+
+ // Once again, with recording on.
+ Telemetry.setEventRecordingEnabled("tb.test", true);
+ events.forEach(e => Telemetry.recordEvent(...e));
+ TelemetryTestUtils.assertEvents(events);
+ checkEventSummary(
+ events.map(e => ["parent", e, 1]),
+ true
+ );
+});
+
+/**
+ * Test Thunderbird histograms are included in the build.
+ */
+add_task(async function test_categorical_histogram() {
+ let h1 = Telemetry.getHistogramById("TELEMETRY_TEST_TB_CATEGORICAL");
+ for (let v of ["CommonLabel", "CommonLabel", "Label2", "Label3"]) {
+ h1.add(v);
+ }
+ for (let s of ["", "Label4", "1234"]) {
+ // The |add| method should not throw for unexpected values, but rather
+ // print an error message in the console.
+ h1.add(s);
+ }
+
+ let snapshot = h1.snapshot();
+ Assert.deepEqual(snapshot.values, { 0: 2, 1: 1, 2: 1, 3: 0 });
+ // sum is a little meaningless for categorical histograms, but hey.
+ // (CommonLabel is 0, Label2 is 1, Label3 is 2)
+ Assert.equal(snapshot.sum, 0 * 2 + 1 * 1 + 2 * 1);
+ Assert.deepEqual(snapshot.range, [1, 50]);
+});
+
+/**
+ * Test Thunderbird scalars are included in the build.
+ */
+add_task(async function test_serializationFormat() {
+ Telemetry.clearScalars();
+
+ // Set the scalars to a known value.
+ const expectedUint = 3785;
+ const expectedString = "some value";
+ Telemetry.scalarSet(UINT_SCALAR, expectedUint);
+ Telemetry.scalarSet(STRING_SCALAR, expectedString);
+ Telemetry.scalarSet(BOOLEAN_SCALAR, true);
+
+ // Get a snapshot of the scalars for the main process (internally called "default").
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ // Check that they are serialized to the correct format.
+ Assert.equal(
+ typeof scalars[UINT_SCALAR],
+ "number",
+ UINT_SCALAR + " must be serialized to the correct format."
+ );
+ Assert.ok(
+ Number.isInteger(scalars[UINT_SCALAR]),
+ UINT_SCALAR + " must be a finite integer."
+ );
+ Assert.equal(
+ scalars[UINT_SCALAR],
+ expectedUint,
+ UINT_SCALAR + " must have the correct value."
+ );
+ Assert.equal(
+ typeof scalars[STRING_SCALAR],
+ "string",
+ STRING_SCALAR + " must be serialized to the correct format."
+ );
+ Assert.equal(
+ scalars[STRING_SCALAR],
+ expectedString,
+ STRING_SCALAR + " must have the correct value."
+ );
+ Assert.equal(
+ typeof scalars[BOOLEAN_SCALAR],
+ "boolean",
+ BOOLEAN_SCALAR + " must be serialized to the correct format."
+ );
+ Assert.equal(
+ scalars[BOOLEAN_SCALAR],
+ true,
+ BOOLEAN_SCALAR + " must have the correct value."
+ );
+});
diff --git a/comm/mail/components/test/unit/xpcshell.ini b/comm/mail/components/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..1b37e9a3d9
--- /dev/null
+++ b/comm/mail/components/test/unit/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head_mailcomponents.js
+tail =
+
+[test_about_support.js]
+[test_telemetry_buildconfig.js]
diff --git a/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs b/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs
new file mode 100644
index 0000000000..e503306fde
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/customizable-element.mjs
@@ -0,0 +1,299 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import CUSTOMIZABLE_ITEMS from "resource:///modules/CustomizableItemsDetails.mjs";
+
+const { EXTENSION_PREFIX } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+const browserActionFor = extensionId =>
+ lazy.ExtensionParent.apiManager.global.browserActionFor?.(
+ lazy.ExtensionParent.GlobalManager.getExtension(extensionId)
+ );
+
+/**
+ * Wrapper element for elements whose position can be customized.
+ *
+ * Template ID: #unifiedToolbarCustomizableElementTemplate
+ * Attributes:
+ * - item-id: ID of the customizable item this represents. Not observed.
+ * - disabled: Gets passed on to the live content.
+ */
+export default class CustomizableElement extends HTMLLIElement {
+ static get observedAttributes() {
+ return ["disabled", "tabindex"];
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "customizable-element");
+
+ const template = document
+ .getElementById("unifiedToolbarCustomizableElementTemplate")
+ .content.cloneNode(true);
+
+ const itemId = this.getAttribute("item-id");
+
+ if (itemId.startsWith(EXTENSION_PREFIX)) {
+ const extensionId = itemId.slice(EXTENSION_PREFIX.length);
+ this.append(template);
+ this.#initializeForExtension(extensionId);
+ return;
+ }
+
+ const details = CUSTOMIZABLE_ITEMS.find(item => item.id === itemId);
+ if (!details) {
+ throw new Error(`Could not find definition for ${itemId}`);
+ }
+ this.append(template);
+ this.#initializeFromDetails(details).catch(console.error);
+ }
+
+ attributeChangedCallback(attribute) {
+ switch (attribute) {
+ case "disabled": {
+ const isDisabled = this.disabled;
+ for (const child of this.querySelector(".live-content")?.children ??
+ []) {
+ child.toggleAttribute("disabled", isDisabled);
+ }
+ break;
+ }
+ case "tabindex": {
+ const tabIndex = this.getAttribute("tabindex");
+ if (tabIndex === null) {
+ return;
+ }
+ if (this.details?.skipFocus && tabIndex !== "-1") {
+ this.removeAttribute("tabindex");
+ // Let the container know that an element that shouldn't be focused is
+ // currently marked with a tabindex instruction.
+ if (this.hasConnected) {
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ }
+ return;
+ }
+ const tabIndexNumber = parseInt(tabIndex, 10);
+ for (const child of this.querySelector(".live-content")?.children ??
+ []) {
+ child.tabIndex = tabIndexNumber;
+ }
+ if (tabIndex !== "-1") {
+ this.removeAttribute("tabindex");
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Initialize the template contents from item details. Can't operate on the
+ * template directly due to being async.
+ *
+ * @param {CustomizableItemDetails} itemDetails
+ */
+ async #initializeFromDetails(itemDetails) {
+ if (this.details) {
+ return;
+ }
+ this.details = itemDetails;
+ this.classList.add(itemDetails.id);
+ if (Array.isArray(itemDetails.requiredModules)) {
+ await Promise.all(
+ itemDetails.requiredModules.map(module => {
+ return import(module); // eslint-disable-line no-unsanitized/method
+ })
+ );
+ }
+ if (itemDetails.templateId) {
+ const contentTemplate = document.getElementById(itemDetails.templateId);
+ this.querySelector(".live-content").append(
+ contentTemplate.content.cloneNode(true)
+ );
+ if (this.disabled) {
+ this.attributeChangedCallback("disabled");
+ }
+ }
+ if (itemDetails.skipFocus) {
+ this.classList.add("skip-focus");
+ }
+ if (this.hasAttribute("tabindex")) {
+ this.attributeChangedCallback("tabindex");
+ }
+ // We need to manually re-emit this event, since it might've been emitted
+ // after we cloned the template.
+ if (this.querySelector(".live-content button[disabled]")) {
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ }
+ document.l10n.setAttributes(
+ this.querySelector(".preview-label"),
+ `${itemDetails.labelId}-label`
+ );
+ }
+
+ /**
+ * Initialize the contents of this customizable element for a button from an
+ * extension.
+ *
+ * @param {string} extensionId - ID of the extension the button is from.
+ */
+ async #initializeForExtension(extensionId) {
+ const extensionAction = browserActionFor(extensionId);
+ if (!extensionAction?.extension) {
+ return;
+ }
+ this.details = {
+ allowMultiple: false,
+ spaces: extensionAction.allowedSpaces ?? ["mail"],
+ };
+ if (!customElements.get("extension-action-button")) {
+ await import("./extension-action-button.mjs");
+ }
+ const { extension } = extensionAction;
+ this.classList.add("extension-action");
+ const extensionButton = document.createElement("button", {
+ is: "extension-action-button",
+ });
+ extensionButton.setAttribute("extension", extensionId);
+ this.querySelector(".live-content").append(extensionButton);
+ if (this.disabled) {
+ this.attributeChangedCallback("disabled");
+ }
+ if (this.hasAttribute("tabindex")) {
+ this.attributeChangedCallback("tabindex");
+ }
+ // We need to manually re-emit this event, since it might've been emitted
+ // before the button was attached to the DOM.
+ if (this.querySelector(".live-content button[disabled]")) {
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ }
+ const previewLabel = this.querySelector(".preview-label");
+ const labelText = extension.name || extensionId;
+ previewLabel.textContent = labelText;
+ previewLabel.title = labelText;
+ const { IconDetails } = lazy.ExtensionParent;
+ if (extension.manifest.icons) {
+ let { icon } = IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon2x } = IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ this.style.setProperty(
+ "--webextension-icon",
+ `url("${lazy.ExtensionParent.IconDetails.escapeUrl(icon)}")`
+ );
+ this.style.setProperty(
+ "--webextension-icon-2x",
+ `url("${lazy.ExtensionParent.IconDetails.escapeUrl(icon2x)}")`
+ );
+ }
+ }
+
+ /**
+ * Holds a reference to the palette this element belongs to.
+ *
+ * @type {CustomizationPalette}
+ */
+ get palette() {
+ const paletteClass = this.details.spaces?.length
+ ? "space-specific-palette"
+ : "generic-palette";
+ return this.getRootNode().querySelector(`.${paletteClass}`);
+ }
+
+ /**
+ * If multiple instances of this element are allowed in the same space.
+ *
+ * @type {boolean}
+ */
+ get allowMultiple() {
+ return Boolean(this.details?.allowMultiple);
+ }
+
+ /**
+ * Human readable label for the widget.
+ *
+ * @type {string}
+ */
+ get label() {
+ return this.querySelector(".preview-label").textContent;
+ }
+
+ /**
+ * Calls onTabSwitched on the first button contained in the live content.
+ * No-op if this item is disabled. Called by unified-toolbar's tab monitor.
+ *
+ * @param {TabInfo} tab - Tab that is now selected.
+ * @param {TabInfo} oldTab - Tab that was selected before.
+ */
+ onTabSwitched(tab, oldTab) {
+ if (this.disabled) {
+ return;
+ }
+ this.querySelector(".live-content button")?.onTabSwitched?.(tab, oldTab);
+ }
+
+ /**
+ * Calls onTabClosing on the first button contained in the live content.
+ * No-op if this item is disabled. Called by unified-toolbar's tab monitor.
+ *
+ * @param {TabInfo} tab - Tab that was closed.
+ */
+ onTabClosing(tab) {
+ if (this.disabled) {
+ return;
+ }
+ this.querySelector(".live-content button")?.onTabClosing?.(tab);
+ }
+
+ /**
+ * If this item can be added to all spaces.
+ *
+ * @type {boolean}
+ */
+ get allSpaces() {
+ return !this.details.spaces?.length;
+ }
+
+ /**
+ * If this item wants to provide its own context menu.
+ *
+ * @type {boolean}
+ */
+ get hasContextMenu() {
+ return Boolean(this.details?.hasContextMenu);
+ }
+
+ /**
+ * @type {boolean}
+ */
+ get disabled() {
+ return this.hasAttribute("disabled");
+ }
+
+ set disabled(value) {
+ this.toggleAttribute("disabled", value);
+ }
+
+ focus() {
+ this.querySelector(".live-content *:first-child")?.focus();
+ }
+}
+customElements.define("customizable-element", CustomizableElement, {
+ extends: "li",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs b/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs
new file mode 100644
index 0000000000..d3d0417f7e
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/customization-palette.mjs
@@ -0,0 +1,243 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import ListBoxSelection from "./list-box-selection.mjs";
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+const { getAvailableItemIdsForSpace, MULTIPLE_ALLOWED_ITEM_IDS } =
+ ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+/**
+ * Customization palette containing items that can be added to a customization
+ * target.
+ * Attributes:
+ * - space: ID of the space the widgets are for. "all" for space agnostic
+ * widgets. Not observed.
+ * - items-in-use: Comma-separated IDs of items that are in a target at the time
+ * this is initialized. When changed, initialize should be called.
+ */
+class CustomizationPalette extends ListBoxSelection {
+ contextMenuId = "customizationPaletteMenu";
+
+ /**
+ * If this palette contains items (even if those items are currently all in a
+ * target).
+ *
+ * @type {boolean}
+ */
+ isEmpty = false;
+
+ /**
+ * Array of item IDs allowed to be in this palette.
+ *
+ * @type {string[]}
+ */
+ #allAvailableItems = [];
+
+ /**
+ * If this palette contains items that can be added to all spaces.
+ *
+ * @type {boolean}
+ */
+ #allSpaces = false;
+
+ connectedCallback() {
+ if (super.connectedCallback()) {
+ return;
+ }
+
+ this.#allSpaces = this.getAttribute("space") === "all";
+
+ if (this.#allSpaces) {
+ document
+ .getElementById("customizationPaletteAddEverywhere")
+ .addEventListener("command", this.#handleMenuAddEverywhere);
+ }
+
+ this.initialize();
+ }
+
+ /**
+ * Initializes the contents of the palette from the current state. The
+ * relevant state is defined by the space and items-in-use attributes.
+ */
+ initialize() {
+ const itemIds = this.getAttribute("items-in-use").split(",");
+ this.setItems(itemIds);
+ }
+
+ /**
+ * Update the items currently removed from the palette with an array of item
+ * IDs.
+ *
+ * @param {string[]} itemIds - Array of item IDs currently being used in a
+ * target.
+ */
+ setItems(itemIds) {
+ let space = this.getAttribute("space");
+ if (space === "all") {
+ space = undefined;
+ }
+ const itemsInUse = new Set(itemIds);
+ this.#allAvailableItems = getAvailableItemIdsForSpace(space);
+ this.isEmpty = !this.#allAvailableItems.length;
+ const items = this.#allAvailableItems.filter(
+ itemId => !itemsInUse.has(itemId) || MULTIPLE_ALLOWED_ITEM_IDS.has(itemId)
+ );
+ this.replaceChildren(
+ ...items.map(itemId => {
+ const element = document.createElement("li", {
+ is: "customizable-element",
+ });
+ element.setAttribute("item-id", itemId);
+ element.draggable = true;
+ return element;
+ })
+ );
+ }
+
+ /**
+ * Overwritten context menu handler. Before showing the menu, initializes the
+ * menu with items for all the target areas available.
+ *
+ * @param {MouseEvent} event
+ */
+ handleContextMenu = event => {
+ const menu = document.getElementById(this.contextMenuId);
+ const targets = this.getRootNode().querySelectorAll(
+ '[is="customization-target"]'
+ );
+ const addEverywhereItem = document.getElementById(
+ "customizationPaletteAddEverywhere"
+ );
+ addEverywhereItem.setAttribute("hidden", (!this.#allSpaces).toString());
+ const menuItems = Array.from(targets, target => {
+ const menuItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(menuItem, "customize-palette-add-to", {
+ target: target.name,
+ });
+ menuItem.addEventListener(
+ "command",
+ this.#makeAddToTargetHandler(target)
+ );
+ return menuItem;
+ });
+ menuItems.push(addEverywhereItem);
+ menu.replaceChildren(...menuItems);
+ this.initializeContextMenu(event);
+ };
+
+ #handleMenuAddEverywhere = () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor);
+ this.dispatchEvent(
+ new CustomEvent("additem", {
+ detail: {
+ itemId: this.contextMenuFor.getAttribute("item-id"),
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ };
+
+ /**
+ * Generate a context menu item event handler that will add the right clicked
+ * item to the target.
+ *
+ * @param {CustomizationTarget} target
+ * @returns {function} Context menu item event handler curried with the given
+ * target.
+ */
+ #makeAddToTargetHandler(target) {
+ return () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor, target);
+ }
+ };
+ }
+
+ handleDragSuccess(item) {
+ if (item.allowMultiple) {
+ return;
+ }
+ super.handleDragSuccess(item);
+ }
+
+ handleDrop(itemId, sibling, afterSibling) {
+ if (this.querySelector(`li[item-id="${itemId}"]`)?.allowMultiple) {
+ return;
+ }
+ super.handleDrop(itemId, sibling, afterSibling);
+ }
+
+ canAddElement(itemId) {
+ return (
+ this.#allAvailableItems.includes(itemId) &&
+ (super.canAddElement(itemId) ||
+ this.querySelector(`li[item-id="${itemId}"]`).allowMultiple)
+ );
+ }
+
+ /**
+ * The primary action for the palette is to add the item to a customization
+ * target. Will pick the first target if none is provided.
+ *
+ * @param {CustomizableElement} item - Item to move to a target.
+ * @param {CustomizationTarget} [target] - The target to move the item to.
+ * Defaults to the first target in the root.
+ */
+ primaryAction(item, target) {
+ if (!target) {
+ target = this.getRootNode().querySelector('[is="customization-target"]');
+ }
+ if (item?.allowMultiple) {
+ target.addItem(item.cloneNode(true));
+ return;
+ }
+ if (super.primaryAction(item)) {
+ return;
+ }
+ target.addItem(item);
+ }
+
+ /**
+ * Returns the item to this palette from some other place.
+ *
+ * @param {CustomizableElement} item - Item to return to this palette.
+ */
+ returnItem(item) {
+ if (item.allowMultiple) {
+ item.remove();
+ return;
+ }
+ this.append(item);
+ }
+
+ /**
+ * Filter the items in the palette for the given string based on their label.
+ * The comparison is done on the lower cased label, and the filter string is
+ * lower cased as well.
+ *
+ * @param {string} filterString - String to filter the items by.
+ */
+ filterItems(filterString) {
+ const lowerFilterString = filterString.toLowerCase();
+ for (const item of this.children) {
+ item.hidden = !item.label.toLowerCase().includes(lowerFilterString);
+ }
+ }
+
+ addItemById(itemId) {
+ const item = this.querySelector(`[item-id="${itemId}"]`);
+ if (!item) {
+ return;
+ }
+ this.primaryAction(item);
+ }
+}
+customElements.define("customization-palette", CustomizationPalette, {
+ extends: "ul",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/customization-target.mjs b/comm/mail/components/unifiedtoolbar/content/customization-target.mjs
new file mode 100644
index 0000000000..1ea5f67160
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/customization-target.mjs
@@ -0,0 +1,333 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import ListBoxSelection from "./list-box-selection.mjs";
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+const { getAvailableItemIdsForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+/**
+ * Customization target where items can be placed, rearranged and removed.
+ * Attributes:
+ * - aria-label: Name of the target area.
+ * - current-items: Comma separated item IDs currently in this area. When
+ * changed initialize should be called.
+ * Events:
+ * - itemchange: Fired whenever the items inside the toolbar are added, moved or
+ * removed.
+ * - space: The space this target is in.
+ */
+class CustomizationTarget extends ListBoxSelection {
+ contextMenuId = "customizationTargetMenu";
+ actionKey = "Delete";
+ canMoveItems = true;
+
+ connectedCallback() {
+ if (super.connectedCallback()) {
+ return;
+ }
+
+ document
+ .getElementById("customizationTargetForward")
+ .addEventListener("command", this.#handleMenuForward);
+ document
+ .getElementById("customizationTargetBackward")
+ .addEventListener("command", this.#handleMenuBackward);
+ document
+ .getElementById("customizationTargetRemove")
+ .addEventListener("command", this.#handleMenuRemove);
+ document
+ .getElementById("customizationTargetRemoveEverywhere")
+ .addEventListener("command", this.#handleMenuRemoveEverywhere);
+ document
+ .getElementById("customizationTargetAddEverywhere")
+ .addEventListener("command", this.#handleMenuAddEverywhere);
+ document
+ .getElementById("customizationTargetStart")
+ .addEventListener("command", this.#handleMenuStart);
+ document
+ .getElementById("customizationTargetEnd")
+ .addEventListener("command", this.#handleMenuEnd);
+
+ this.initialize();
+ }
+
+ /**
+ * Initialize the contents of the target from the current state. The relevant
+ * state is passed in via the current-items attribute.
+ */
+ initialize() {
+ const itemIds = this.getAttribute("current-items").split(",");
+ this.setItems(itemIds);
+ }
+
+ /**
+ * Update the items in the target from an array of item IDs.
+ *
+ * @param {string[]} itemIds - ordered array of IDs of the items currently in
+ * the target
+ */
+ setItems(itemIds) {
+ const childCount = this.children.length;
+ const availableItems = getAvailableItemIdsForSpace(
+ this.getAttribute("space"),
+ true
+ );
+ this.replaceChildren(
+ ...itemIds.map(itemId => {
+ const element = document.createElement("li", {
+ is: "customizable-element",
+ });
+ element.setAttribute("item-id", itemId);
+ element.setAttribute("disabled", "disabled");
+ element.classList.toggle("collapsed", !availableItems.includes(itemId));
+ element.draggable = true;
+ return element;
+ })
+ );
+ if (childCount) {
+ this.#onChange();
+ }
+ }
+
+ /**
+ * Human-readable name of the customization target area.
+ *
+ * @type {string}
+ */
+ get name() {
+ return this.getAttribute("aria-label");
+ }
+
+ handleContextMenu = event => {
+ this.initializeContextMenu(event);
+ const notForAllSpaces = !this.contextMenuFor.allSpaces;
+ const removeEverywhereItem = document.getElementById(
+ "customizationTargetRemoveEverywhere"
+ );
+ const addEverywhereItem = document.getElementById(
+ "customizationTargetAddEverywhere"
+ );
+ addEverywhereItem.setAttribute("hidden", notForAllSpaces.toString());
+ removeEverywhereItem.setAttribute("hidden", notForAllSpaces.toString());
+ if (!notForAllSpaces) {
+ const customization = this.getRootNode().host.closest(
+ "unified-toolbar-customization"
+ );
+ const itemId = this.contextMenuFor.getAttribute("item-id");
+ addEverywhereItem.disabled =
+ !this.contextMenuFor.allowMultiple &&
+ customization.activeInAllSpaces(itemId);
+ removeEverywhereItem.disabled =
+ this.contextMenuFor.allowMultiple ||
+ !customization.activeInMultipleSpaces(itemId);
+ }
+ const isFirstElement = this.contextMenuFor === this.firstElementChild;
+ const isLastElement = this.contextMenuFor === this.lastElementChild;
+ document.getElementById("customizationTargetBackward").disabled =
+ isFirstElement;
+ document.getElementById("customizationTargetForward").disabled =
+ isLastElement;
+ document.getElementById("customizationTargetStart").disabled =
+ isFirstElement;
+ document.getElementById("customizationTargetEnd").disabled = isLastElement;
+ };
+
+ /**
+ * Event handler when the context menu item to move the item forward is
+ * selected.
+ */
+ #handleMenuForward = () => {
+ if (this.contextMenuFor) {
+ this.moveItemForward(this.contextMenuFor);
+ }
+ };
+
+ /**
+ * Event handler when the context menu item to move the item backward is
+ * selected.
+ */
+ #handleMenuBackward = () => {
+ if (this.contextMenuFor) {
+ this.moveItemBackward(this.contextMenuFor);
+ }
+ };
+
+ /**
+ * Event handler when the context menu item to remove the item is selected.
+ */
+ #handleMenuRemove = () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor);
+ }
+ };
+
+ #handleMenuRemoveEverywhere = () => {
+ if (this.contextMenuFor) {
+ this.primaryAction(this.contextMenuFor);
+ this.dispatchEvent(
+ new CustomEvent("removeitem", {
+ detail: {
+ itemId: this.contextMenuFor.getAttribute("item-id"),
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ };
+
+ #handleMenuAddEverywhere = () => {
+ if (this.contextMenuFor) {
+ this.dispatchEvent(
+ new CustomEvent("additem", {
+ detail: {
+ itemId: this.contextMenuFor.getAttribute("item-id"),
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ };
+
+ #handleMenuStart = () => {
+ if (this.contextMenuFor) {
+ this.moveItemToStart(this.contextMenuFor);
+ }
+ };
+
+ #handleMenuEnd = () => {
+ if (this.contextMenuFor) {
+ this.moveItemToEnd(this.contextMenuFor);
+ }
+ };
+
+ /**
+ * Emit a change event. Should be called whenever items are added, moved or
+ * removed from the target.
+ */
+ #onChange() {
+ const changeEvent = new Event("itemchange", {
+ bubbles: true,
+ // Make sure this bubbles out of the pane shadow root.
+ composed: true,
+ });
+ this.dispatchEvent(changeEvent);
+ }
+
+ /**
+ * Adopt an item from another list into this one.
+ *
+ * @param {?CustomizableElement} item - Item from another list.
+ */
+ #adoptItem(item) {
+ item?.setAttribute("disabled", "disabled");
+ }
+
+ moveItemForward(...args) {
+ super.moveItemForward(...args);
+ this.#onChange();
+ }
+
+ moveItemBackward(...args) {
+ super.moveItemBackward(...args);
+ this.#onChange();
+ }
+
+ moveItemToStart(...args) {
+ super.moveItemToStart(...args);
+ this.#onChange();
+ }
+
+ moveItemToEnd(...args) {
+ super.moveItemToEnd(...args);
+ this.#onChange();
+ }
+
+ handleDrop(itemId, sibling, afterSibling) {
+ const item = super.handleDrop(itemId, sibling, afterSibling);
+ if (item) {
+ this.#adoptItem(item);
+ this.#onChange();
+ }
+ }
+
+ handleDragSuccess(item) {
+ super.handleDragSuccess(item);
+ this.#onChange();
+ }
+
+ /**
+ * Return the item to its palette, removing it from this target.
+ *
+ * @param {CustomizableElement} item - The item to remove.
+ */
+ primaryAction(item) {
+ if (super.primaryAction(item)) {
+ return;
+ }
+ item.palette.returnItem(item);
+ this.#onChange();
+ }
+
+ /**
+ * Add an item to the end of this customization target.
+ *
+ * @param {CustomizableElement} item - The item to add.
+ */
+ addItem(item) {
+ if (!item) {
+ return;
+ }
+ this.#adoptItem(item);
+ this.append(item);
+ this.#onChange();
+ }
+
+ removeItemById(itemId) {
+ const item = this.querySelector(`[item-id="${itemId}"]`);
+ if (!item) {
+ return;
+ }
+ this.primaryAction(item);
+ }
+
+ /**
+ * Check if an item is currently used in this target.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the item is currently used in this target.
+ */
+ hasItem(itemId) {
+ return Boolean(this.querySelector(`[item-id="${itemId}"]`));
+ }
+
+ /**
+ * IDs of the items currently in this target, in correct order including
+ * duplicates.
+ *
+ * @type {string[]}
+ */
+ get itemIds() {
+ return Array.from(this.children, element =>
+ element.getAttribute("item-id")
+ );
+ }
+
+ /**
+ * If the contents of this target differ from the currently saved
+ * configuration.
+ *
+ * @type {boolean}
+ */
+ get hasChanges() {
+ return this.itemIds.join(",") !== this.getAttribute("current-items");
+ }
+}
+customElements.define("customization-target", CustomizationTarget, {
+ extends: "ul",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs b/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs
new file mode 100644
index 0000000000..cc833aae62
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/extension-action-button.mjs
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { UnifiedToolbarButton } from "./unified-toolbar-button.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+let browserActionFor = extensionId => {
+ const extension =
+ lazy.ExtensionParent.GlobalManager.getExtension(extensionId);
+ if (!extension) {
+ return null;
+ }
+ return lazy.ExtensionParent.apiManager.global.browserActionFor(extension);
+};
+
+const BADGE_BACKGROUND_COLOR = "--toolbar-button-badge-bg-color";
+
+/**
+ * Attributes:
+ * - extension: ID of the extension this button is for.
+ * - open: true if the popup is currently open. Gets redirected to aria-pressed.
+ */
+class ExtensionActionButton extends UnifiedToolbarButton {
+ static get observedAttributes() {
+ return super.observedAttributes.concat("open");
+ }
+
+ /**
+ * ext-browserAction instance for this button.
+ *
+ * @type {?ToolbarButtonAPI}
+ */
+ #action = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ super.connectedCallback();
+ if (this.#action?.extension?.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this.#action);
+ }
+ return;
+ }
+ super.connectedCallback();
+ this.#action = browserActionFor(this.getAttribute("extension"));
+ if (!this.#action) {
+ return;
+ }
+ const contextData = this.#action.getContextData(
+ this.#action.getTargetFromWindow(window)
+ );
+ this.applyTabData(contextData);
+ if (this.#action.extension.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this.#action);
+ if (this.#action.defaults.type == "menu") {
+ let menupopup = document.createXULElement("menupopup");
+ menupopup.dataset.actionMenu = this.#action.manifestName;
+ menupopup.dataset.extensionId = this.#action.extension.id;
+ menupopup.addEventListener("popuphiding", event => {
+ if (event.target.state === "open") {
+ return;
+ }
+ this.removeAttribute("aria-pressed");
+ });
+ this.appendChild(menupopup);
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ if (this.#action?.extension?.hasPermission("menus")) {
+ document.removeEventListener("popupshowing", this.#action);
+ }
+ }
+
+ attributeChangedCallback(attribute) {
+ super.attributeChangedCallback(attribute);
+ if (attribute === "open") {
+ if (this.getAttribute("open") === "true") {
+ this.setAttribute("aria-pressed", "true");
+ } else {
+ this.removeAttribute("aria-pressed");
+ }
+ }
+ }
+
+ /**
+ * Apply the data for the current tab to the extension button. Updates title,
+ * label, icon, badge, disabled and popup.
+ *
+ * @param {object} tabData - Properties for the button in the current tab. See
+ * ExtensionToolbarButtons.jsm for more details.
+ */
+ applyTabData(tabData) {
+ if (!this.#action) {
+ this.#action = browserActionFor(this.getAttribute("extension"));
+ }
+ this.title = tabData.title || this.#action.extension.name;
+ this.setAttribute("label", tabData.label || this.title);
+ this.classList.toggle("prefer-icon-only", tabData.label == "");
+ this.badge = tabData.badgeText;
+ this.disabled = !tabData.enabled;
+ const { style } = this.#action.iconData.get(tabData.icon);
+ for (const [propName, value] of style) {
+ this.style.setProperty(propName, value);
+ }
+ if (tabData.badgeText && tabData.badgeBackgroundColor) {
+ const bgColor = tabData.badgeBackgroundColor;
+ this.style.setProperty(
+ BADGE_BACKGROUND_COLOR,
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ } else {
+ this.style.removeProperty(BADGE_BACKGROUND_COLOR);
+ }
+ this.toggleAttribute("popup", tabData.popup || tabData.type == "menu");
+ if (!tabData.popup) {
+ this.removeAttribute("aria-pressed");
+ }
+ }
+
+ handleClick = event => {
+ // If there is a menupopup associated with this button, open it, instead of
+ // executing the click action.
+ const menupopup = this.querySelector("menupopup");
+ if (menupopup) {
+ event.preventDefault();
+ event.stopPropagation();
+ menupopup.openPopup(this, {
+ position: "after_start",
+ triggerEvent: event,
+ });
+ this.setAttribute("aria-pressed", "true");
+ return;
+ }
+ this.#action?.handleEvent(event);
+ };
+
+ handlePopupShowing(event) {
+ this.#action.handleEvent(event);
+ }
+}
+customElements.define("extension-action-button", ExtensionActionButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs
new file mode 100644
index 0000000000..9fe0aef11d
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/add-to-calendar-button.mjs
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/* import-globals-from ../../../../../calendar/base/content/calendar-extract.js */
+
+/**
+ * Unified toolbar button to add the selected message to a calendar as event or
+ * task.
+ * Attributes:
+ * - type: "event" or "task", specifying the target type to create.
+ */
+class AddToCalendarButton extends MailTabButton {
+ onCommandContextChange() {
+ const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ this.disabled =
+ (about3Pane && !about3Pane.gDBView) ||
+ (about3Pane?.gDBView?.numSelected ?? -1) === 0;
+ }
+
+ handleClick = event => {
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+ const type = this.getAttribute("type");
+ calendarExtract.extractFromEmail(
+ tabmail.currentAboutMessage?.gMessage ||
+ about3Pane.gDBView.hdrForFirstSelectedMessage,
+ type !== "task"
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("add-to-calendar-button", AddToCalendarButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs
new file mode 100644
index 0000000000..593513cd35
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/addons-button.mjs
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { UnifiedToolbarButton } from "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs";
+
+/**
+ * Unified toolbar button that opens the add-ons manager.
+ */
+class AddonsButton extends UnifiedToolbarButton {
+ handleClick = event => {
+ window.openAddonsMgr();
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("addons-button", AddonsButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs
new file mode 100644
index 0000000000..78abbaef3a
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/compact-folder-button.mjs
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/**
+ * Unified toolbar button for compacting the current folder.
+ */
+class CompactFolderButton extends MailTabButton {
+ observed3PaneEvents = ["folderURIChanged"];
+ observedAboutMessageEvents = [];
+
+ onCommandContextChange() {
+ const { gFolder } =
+ document.getElementById("tabmail").currentAbout3Pane ?? {};
+ if (!gFolder) {
+ this.disabled = true;
+ return;
+ }
+ try {
+ this.disabled = !gFolder.isCommandEnabled("cmd_compactFolder");
+ } catch {
+ this.disabled = true;
+ }
+ }
+
+ handleClick = event => {
+ const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ if (!about3Pane) {
+ return;
+ }
+ about3Pane.folderPane.compactFolder(about3Pane.gFolder);
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("compact-folder-button", CompactFolderButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs
new file mode 100644
index 0000000000..02b8bb8035
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/delete-button.mjs
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/* import-globals-from ../../../../base/content/globalOverlay.js */
+
+/**
+ * Unified toolbar button that deletes the selected message or folder.
+ */
+class DeleteButton extends MailTabButton {
+ onCommandContextChange() {
+ const tabmail = document.getElementById("tabmail");
+ try {
+ const controller = getEnabledControllerForCommand("cmd_deleteMessage");
+ const tab = tabmail.currentTabInfo;
+ const message = tab.message;
+
+ this.disabled = !controller || !message;
+
+ if (!this.disabled && message.flags & Ci.nsMsgMessageFlags.IMAPDeleted) {
+ this.setAttribute("label-id", "toolbar-undelete-label");
+ document.l10n.setAttributes(this, "toolbar-undelete");
+ } else {
+ this.setAttribute("label-id", "toolbar-delete-label");
+ document.l10n.setAttributes(this, "toolbar-delete-title");
+ }
+ } catch {
+ this.disabled = true;
+ }
+ }
+
+ handleClick(event) {
+ goDoCommand(
+ event.shiftKey ? "cmd_shiftDeleteMessage" : "cmd_deleteMessage"
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ }
+}
+customElements.define("delete-button", DeleteButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs
new file mode 100644
index 0000000000..9d99dbbf30
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/folder-location-button.mjs
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FolderUtils",
+ "resource:///modules/FolderUtils.jsm"
+);
+
+class FolderLocationButton extends MailTabButton {
+ /**
+ * Image element displaying the icon on the button.
+ *
+ * @type {Image?}
+ */
+ #icon = null;
+
+ /**
+ * If we've added our event listeners, especially to the current about3pane.
+ *
+ * @type {boolean}
+ */
+ #addedListeners = false;
+
+ observed3PaneEvents = ["folderURIChanged"];
+
+ observedAboutMessageEvents = [];
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.#addedListeners) {
+ return;
+ }
+ this.#icon = this.querySelector(".button-icon");
+ this.onCommandContextChange();
+ this.#addedListeners = true;
+ const popup = document.getElementById(this.getAttribute("popup"));
+ popup.addEventListener("command", this.#handlePopupCommand);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this.#addedListeners) {
+ const popup = document.getElementById(this.getAttribute("popup"));
+ popup.removeEventListener("command", this.#handlePopupCommand);
+ }
+ }
+
+ #handlePopupCommand = event => {
+ const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(event.target._folder.URI);
+ };
+
+ /**
+ * Update the label and icon of the button from the currently selected folder
+ * in the local 3pane.
+ */
+ onCommandContextChange() {
+ if (!this.#icon) {
+ return;
+ }
+ const { gFolder } =
+ document.getElementById("tabmail").currentAbout3Pane ?? {};
+ if (!gFolder) {
+ this.disabled = true;
+ return;
+ }
+ this.disabled = false;
+ this.label.textContent = gFolder.name;
+ this.#icon.style = `content: url(${lazy.FolderUtils.getFolderIcon(
+ gFolder
+ )});`;
+ }
+}
+customElements.define("folder-location-button", FolderLocationButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs b/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs
new file mode 100644
index 0000000000..924955c895
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/global-search-bar.mjs
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { SearchBar } from "chrome://messenger/content/unifiedtoolbar/search-bar.mjs";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaMsgSearcher",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaConstants",
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Gloda",
+ "resource:///modules/gloda/GlodaPublic.jsm"
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "glodaCompleter",
+ () =>
+ Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService(
+ Ci.nsIAutoCompleteSearch
+ ).wrappedJSObject
+);
+
+/**
+ * Unified toolbar global search bar.
+ */
+class GlobalSearchBar extends SearchBar {
+ // Fields required for the auto complete popup to work.
+
+ get popup() {
+ return document.getElementById("PopupGlodaAutocomplete");
+ }
+
+ controller = {
+ matchCount: 0,
+ searchString: "",
+ stopSearch() {
+ lazy.glodaCompleter.stopSearch();
+ },
+ handleEnter: (isAutocomplete, event) => {
+ if (!isAutocomplete) {
+ return;
+ }
+ this.#handleSearch({ detail: this.controller.searchString });
+ this.reset();
+ },
+ };
+
+ _focus() {
+ this.focus();
+ }
+
+ #searchResultListener = {
+ onSearchResult: (result, search) => {
+ this.controller.matchCount = search.matchCount;
+ if (this.controller.matchCount < 1) {
+ this.popup.closePopup();
+ return;
+ }
+ if (!this.popup.mPopupOpen) {
+ this.popup.openAutocompletePopup(
+ this,
+ this.shadowRoot.querySelector("input")
+ );
+ return;
+ }
+ this.popup.invalidate();
+ },
+ };
+
+ // Normal custom element stuff
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ if (
+ !Services.prefs.getBoolPref(
+ "mailnews.database.global.indexer.enabled",
+ true
+ )
+ ) {
+ return;
+ }
+ // Need to call this after the shadow root test, since this will always set
+ // up a shadow root.
+ super.connectedCallback();
+ this.addEventListener("search", this.#handleSearch);
+ this.addEventListener("autocomplete", this.#handleAutocomplete);
+ // Capturing to avoid the default cursor movements inside the input.
+ this.addEventListener("keydown", this.#handleKeydown, {
+ capture: true,
+ });
+ this.addEventListener("focus", this.#handleFocus);
+ this.addEventListener("blur", this);
+ this.addEventListener("drop", this.#handleDrop, { capture: true });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "blur":
+ if (this.popup.mPopupOpen) {
+ this.popup.closePopup();
+ }
+ break;
+ }
+ }
+
+ #handleSearch = event => {
+ let tabmail = document.getElementById("tabmail");
+ let args;
+ // Build the query from the autocomplete result.
+ const selectedIndex = this.popup.selectedIndex;
+ if (selectedIndex > -1) {
+ const curResult = lazy.glodaCompleter.curResult;
+ if (curResult) {
+ const row = curResult.getObjectAt(selectedIndex);
+ if (row && !row.fullText && row.nounDef) {
+ let query = lazy.Gloda.newQuery(lazy.GlodaConstants.NOUN_MESSAGE);
+ switch (row.nounDef.name) {
+ case "tag":
+ query = query.tags(row.item);
+ break;
+ case "identity":
+ query = query.involves(row.item);
+ break;
+ }
+ query.orderBy("-date");
+ args = { query };
+ }
+ }
+ }
+ // Or just do a normal full text search.
+ if (!args) {
+ let searchString = event.detail;
+ args = {
+ searcher: new lazy.GlodaMsgSearcher(null, searchString),
+ };
+ if (Services.prefs.getBoolPref("mail.chat.enabled")) {
+ args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString);
+ }
+ }
+ tabmail.openTab("glodaFacet", args);
+ this.popup.closePopup();
+ this.controller.matchCount = 0;
+ this.controller.searchString = "";
+ };
+
+ #handleAutocomplete = event => {
+ this.controller.searchString = event.detail;
+ if (!event.detail) {
+ this.popup.closePopup();
+ this.controller.matchCount = 0;
+ return;
+ }
+ lazy.glodaCompleter.startSearch(
+ this.controller.searchString,
+ "global",
+ null,
+ this.#searchResultListener
+ );
+ };
+
+ #handleKeydown = event => {
+ if (event.ctrlKey) {
+ return;
+ }
+ if (event.key == "ArrowDown") {
+ if (this.popup.selectedIndex < this.controller.matchCount - 1) {
+ ++this.popup.selectedIndex;
+ event.preventDefault();
+ return;
+ }
+ this.popup.selectedIndex = -1;
+ event.preventDefault();
+ return;
+ }
+ if (event.key == "ArrowUp") {
+ if (this.popup.selectedIndex > -1) {
+ --this.popup.selectedIndex;
+ event.preventDefault();
+ return;
+ }
+ this.popup.selectedIndex = this.controller.matchCount - 1;
+ event.preventDefault();
+ }
+ };
+
+ #handleFocus = event => {
+ if (this.controller.searchString && this.controller.matchCount >= 1) {
+ this.popup.openAutocompletePopup(
+ this,
+ this.shadowRoot.querySelector("input")
+ );
+ }
+ };
+
+ #handleDrop = event => {
+ if (event.dataTransfer.types.includes("text/x-moz-address")) {
+ const searchTerm = event.dataTransfer.getData("text/plain");
+ this.#handleSearch({ detail: searchTerm });
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ };
+}
+customElements.define("global-search-bar", GlobalSearchBar);
diff --git a/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs
new file mode 100644
index 0000000000..df9266d077
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/mail-go-button.mjs
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Map from the direction attribute value to the command the button executes on
+ * click.
+ *
+ * @type {{[string]: string}}
+ */
+const COMMAND_FOR_DIRECTION = {
+ forward: "cmd_goForward",
+ back: "cmd_goBack",
+};
+
+/**
+ * Unified toolbar button to add the selected message to a calendar as event or
+ * task.
+ * Attributes:
+ * - direction: "forward" or "back".
+ */
+class MailGoButton extends MailTabButton {
+ /**
+ * @type {?XULPopupElement}
+ */
+ #contextMenu = null;
+
+ connectedCallback() {
+ if (!this.hasConnected) {
+ const command = COMMAND_FOR_DIRECTION[this.getAttribute("direction")];
+ if (!command) {
+ throw new Error(
+ `Unknown direction "${this.getAttribute("direction")}"`
+ );
+ }
+ this.setAttribute("command", command);
+ this.#contextMenu = document.getElementById("messageHistoryPopup");
+ this.addEventListener("contextmenu", this.#handleContextMenu, true);
+ }
+ super.connectedCallback();
+ }
+
+ /**
+ * Build and show the history popup containing a list of messages to navigate
+ * to. Messages that can't be found or that were in folders we can't find are
+ * ignored. The currently displayed message is marked.
+ *
+ * @param {MouseEvent} event - Event triggering the context menu.
+ */
+ #handleContextMenu = event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const tabmail = document.getElementById("tabmail");
+ const currentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ const { messageHistory } = tabmail.currentAboutMessage;
+ const { entries, currentIndex } = messageHistory.getHistory();
+
+ // For populating the back menu, we want the most recently visited
+ // messages first in the menu. So we go backward from curPos to 0.
+ // For the forward menu, we want to go forward from curPos to the end.
+ const items = [];
+ const relativePositionBase = entries.length - 1 - currentIndex;
+ for (const [index, entry] of entries.reverse().entries()) {
+ const folder = MailServices.folderLookup.getFolderForURL(entry.folderURI);
+ if (!folder) {
+ // Where did the folder go?
+ continue;
+ }
+
+ let menuText = "";
+ let msgHdr;
+ try {
+ msgHdr = MailServices.messageServiceFromURI(
+ entry.messageURI
+ ).messageURIToMsgHdr(entry.messageURI);
+ } catch (ex) {
+ // Let's just ignore this history entry.
+ continue;
+ }
+ const messageSubject = msgHdr.mime2DecodedSubject;
+ const messageAuthor = msgHdr.mime2DecodedAuthor;
+
+ if (!messageAuthor && !messageSubject) {
+ // Avoid empty entries in the menu. The message was most likely (re)moved.
+ continue;
+ }
+
+ // If the message was not being displayed via the current folder, prepend
+ // the folder name. We do not need to check underlying folders for
+ // virtual folders because 'folder' is the display folder, not the
+ // underlying one.
+ if (folder != currentWindow.gFolder) {
+ menuText = folder.prettyName + " - ";
+ }
+
+ let subject = "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: ";
+ }
+ if (messageSubject) {
+ subject += messageSubject;
+ }
+ if (subject) {
+ menuText += subject + " - ";
+ }
+
+ menuText += messageAuthor;
+ const newMenuItem = document.createXULElement("menuitem");
+ newMenuItem.setAttribute("label", menuText);
+ const relativePosition = relativePositionBase - index;
+ newMenuItem.setAttribute("value", relativePosition);
+ newMenuItem.addEventListener("command", commandEvent => {
+ this.#navigateToUri(commandEvent.target);
+ commandEvent.stopPropagation();
+ });
+ if (relativePosition === 0 && !messageHistory.canPop(0)) {
+ newMenuItem.setAttribute("checked", true);
+ newMenuItem.setAttribute("type", "radio");
+ }
+ items.push(newMenuItem);
+ }
+ this.#contextMenu.replaceChildren(...items);
+
+ this.#contextMenu.openPopupAtScreen(
+ event.screenX,
+ event.screenY,
+ true,
+ event
+ );
+ };
+
+ /**
+ * Select the message in the appropriate folder for the history popup entry.
+ * Finds the message based on the value of the item, which is the relative
+ * index of the item in the message history.
+ *
+ * @param {Element} target
+ */
+ #navigateToUri(target) {
+ const nsMsgViewIndex_None = 0xffffffff;
+ const historyIndex = Number.parseInt(target.getAttribute("value"), 10);
+ const tabmail = document.getElementById("tabmail");
+ const currentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ const messageHistory = tabmail.currentAboutMessage.messageHistory;
+ if (!messageHistory || !messageHistory.canPop(historyIndex)) {
+ return;
+ }
+ const item = messageHistory.pop(historyIndex);
+
+ if (
+ currentWindow.displayFolder &&
+ currentWindow.gFolder?.URI !== item.folderURI
+ ) {
+ const folder = MailServices.folderLookup.getFolderForURL(item.folderURI);
+ currentWindow.displayFolder(folder);
+ }
+ const msgHdr = MailServices.messageServiceFromURI(
+ item.messageURI
+ ).messageURIToMsgHdr(item.messageURI);
+ const index = currentWindow.gDBView.findIndexOfMsgHdr(msgHdr, true);
+ if (index != nsMsgViewIndex_None) {
+ if (currentWindow.threadTree) {
+ currentWindow.threadTree.selectedIndex = index;
+ currentWindow.threadTree.table.body.focus();
+ } else {
+ currentWindow.gViewWrapper.dbView.selection.select(index);
+ currentWindow.displayMessage(
+ currentWindow.gViewWrapper.dbView.URIForFirstSelectedMessage
+ );
+ }
+ }
+ }
+}
+customElements.define("mail-go-button", MailGoButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs b/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs
new file mode 100644
index 0000000000..651502a934
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/quick-filter-bar-toggle.mjs
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/**
+ * Unified toolbar button for toggling the quick filter bar.
+ */
+class QuickFilterBarToggle extends MailTabButton {
+ observed3PaneEvents = ["folderURIChanged", "select", "qfbtoggle"];
+ observedAboutMessageEvents = [];
+
+ onCommandContextChange() {
+ super.onCommandContextChange();
+ const tabmail = document.getElementById("tabmail");
+ const about3Pane = tabmail.currentAbout3Pane;
+ if (
+ !about3Pane?.paneLayout ||
+ about3Pane.paneLayout.accountCentralVisible
+ ) {
+ this.disabled = true;
+ this.setAttribute("aria-pressed", "false");
+ return;
+ }
+ const active = about3Pane.quickFilterBar.filterer.visible;
+ this.setAttribute("aria-pressed", active.toString());
+ }
+}
+customElements.define("quick-filter-bar-toggle", QuickFilterBarToggle, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs
new file mode 100644
index 0000000000..e3ce55e05e
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/reply-list-button.mjs
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+/**
+ * Unified toolbar button for replying to a mailing list..
+ */
+class ReplyListButton extends MailTabButton {
+ observedAboutMessageEvents = ["load", "MsgLoaded"];
+}
+customElements.define("reply-list-button", ReplyListButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs
new file mode 100644
index 0000000000..75c23592bf
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/space-button.mjs
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { UnifiedToolbarButton } from "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs";
+
+/* import-globals-from ../../../../base/content/spacesToolbar.js */
+
+/**
+ * Unified toolbar button that opens a specific space.
+ * Attributes:
+ * - space: Space to open when the button is activated
+ */
+class SpaceButton extends UnifiedToolbarButton {
+ connectedCallback() {
+ super.connectedCallback();
+ const spaceId = this.getAttribute("space");
+ const space = gSpacesToolbar.spaces.find(
+ spaceDetails => spaceDetails.name == spaceId
+ );
+ if (space.button.classList.contains("has-badge")) {
+ const badgeContainer = space.button.querySelector(
+ ".spaces-badge-container"
+ );
+ this.badge = badgeContainer.textContent;
+ }
+ }
+
+ handleClick = event => {
+ const spaceId = this.getAttribute("space");
+ const space = gSpacesToolbar.spaces.find(
+ spaceDetails => spaceDetails.name == spaceId
+ );
+ gSpacesToolbar.openSpace(document.getElementById("tabmail"), space);
+ event.preventDefault();
+ event.stopPropagation();
+ };
+}
+customElements.define("space-button", SpaceButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs b/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs
new file mode 100644
index 0000000000..3cd7686b5e
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/items/view-picker-button.mjs
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MailTabButton } from "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs";
+
+class ViewPickerButton extends MailTabButton {
+ observed3PaneEvents = ["folderURIChanged", "MailViewChanged"];
+
+ observedAboutMessageEvents = [];
+
+ /**
+ * Update the label and icon of the button from the currently selected folder
+ * in the local 3pane.
+ */
+ onCommandContextChange() {
+ const { gViewWrapper } =
+ document.getElementById("tabmail").currentAbout3Pane ?? {};
+ if (!gViewWrapper) {
+ this.disabled = true;
+ return;
+ }
+ this.disabled = false;
+ const viewPickerPopup = document.getElementById(this.getAttribute("popup"));
+ const value = window.ViewPickerBinding.currentViewValue;
+ let selectedItem = viewPickerPopup.querySelector(`[value="${value}"]`);
+ if (!selectedItem) {
+ // We may have a new item, so refresh to make it show up.
+ window.RefreshAllViewPopups(viewPickerPopup, true);
+ selectedItem = viewPickerPopup.querySelector(`[value="${value}"]`);
+ }
+ this.label.textContent = selectedItem?.getAttribute("label");
+ if (!this.label.textContent) {
+ document.l10n.setAttributes(this.label, "toolbar-view-picker-label");
+ }
+ }
+}
+customElements.define("view-picker-button", ViewPickerButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs
new file mode 100644
index 0000000000..afe84921dd
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs
@@ -0,0 +1,549 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+/**
+ * Shared implementation for a list box used as both a palette of items to add
+ * to a toolbar and a toolbar of items.
+ */
+export default class ListBoxSelection extends HTMLUListElement {
+ /**
+ * The currently selected item for keyboard operations.
+ *
+ * @type {?CustomizableElement}
+ */
+ selectedItem = null;
+
+ /**
+ * The item the context menu is opened for.
+ *
+ * @type {?CustomizableElement}
+ */
+ contextMenuFor = null;
+
+ /**
+ * Key name the primary action is executed on.
+ *
+ * @type {string}
+ */
+ actionKey = "Enter";
+
+ /**
+ * The ID of the menu to show as context menu.
+ *
+ * @type {string}
+ */
+ contextMenuId = "";
+
+ /**
+ * If items can be reordered in this list box.
+ *
+ * @type {boolean}
+ */
+ canMoveItems = false;
+
+ /**
+ * @returns {boolean} If the widget has connected previously.
+ */
+ connectedCallback() {
+ if (this.hasConnected) {
+ return true;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("role", "listbox");
+ this.setAttribute("tabindex", "0");
+
+ this.addEventListener("contextmenu", this.handleContextMenu, {
+ capture: true,
+ });
+ document
+ .getElementById(this.contextMenuId)
+ .addEventListener("popuphiding", this.#handleContextMenuClose);
+ this.addEventListener("keydown", this.#handleKey, { capture: true });
+ this.addEventListener("click", this.#handleClick, { capture: true });
+ this.addEventListener("focus", this.#handleFocus);
+ this.addEventListener("dragstart", this.#handleDragstart);
+ this.addEventListener("dragenter", this.#handleDragenter);
+ this.addEventListener("dragover", this.#handleDragover);
+ this.addEventListener("dragleave", this.#handleDragleave);
+ this.addEventListener("drop", this.#handleDrop);
+ this.addEventListener("dragend", this.#handleDragend);
+ return false;
+ }
+
+ disconnectedCallback() {
+ this.contextMenuFor = null;
+ this.selectedItem = null;
+ }
+
+ /**
+ * Default context menu event handler. Simply forwards the call to
+ * initializeContextMenu.
+ *
+ * @param {MouseEvent} event - The contextmenu mouse click event.
+ */
+ handleContextMenu = event => {
+ this.initializeContextMenu(event);
+ };
+
+ /**
+ * Store the clicked item and open the context menu.
+ *
+ * @param {MouseEvent} event - The contextmenu mouse click event.
+ */
+ initializeContextMenu(event) {
+ // If the context menu was opened by keyboard, we already have the item.
+ if (!this.contextMenuFor) {
+ this.contextMenuFor = event.target.closest("li");
+ this.#clearSelection();
+ }
+ document
+ .getElementById(this.contextMenuId)
+ .openPopupAtScreen(event.screenX, event.screenY, true);
+ }
+
+ /**
+ * Discard the reference to the item the context menu is triggered on when the
+ * menu is closed.
+ */
+ #handleContextMenuClose = () => {
+ this.contextMenuFor = null;
+ };
+
+ /**
+ * Make sure some element is selected when focus enters the element.
+ */
+ #handleFocus = () => {
+ if (!this.selectedItem) {
+ this.selectItem(this.firstElementChild);
+ }
+ };
+
+ /**
+ * Handles basic list box keyboard interactions.
+ *
+ * @param {KeyboardEvent} event - The event for the key down.
+ */
+ #handleKey = event => {
+ // Clicking into the list might clear the selection while retaining focus,
+ // so we need to make sure we have a selected item here.
+ if (!this.selectedItem) {
+ this.selectItem(this.firstElementChild);
+ }
+ const rightIsForward = document.dir === "ltr";
+ switch (event.key) {
+ case this.actionKey:
+ this.primaryAction(this.selectedItem);
+ break;
+ case "Home":
+ if (this.canMoveItems && event.altKey) {
+ this.moveItemToStart(this.selectedItem);
+ break;
+ }
+ this.selectItem(this.firstElementChild);
+ break;
+ case "End":
+ if (this.canMoveItems && event.altKey) {
+ this.moveItemToEnd(this.selectedItem);
+ break;
+ }
+ this.selectItem(this.lastElementChild);
+ break;
+ case "ArrowLeft":
+ if (this.canMoveItems && event.altKey) {
+ if (rightIsForward) {
+ this.moveItemBackward(this.selectedItem);
+ break;
+ }
+ this.moveItemForward(this.selectedItem);
+ break;
+ }
+ if (rightIsForward) {
+ this.selectItem(this.selectedItem?.previousElementSibling);
+ break;
+ }
+ this.selectItem(this.selectedItem?.nextElementSibling);
+ break;
+ case "ArrowRight":
+ if (this.canMoveItems && event.altKey) {
+ if (rightIsForward) {
+ this.moveItemForward(this.selectedItem);
+ break;
+ }
+ this.moveItemBackward(this.selectedItem);
+ break;
+ }
+ if (rightIsForward) {
+ this.selectItem(this.selectedItem?.nextElementSibling);
+ break;
+ }
+ this.selectItem(this.selectedItem?.previousElementSibling);
+ break;
+ case "ContextMenu":
+ this.contextMenuFor = this.selectedItem;
+ return;
+ default:
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ /**
+ * Handles the click event on an item in the list box. Marks the item as
+ * selected.
+ *
+ * @param {MouseEvent} event - The event for the mouse click.
+ */
+ #handleClick = event => {
+ const item = event.target.closest("li");
+ if (item) {
+ this.selectItem(item);
+ } else {
+ this.#clearSelection();
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ /**
+ * Set up the drag data transfer.
+ *
+ * @param {DragEvent} event - Drag start event.
+ */
+ #handleDragstart = event => {
+ // Only allow dragging the customizable elements themeselves.
+ if (event.target.getAttribute("is") !== "customizable-element") {
+ event.preventDefault();
+ return;
+ }
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData(
+ "text/tb-item-id",
+ event.target.getAttribute("item-id")
+ );
+ const customizableItem = event.target;
+ window.requestAnimationFrame(() => {
+ customizableItem.classList.add("dragging");
+ });
+ };
+
+ /**
+ * Calculate the drop position's closest sibling and the relative drop point.
+ * Assumes the list is laid out horizontally if canMoveItems is true. Else
+ * the sibling will be the event target and afterSibling will always be true.
+ *
+ * @param {DragEvent} event - The event the sibling being dragged over should
+ * be found in.
+ * @returns {{sibling: CustomizableElement, afterSibling: boolean}}
+ */
+ #dragSiblingInfo(event) {
+ let sibling = event.target;
+ let afterSibling = true;
+ if (this.canMoveItems) {
+ const listBoundingRect = this.getBoundingClientRect();
+ const listY = listBoundingRect.y + listBoundingRect.height / 2;
+ const element = this.getRootNode().elementFromPoint(event.x, listY);
+ sibling = element.closest('li[is="customizable-element"]');
+ if (!sibling) {
+ if (!this.children.length) {
+ return {};
+ }
+ sibling = this.lastElementChild;
+ }
+ const boundingRect = sibling.getBoundingClientRect();
+ if (event.x < boundingRect.x + boundingRect.width / 2) {
+ afterSibling = false;
+ }
+ if (document.dir === "rtl") {
+ afterSibling = !afterSibling;
+ }
+ }
+ return { sibling, afterSibling };
+ }
+
+ /**
+ * Shared logic for when a drag event happens over a new part of the list.
+ *
+ * @param {DragEvent} event - Drag event.
+ */
+ #dragIn(event) {
+ const itemId = event.dataTransfer.getData("text/tb-item-id");
+ if (!itemId || !this.canAddElement(itemId)) {
+ event.dataTransfer.dropEffect = "none";
+ event.preventDefault();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "move";
+ if (!this.canMoveItems) {
+ return;
+ }
+ const { sibling, afterSibling } = this.#dragSiblingInfo(event);
+ if (!sibling) {
+ return;
+ }
+ sibling.classList.toggle("drop-before", !afterSibling);
+ sibling.classList.toggle("drop-after", afterSibling);
+ sibling.nextElementSibling?.classList.remove("drop-before", "drop-after");
+ sibling.previousElementSibling?.classList.remove(
+ "drop-before",
+ "drop-after"
+ );
+ }
+
+ /**
+ * Shared logic for when a drag leaves an element.
+ *
+ * @param {Element} element - Element the drag has left.
+ */
+ #dragOut(element) {
+ element.classList.remove("drop-after", "drop-before");
+ if (element !== this) {
+ return;
+ }
+ for (const child of this.querySelectorAll(".drop-after,.drop-before")) {
+ child.classList.remove("drop-after", "drop-before");
+ }
+ }
+
+ /**
+ * Prevents the default action for the dragenter event to enable dropping
+ * items on this list. Shows a drag position placeholder in the target if
+ * applicable.
+ *
+ * @param {DragEvent} event - Drag enter event.
+ */
+ #handleDragenter = event => {
+ this.#dragIn(event);
+ };
+
+ /**
+ * Prevents the default for the dragover event to enable dropping items on
+ * this list. Shows a drag position placeholder in the target if applicable.
+ *
+ * @param {DragEvent} event - Drag over event.
+ */
+ #handleDragover = event => {
+ this.#dragIn(event);
+ };
+
+ /**
+ * Hide the drag position placeholder.
+ *
+ * @param {DragEvent} event - Drag leave event.
+ */
+ #handleDragleave = event => {
+ if (!this.canMoveItems) {
+ return;
+ }
+ this.#dragOut(event.target);
+ };
+
+ /**
+ * Move the item to the dragged into given position. Possibly moving adopting
+ * it from another list.
+ *
+ * @param {DragEvent} event - Drop event.
+ */
+ #handleDrop = event => {
+ const itemId = event.dataTransfer.getData("text/tb-item-id");
+ if (
+ event.dataTransfer.dropEffect !== "move" ||
+ !itemId ||
+ !this.canAddElement(itemId)
+ ) {
+ return;
+ }
+
+ const { sibling, afterSibling } = this.#dragSiblingInfo(event);
+
+ event.preventDefault();
+ this.#dragOut(sibling ?? this);
+ this.handleDrop(itemId, sibling, afterSibling);
+ };
+
+ /**
+ * Remove the item from this list if it was dropped into another list. Return
+ * it to its palette if dropped outside a valid target.
+ *
+ * @param {DragEvent} event - Drag end event.
+ */
+ #handleDragend = event => {
+ event.target.classList.remove("dragging");
+ if (event.dataTransfer.dropEffect === "move") {
+ this.handleDragSuccess(event.target);
+ return;
+ }
+ // If we can't move the item to the drop location, return it to its palette.
+ const palette = event.target.palette;
+ if (event.dataTransfer.dropEffect === "none" && palette !== this) {
+ event.preventDefault();
+ this.handleDragSuccess(event.target);
+ palette.returnItem(event.target);
+ }
+ };
+
+ /**
+ * Handle an item from a drag operation being added to the list. The drag
+ * origin could be this list or another list.
+ *
+ * @param {string} itemId - Item ID to add to this list from a drop.
+ * @param {CustomizableElement} sibling - Sibling this item should end up next
+ * to.
+ * @param {boolean} afterSibling - If the item should be inserted after the
+ * sibling.
+ * @return {CustomizableElement} The dropped customizable element created by
+ * this handler.
+ */
+ handleDrop(itemId, sibling, afterSibling) {
+ const item = document.createElement("li", {
+ is: "customizable-element",
+ });
+ item.setAttribute("item-id", itemId);
+ item.draggable = true;
+ if (!this.canMoveItems || !sibling) {
+ this.appendChild(item);
+ return item;
+ }
+ if (afterSibling) {
+ sibling.after(item);
+ return item;
+ }
+ sibling.before(item);
+ return item;
+ }
+
+ /**
+ * Handle an item from this list having been dragged somewhere else.
+ *
+ * @param {CustomizableElement} item - Item dragged somewhere else.
+ */
+ handleDragSuccess(item) {
+ item.remove();
+ }
+
+ /**
+ * Check if a given item is allowed to be added to this list. Is false if the
+ * item is already in the list and moving around is not allowed.
+ *
+ * @param {string} itemId - The item ID of the item that wants to be added to
+ * this list.
+ * @returns {boolean} If this item can be added to this list.
+ */
+ canAddElement(itemId) {
+ return this.canMoveItems || !this.querySelector(`li[item-id="${itemId}"]`);
+ }
+
+ /**
+ * Move the item forward in the list box. Only works if canMoveItems is true.
+ *
+ * @param {CustomizableElement} item - The item to move forward.
+ */
+ moveItemForward(item) {
+ if (!this.canMoveItems) {
+ return;
+ }
+ item.nextElementSibling?.after(item);
+ }
+
+ /**
+ * Move the item backward in the list box. Only works if canMoveItems is true.
+ *
+ * @param {CustomizableElement} item - The item to move backward.
+ */
+ moveItemBackward(item) {
+ if (!this.canMoveItems) {
+ return;
+ }
+ item.previousElementSibling?.before(item);
+ }
+
+ /**
+ * Move the item to the start of the list. Only works if canMoveItems is
+ * true.
+ *
+ * @param {CustomizableElement} item - The item to move to the start.
+ */
+ moveItemToStart(item) {
+ if (!this.canMoveItems || item === this.firstElementChild) {
+ return;
+ }
+ this.prepend(item);
+ }
+
+ /**
+ * Move the item to the end of the list. Only works if canMoveItems is true.
+ *
+ * @param {CustomizableElement} item - The item to move to the end.
+ */
+ moveItemToEnd(item) {
+ if (!this.canMoveItems || item === this.lastElementChild) {
+ return;
+ }
+ this.appendChild(item);
+ }
+
+ /**
+ * Select the item. Removes the selection of the previous item. No-op if no
+ * item is passed.
+ *
+ * @param {CustomizableElement} item - The item to select.
+ */
+ selectItem(item) {
+ if (item) {
+ this.selectedItem?.removeAttribute("aria-selected");
+ item.setAttribute("aria-selected", "true");
+ this.selectedItem = item;
+ this.setAttribute("aria-activedescendant", item.id);
+ }
+ }
+
+ /**
+ * Clear the selection inside the list box.
+ */
+ #clearSelection() {
+ this.selectedItem?.removeAttribute("aria-selected");
+ this.selectedItem = null;
+ this.removeAttribute("aria-activedescendant");
+ }
+
+ /**
+ * Select the next item in the list. If there are no more items in either
+ * direction, the selection state is reset.
+ *
+ * @param {CustomizableElement} item - The item of which the next sibling
+ * should be the new selection.
+ */
+ #selectNextItem(item) {
+ const nextItem = item.nextElementSibling || item.previousElementSibling;
+ if (nextItem) {
+ this.selectItem(nextItem);
+ return;
+ }
+ this.#clearSelection();
+ }
+
+ /**
+ * Execute the primary action on the item after it has been deselected and the
+ * next item was selected. Implementations are expected to override this
+ * method and call it as the first step, aborting if it returns true.
+ *
+ * @param {CustomizableElement} item - The item the primary action should be
+ * executed on.
+ * @returns {boolean} If the action should be aborted.
+ */
+ primaryAction(item) {
+ if (!item) {
+ return true;
+ }
+ item.removeAttribute("aria-selected");
+ this.#selectNextItem(item);
+ return false;
+ }
+}
diff --git a/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs b/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs
new file mode 100644
index 0000000000..a0eeee2279
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/mail-tab-button.mjs
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { UnifiedToolbarButton } from "./unified-toolbar-button.mjs";
+
+/* import-globals-from ../../../base/content/globalOverlay.js */
+
+/**
+ * Mail tab specific unified toolbar button. Instead of tracking a global
+ * command, its state gets re-evaluated every time the state of about:3pane or
+ * about:message tab changes in a relevant way.
+ */
+export class MailTabButton extends UnifiedToolbarButton {
+ /**
+ * Array of events to listen for on the about:3pane document.
+ *
+ * @type {string[]}
+ */
+ observed3PaneEvents = ["folderURIChanged", "select"];
+
+ /**
+ * Array of events to listen for on the message browser.
+ *
+ * @type {string[]}
+ */
+ observedAboutMessageEvents = ["load"];
+
+ /**
+ * Listeners we've added in tabs.
+ *
+ * @type {{tabId: any, target: EventTarget, event: string, callback: function}[]}
+ */
+ #listeners = [];
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.#addTabListeners();
+ this.onCommandContextChange();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const listener of this.#listeners) {
+ listener.target.removeEventListener(listener.event, listener.callback);
+ }
+ this.#listeners.length = 0;
+ }
+
+ /**
+ * Callback for customizable-element when the current tab is switched while
+ * this button is visible.
+ */
+ onTabSwitched() {
+ this.#addTabListeners();
+ this.onCommandContextChange();
+ }
+
+ /**
+ * Callback for customizable-element when a tab is closed.
+ *
+ * @param {TabInfo} tab
+ */
+ onTabClosing(tab) {
+ this.#removeListenersForTab(tab.tabId);
+ }
+
+ /**
+ * Remove all event listeners this button has for a given tab.
+ *
+ * @param {*} tabId - ID of the tab to remove listeners for.
+ */
+ #removeListenersForTab(tabId) {
+ for (const listener of this.#listeners) {
+ if (listener.tabId === tabId) {
+ listener.target.removeEventListener(listener.event, listener.callback);
+ }
+ }
+ this.#listeners = this.#listeners.filter(
+ listener => listener.tabId !== tabId
+ );
+ }
+
+ /**
+ * Add missing event listeners for the current tab.
+ */
+ #addTabListeners() {
+ const tabmail = document.getElementById("tabmail");
+ const tabId = tabmail.currentTabInfo.tabId;
+ const existingListeners = this.#listeners.filter(
+ listener => listener.tabId === tabId
+ );
+ let expectedEventListeners = [];
+ switch (tabmail.currentTabInfo.mode.name) {
+ case "mail3PaneTab":
+ expectedEventListeners = this.observed3PaneEvents.concat(
+ this.observedAboutMessageEvents
+ );
+ break;
+ case "mailMessageTab":
+ expectedEventListeners = this.observedAboutMessageEvents.concat();
+ break;
+ }
+ const missingListeners = expectedEventListeners.filter(event =>
+ existingListeners.every(listener => listener.event !== event)
+ );
+ if (!missingListeners.length) {
+ return;
+ }
+ const contentWindow = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ for (const event of missingListeners) {
+ const listener = {
+ event,
+ tabId,
+ callback: this.#handle3PaneChange,
+ target: contentWindow,
+ };
+ if (
+ this.observedAboutMessageEvents.includes(event) &&
+ contentWindow.messageBrowser
+ ) {
+ listener.target = contentWindow.messageBrowser.contentWindow;
+ }
+ listener.target.addEventListener(listener.event, listener.callback);
+ this.#listeners.push(listener);
+ }
+ }
+
+ /**
+ * Event handling callback when an event by a tab is fired.
+ */
+ #handle3PaneChange = () => {
+ this.onCommandContextChange();
+ };
+
+ /**
+ * Handle the context changing, updating the disabled state for the button
+ * etc.
+ */
+ onCommandContextChange() {
+ if (!this.observedCommand) {
+ return;
+ }
+ try {
+ this.disabled = !getEnabledControllerForCommand(this.observedCommand);
+ } catch {
+ this.disabled = true;
+ }
+ }
+}
+customElements.define("mail-tab-button", MailTabButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/search-bar.mjs b/comm/mail/components/unifiedtoolbar/content/search-bar.mjs
new file mode 100644
index 0000000000..a450f7349f
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/search-bar.mjs
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Search input with customizable search button and placeholder.
+ * Attributes:
+ * - label: Search field label for accessibility tree.
+ * - disabled: When present, disable the search field and button.
+ * Slots in template (#searchBarTemplate):
+ * - placeholder: Content displayed as placeholder. When not provided, the value
+ * of the label attribute is shown as placeholder.
+ * - button: Content displayed on the search button.
+ *
+ * @emits search: Event when a search should be executed. detail holds the
+ * search term.
+ * @emits autocomplte: Auto complete update. detail holds the current search
+ * term.
+ */
+export class SearchBar extends HTMLElement {
+ static get observedAttributes() {
+ return ["label", "disabled"];
+ }
+
+ /**
+ * Reference to the input field in the form.
+ *
+ * @type {?HTMLInputElement}
+ */
+ #input = null;
+
+ /**
+ * Reference to the search button in the form.
+ *
+ * @type {?HTMLButtonElement}
+ */
+ #button = null;
+
+ #onSubmit = event => {
+ event.preventDefault();
+ if (!this.#input.value) {
+ return;
+ }
+
+ const searchEvent = new CustomEvent("search", {
+ detail: this.#input.value,
+ cancelable: true,
+ });
+ if (this.dispatchEvent(searchEvent)) {
+ this.reset();
+ }
+ };
+
+ #onInput = () => {
+ const autocompleteEvent = new CustomEvent("autocomplete", {
+ detail: this.#input.value,
+ });
+ this.dispatchEvent(autocompleteEvent);
+ };
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ const shadowRoot = this.attachShadow({ mode: "open" });
+
+ const template = document
+ .getElementById("searchBarTemplate")
+ .content.cloneNode(true);
+ this.#input = template.querySelector("input");
+ this.#button = template.querySelector("button");
+
+ template.querySelector("form").addEventListener("submit", this.#onSubmit, {
+ passive: false,
+ });
+
+ this.#input.setAttribute("aria-label", this.getAttribute("label"));
+ template.querySelector("slot[name=placeholder]").textContent =
+ this.getAttribute("label");
+ this.#input.addEventListener("input", this.#onInput);
+
+ const styles = document.createElement("link");
+ styles.setAttribute("rel", "stylesheet");
+ styles.setAttribute(
+ "href",
+ "chrome://messenger/skin/shared/search-bar.css"
+ );
+ shadowRoot.append(styles, template);
+ }
+
+ attributeChangedCallback(attributeName, oldValue, newValue) {
+ if (!this.#input) {
+ return;
+ }
+ switch (attributeName) {
+ case "label":
+ this.#input.setAttribute("aria-label", newValue);
+ this.shadowRoot.querySelector("slot[name=placeholder]").textContent =
+ newValue;
+ break;
+ case "disabled": {
+ const isDisabled = this.hasAttribute("disabled");
+ this.#input.disabled = isDisabled;
+ this.#button.disabled = isDisabled;
+ }
+ }
+ }
+
+ focus() {
+ this.#input.focus();
+ }
+
+ /**
+ * Reset the search bar to its empty state.
+ */
+ reset() {
+ this.#input.value = "";
+ }
+}
+customElements.define("search-bar", SearchBar);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs
new file mode 100644
index 0000000000..466a83f0c1
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-button.mjs
@@ -0,0 +1,240 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//TODO keyboard handling, keyboard + commands
+
+/* import-globals-from ../../../base/content/globalOverlay.js */
+
+/**
+ * Toolbar button implementation for the unified toolbar.
+ * Template ID: unifiedToolbarButtonTemplate
+ * Attributes:
+ * - command: ID string of the command to execute when the button is pressed.
+ * - observes: ID of command to observe for disabled state. Defaults to value of
+ * command attribute.
+ * - popup: ID of the popup to open when the button is pressed. The popup is
+ * anchored to the button. Overrides any other click handling.
+ * - disabled: When set the button is disabled.
+ * - title: Tooltip to show on the button.
+ * - label: Label text of the button. Observed for changes.
+ * - label-id: A fluent ID for the label instead of the label attribute.
+ * Observed for changes.
+ * - badge: When set, the value of the attribute is shown as badge.
+ * - aria-pressed: set to "false" to make the button behave like a toggle.
+ * Events:
+ * - buttondisabled: Fired when the button gets disabled while it is keyboard
+ * navigable.
+ * - buttonenabled: Fired when the button gets enabled again but isn't marked to
+ * be keyboard navigable.
+ */
+export class UnifiedToolbarButton extends HTMLButtonElement {
+ static get observedAttributes() {
+ return ["label", "label-id", "disabled"];
+ }
+
+ /**
+ * Container for the button label.
+ *
+ * @type {?HTMLSpanElement}
+ */
+ label = null;
+
+ /**
+ * Name of the command this button follows the disabled (and if it is a toggle
+ * button the checked) state of.
+ *
+ * @type {string?}
+ */
+ observedCommand;
+
+ /**
+ * The mutation observer observing the command this button follows the state
+ * of.
+ *
+ * @type {MutationObserver?}
+ */
+ #observer = null;
+
+ connectedCallback() {
+ // We remove the mutation overserver when the element is disconnected, thus
+ // we have to add it every time the element is connected.
+ this.observedCommand =
+ this.getAttribute("observes") || this.getAttribute("command");
+ if (this.observedCommand) {
+ const command = document.getElementById(this.observedCommand);
+ if (command) {
+ if (!this.#observer) {
+ this.#observer = new MutationObserver(this.#handleCommandMutation);
+ }
+ const observedAttributes = ["disabled"];
+ if (this.hasAttribute("aria-pressed")) {
+ observedAttributes.push("checked");
+
+ // Update the pressed state from the command
+ this.setAttribute(
+ "aria-pressed",
+ command.getAttribute("checked") ?? "false"
+ );
+ }
+ this.#observer.observe(command, {
+ attributes: true,
+ attributeFilter: observedAttributes,
+ });
+ }
+ // Update the disabled state to match the current state of the command.
+ try {
+ this.disabled = !getEnabledControllerForCommand(this.observedCommand);
+ } catch {
+ this.disabled = true;
+ }
+ }
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.classList.add("unified-toolbar-button", "button");
+
+ const template = document
+ .getElementById("unifiedToolbarButtonTemplate")
+ .content.cloneNode(true);
+ this.label = template.querySelector("span");
+ this.#updateLabel();
+ this.appendChild(template);
+ this.addEventListener("click", event => this.handleClick(event));
+ }
+
+ disconnectedCallback() {
+ if (this.#observer) {
+ this.#observer.disconnect();
+ }
+ }
+
+ attributeChangedCallback(attribute) {
+ switch (attribute) {
+ case "label":
+ case "label-id":
+ this.#updateLabel();
+ break;
+ case "disabled":
+ if (!this.hasConnected) {
+ return;
+ }
+ if (this.disabled && this.tabIndex !== -1) {
+ this.tabIndex = -1;
+ this.dispatchEvent(new CustomEvent("buttondisabled"));
+ } else if (!this.disabled && this.tabIndex === -1) {
+ this.dispatchEvent(new CustomEvent("buttonenabled"));
+ }
+ break;
+ }
+ }
+
+ /**
+ * Default handling for clicks on the button. Shows the associated popup,
+ * executes the given command and toggles the button state.
+ *
+ * @param {MouseEvent} event - Click event.
+ */
+ handleClick(event) {
+ if (this.hasAttribute("popup")) {
+ event.preventDefault();
+ event.stopPropagation();
+ const popup = document.getElementById(this.getAttribute("popup"));
+ popup.openPopup(this, {
+ position: "after_start",
+ triggerEvent: event,
+ });
+ this.setAttribute("aria-pressed", "true");
+ const hideListener = () => {
+ if (popup.state === "open") {
+ return;
+ }
+ this.removeAttribute("aria-pressed");
+ popup.removeEventListener("popuphiding", hideListener);
+ };
+ popup.addEventListener("popuphiding", hideListener);
+ return;
+ }
+ if (this.hasAttribute("aria-pressed")) {
+ const isPressed = this.getAttribute("aria-pressed") === "true";
+ this.setAttribute("aria-pressed", (!isPressed).toString());
+ }
+ if (this.hasAttribute("command")) {
+ const command = this.getAttribute("command");
+ let controller = getEnabledControllerForCommand(command);
+ if (controller) {
+ event.preventDefault();
+ event.stopPropagation();
+ controller = controller.wrappedJSObject ?? controller;
+ controller.doCommand(command, event);
+ return;
+ }
+ const commandElement = document.getElementById(command);
+ if (!commandElement) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ commandElement.doCommand();
+ }
+ }
+
+ /**
+ * Callback for the mutation observer on the command this button follows.
+ *
+ * @param {Mutation[]} mutationList - List of mutations the observer saw.
+ */
+ #handleCommandMutation = mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type !== "attributes") {
+ continue;
+ }
+ if (mutation.attributeName === "disabled") {
+ this.disabled = mutation.target.getAttribute("disabled") === "true";
+ } else if (mutation.attributeName === "checked") {
+ this.setAttribute(
+ "aria-pressed",
+ mutation.target.getAttribute("checked")
+ );
+ }
+ }
+ };
+
+ /**
+ * Update the contents of the label from the attributes of this element.
+ */
+ #updateLabel() {
+ if (!this.label) {
+ return;
+ }
+ if (this.hasAttribute("label")) {
+ this.label.textContent = this.getAttribute("label");
+ return;
+ }
+ if (this.hasAttribute("label-id")) {
+ document.l10n.setAttributes(this.label, this.getAttribute("label-id"));
+ }
+ }
+
+ /**
+ * Badge displayed on the button. To clear the badge, set to empty string or
+ * nullish value.
+ *
+ * @type {string}
+ */
+ set badge(badgeText) {
+ if (badgeText === "" || badgeText == null) {
+ this.removeAttribute("badge");
+ return;
+ }
+ this.setAttribute("badge", badgeText);
+ }
+
+ get badge() {
+ return this.getAttribute("badge");
+ }
+}
+customElements.define("unified-toolbar-button", UnifiedToolbarButton, {
+ extends: "button",
+});
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs
new file mode 100644
index 0000000000..a43b7c6005
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization-pane.mjs
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import "./search-bar.mjs"; // eslint-disable-line import/no-unassigned-import
+import "./customization-palette.mjs"; // eslint-disable-line import/no-unassigned-import
+import "./customization-target.mjs"; // eslint-disable-line import/no-unassigned-import
+import {
+ BUTTON_STYLE_MAP,
+ BUTTON_STYLE_PREF,
+} from "resource:///modules/ButtonStyle.mjs";
+
+const { getDefaultItemIdsForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+/**
+ * Template ID: unifiedToolbarCustomizationPaneTemplate
+ * Attributes:
+ * - space: Identifier of the space this pane is for. Changes are not observed.
+ * - current-items: Currently used items in this space.
+ * - builtin-space: Boolean indicating if the space is a built in space (true) or an
+ * extension provided space (false).
+ */
+class UnifiedToolbarCustomizationPane extends HTMLElement {
+ /**
+ * Reference to the customization target for the main toolbar area.
+ *
+ * @type {CustomizationTarget?}
+ */
+ #toolbarTarget = null;
+
+ /**
+ * Reference to the title of the space specific palette.
+ *
+ * @type {?HTMLHeadingElement}
+ */
+ #spaceSpecificTitle = null;
+
+ /**
+ * Reference to the palette for items only available in the current space.
+ *
+ * @type {?CustomizationPalette}
+ */
+ #spaceSpecificPalette = null;
+
+ /**
+ * Reference to the palette for items available in all spaces.
+ *
+ * @type {?CustomizationPalette}
+ */
+ #genericPalette = null;
+
+ /**
+ * List of the item IDs that are in the toolbar by default in this area.
+ *
+ * @type {string[]}
+ */
+ #defaultItemIds = [];
+
+ /**
+ * The search bar used to filter the items in the palettes.
+ *
+ * @type {?SearchBar}
+ */
+ #searchBar = null;
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ document.l10n.connectRoot(this.shadowRoot);
+ return;
+ }
+ this.setAttribute("role", "tabpanel");
+ const shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+
+ const space = this.getAttribute("space");
+
+ const template = document
+ .getElementById("unifiedToolbarCustomizationPaneTemplate")
+ .content.cloneNode(true);
+ const styles = document.createElement("link");
+ styles.setAttribute("rel", "stylesheet");
+ styles.setAttribute(
+ "href",
+ "chrome://messenger/skin/shared/unifiedToolbarCustomizationPane.css"
+ );
+
+ this.#toolbarTarget = template.querySelector(".toolbar-target");
+ this.#toolbarTarget.setAttribute("space", space);
+
+ this.#spaceSpecificTitle = template.querySelector(".space-specific-title");
+ document.l10n.setAttributes(
+ this.#spaceSpecificTitle,
+ this.hasAttribute("builtin-space")
+ ? `customize-palette-${space}-specific-title`
+ : "customize-palette-extension-specific-title"
+ );
+ this.#spaceSpecificTitle.id = `${space}PaletteTitle`;
+ this.#spaceSpecificPalette = template.querySelector(
+ ".space-specific-palette"
+ );
+ this.#spaceSpecificPalette.id = `${space}Palette`;
+ this.#spaceSpecificPalette.setAttribute(
+ "aria-labelledby",
+ this.#spaceSpecificTitle.id
+ );
+ this.#spaceSpecificPalette.setAttribute("space", space);
+ const genericTitle = template.querySelector(".generic-palette-title");
+ genericTitle.id = `${space}GenericPaletteTitle`;
+ this.#genericPalette = template.querySelector(".generic-palette");
+ this.#genericPalette.id = `${space}GenericPalette`;
+ this.#genericPalette.setAttribute("aria-labelledby", genericTitle.id);
+
+ this.#searchBar = template.querySelector("search-bar");
+ this.#searchBar.addEventListener("search", this.#handleSearch);
+ this.#searchBar.addEventListener("autocomplete", this.#handleFilter);
+
+ this.initialize();
+
+ shadowRoot.append(styles, template);
+
+ this.addEventListener("dragover", this.#handleDragover);
+ }
+
+ disconnectedCallback() {
+ document.l10n.disconnectRoot(this.shadowRoot);
+ }
+
+ #handleFilter = event => {
+ this.#spaceSpecificPalette.filterItems(event.detail);
+ this.#genericPalette.filterItems(event.detail);
+ };
+
+ #handleSearch = event => {
+ // Don't clear the search bar.
+ event.preventDefault();
+ };
+
+ /**
+ * Default handler to indicate nothing can be dropped in the customization,
+ * except for the dragging and dropping in the palettes and targets.
+ *
+ * @param {DragEvent} event - Drag over event.
+ */
+ #handleDragover = event => {
+ event.dataTransfer.dropEffect = "none";
+ event.preventDefault();
+ };
+
+ /**
+ * Initialize the contents of this element from the state. The relevant state
+ * for this element are the items currently in the toolbar for this space.
+ *
+ * @param {boolean} [deep = false] - If true calls initialize on all the
+ * targets and palettes.
+ */
+ initialize(deep = false) {
+ const space = this.getAttribute("space");
+ this.#defaultItemIds = getDefaultItemIdsForSpace(space);
+ const currentItems = this.hasAttribute("current-items")
+ ? this.getAttribute("current-items")
+ : this.#defaultItemIds.join(",");
+ this.#toolbarTarget.setAttribute("current-items", currentItems);
+ this.#spaceSpecificPalette.setAttribute("items-in-use", currentItems);
+ this.#genericPalette.setAttribute("items-in-use", currentItems);
+
+ if (deep) {
+ this.#searchBar.reset();
+ this.#toolbarTarget.initialize();
+ this.#spaceSpecificPalette.initialize();
+ this.#genericPalette.initialize();
+ this.#spaceSpecificTitle.hidden = this.#spaceSpecificPalette.isEmpty;
+ this.#spaceSpecificPalette.hidden = this.#spaceSpecificPalette.isEmpty;
+ }
+
+ this.updateButtonStyle(
+ BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)]
+ );
+ }
+
+ /**
+ * Reset the items in the targets to the defaults.
+ */
+ reset() {
+ this.#toolbarTarget.setItems(this.#defaultItemIds);
+ this.#spaceSpecificPalette.setItems(this.#defaultItemIds);
+ this.#genericPalette.setItems(this.#defaultItemIds);
+ }
+
+ /**
+ * Add an item to the default target in this space. Can only add items that
+ * are available in all spaces.
+ *
+ * @param {string} itemId - Item ID of the item to add to the default target.
+ */
+ addItem(itemId) {
+ this.#genericPalette.addItemById(itemId);
+ }
+
+ /**
+ * Remove an item from all targets in this space.
+ *
+ * @param {string} itemId - Item ID of the item to remove from this pane's
+ * targets.
+ */
+ removeItem(itemId) {
+ this.#toolbarTarget.removeItemById(itemId);
+ }
+
+ /**
+ * Check if an item is currently in a target in this pane.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the item is currently used in this pane.
+ */
+ hasItem(itemId) {
+ return Boolean(this.#toolbarTarget.hasItem(itemId));
+ }
+
+ /**
+ * If the customization state of this space matches its default state.
+ *
+ * @type {boolean}
+ */
+ get matchesDefaultState() {
+ const itemsInToolbar = this.#toolbarTarget.itemIds;
+ return itemsInToolbar.join(",") === this.#defaultItemIds.join(",");
+ }
+
+ /**
+ * If the customization state of this space matches the currently saved
+ * configuration.
+ *
+ * @type {boolean}
+ */
+ get hasChanges() {
+ return this.#toolbarTarget.hasChanges;
+ }
+
+ /**
+ * Current customization state for this space.
+ *
+ * @type {string[]}
+ */
+ get itemIds() {
+ return this.#toolbarTarget.itemIds;
+ }
+
+ /**
+ * Update the class of the toolbar preview to reflect the selected button
+ * style.
+ *
+ * @param {string} value - The class to apply.
+ */
+ updateButtonStyle(value) {
+ this.#toolbarTarget.classList.remove(...BUTTON_STYLE_MAP);
+ this.#toolbarTarget.classList.add(value);
+ }
+}
+customElements.define(
+ "unified-toolbar-customization-pane",
+ UnifiedToolbarCustomizationPane
+);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs
new file mode 100644
index 0000000000..1acdf85b57
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-customization.mjs
@@ -0,0 +1,414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../base/content/spacesToolbar.js */
+/* import-globals-from ../../../base/content/utilityOverlay.js */
+
+import {
+ storeState,
+ getState,
+} from "resource:///modules/CustomizationState.mjs";
+import "./unified-toolbar-tab.mjs"; // eslint-disable-line import/no-unassigned-import
+import "./unified-toolbar-customization-pane.mjs"; // eslint-disable-line import/no-unassigned-import
+import {
+ BUTTON_STYLE_MAP,
+ BUTTON_STYLE_PREF,
+} from "resource:///modules/ButtonStyle.mjs";
+
+/**
+ * Set of names of the built in spaces.
+ *
+ * @type {Set<string>}
+ */
+const BUILTIN_SPACES = new Set([
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+]);
+
+/**
+ * Customization palette container for the unified toolbar. Contained in a
+ * custom element for state management. When visible, the document should have
+ * the customizingUnifiedToolbar class.
+ * Template: #unifiedToolbarCustomizationTemplate.
+ */
+class UnifiedToolbarCustomization extends HTMLElement {
+ /**
+ * Reference to the container where the space tabs go in. The tab panels will
+ * be placed after this element.
+ *
+ * @type {?HTMLDivElement}
+ */
+ #tabList = null;
+
+ #buttonStyle = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ const template = document
+ .getElementById("unifiedToolbarCustomizationTemplate")
+ .content.cloneNode(true);
+ const form = template.querySelector("form");
+ form.addEventListener(
+ "submit",
+ event => {
+ event.preventDefault();
+ this.#save();
+ },
+ {
+ passive: false,
+ }
+ );
+ form.addEventListener("reset", event => {
+ this.#reset();
+ });
+ template
+ .querySelector("#unifiedToolbarCustomizationCancel")
+ .addEventListener("click", () => {
+ this.toggle(false);
+ });
+ this.#buttonStyle = template.querySelector("#buttonStyle");
+ this.#buttonStyle.addEventListener("change", this.#handleButtonStyleChange);
+ this.addEventListener("itemchange", this.#handleItemChange, {
+ capture: true,
+ });
+ this.addEventListener("additem", this.#handleAddItem, {
+ capture: true,
+ });
+ this.addEventListener("removeitem", this.#handleRemoveItem, {
+ capture: true,
+ });
+ this.#tabList = template.querySelector("#customizationTabs");
+ this.#tabList.addEventListener("tabswitch", this.#handleTabSwitch, {
+ capture: true,
+ });
+ template
+ .querySelector("#customizationToSettingsButton")
+ .addEventListener("click", this.#handleSettingsButton);
+ this.initialize();
+ this.append(template);
+ this.#updateResetToDefault();
+ this.addEventListener("keyup", this.#handleKeyboard);
+ this.addEventListener("keyup", this.#closeByKeyboard);
+ this.addEventListener("keypress", this.#handleKeyboard);
+ this.addEventListener("keydown", this.#handleKeyboard);
+ }
+
+ #handleItemChange = event => {
+ event.stopPropagation();
+ this.#updateResetToDefault();
+ this.#updateUnsavedChangesState();
+ };
+
+ #handleTabSwitch = event => {
+ event.stopPropagation();
+ this.#updateUnsavedChangesState();
+ };
+
+ #handleButtonStyleChange = event => {
+ for (const pane of this.querySelectorAll(
+ "unified-toolbar-customization-pane"
+ )) {
+ pane.updateButtonStyle(event.target.value);
+ }
+ this.#updateUnsavedChangesState();
+ };
+
+ #handleSettingsButton = event => {
+ event.preventDefault();
+ openPreferencesTab("paneGeneral", "layoutGroup");
+ this.toggle(false);
+ };
+
+ #handleAddItem = event => {
+ event.stopPropagation();
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ for (const pane of tabPanes) {
+ pane.addItem(event.detail.itemId);
+ }
+ };
+
+ #handleRemoveItem = event => {
+ event.stopPropagation();
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ for (const pane of tabPanes) {
+ pane.removeItem(event.detail.itemId);
+ }
+ };
+
+ /**
+ * Close the customisation pane when Escape is released
+ *
+ * @param {KeyboardEvent} event - The keyboard event
+ */
+ #closeByKeyboard = event => {
+ if (event.key == "Escape") {
+ event.preventDefault();
+ this.toggle(false);
+ }
+ };
+
+ /**
+ * Ensure keyboard events are not propagated outside the customization dialog.
+ *
+ * @param {KeyboardEvent} event - The keyboard event.
+ */
+ #handleKeyboard = event => {
+ event.stopPropagation();
+ };
+
+ /**
+ * Update state of reset to default button.
+ */
+ #updateResetToDefault() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ const isDefault = tabPanes.every(pane => pane.matchesDefaultState);
+ this.querySelector('button[type="reset"]').disabled = isDefault;
+ }
+
+ #updateUnsavedChangesState() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ const unsavedChanges =
+ tabPanes.some(tabPane => tabPane.hasChanges) ||
+ this.#buttonStyle.value !=
+ BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)];
+ const otherSpacesHaveUnsavedChanges =
+ unsavedChanges &&
+ tabPanes.some(tabPane => tabPane.hidden && tabPane.hasChanges);
+ this.querySelector('button[type="submit"]').disabled = !unsavedChanges;
+ document.getElementById(
+ "unifiedToolbarCustomizationUnsavedChanges"
+ ).hidden = !otherSpacesHaveUnsavedChanges;
+ }
+
+ /**
+ * Generate a tab and tab pane that are linked together for the given space.
+ * If the space is the current space, the tab is marked as active.
+ *
+ * @param {SpaceInfo} space
+ * @returns {{tab: UnifiedToolbarTab, tabPane: UnifiedToolbarCustomizationPane}}
+ */
+ #makeSpaceTab(space) {
+ const activeSpace = space === gSpacesToolbar.currentSpace;
+ const tabId = `unified-toolbar-customization-tab-${space.name}`;
+ const paneId = `unified-toolbar-customization-pane-${space.name}`;
+ const tab = document.createElement("unified-toolbar-tab");
+ tab.id = tabId;
+ tab.setAttribute("aria-controls", paneId);
+ if (activeSpace) {
+ tab.setAttribute("selected", true);
+ }
+ const isBuiltinSpace = BUILTIN_SPACES.has(space.name);
+ if (isBuiltinSpace) {
+ document.l10n.setAttributes(tab, `customize-space-tab-${space.name}`);
+ } else {
+ const title = space.button.title;
+ tab.textContent = title;
+ tab.title = title;
+ tab.style = space.button.querySelector("img").style.cssText;
+ }
+ const tabPane = document.createElement(
+ "unified-toolbar-customization-pane"
+ );
+ tabPane.id = paneId;
+ tabPane.setAttribute("space", space.name);
+ tabPane.setAttribute("aria-labelledby", tabId);
+ tabPane.toggleAttribute("builtin-space", isBuiltinSpace);
+ tabPane.hidden = !activeSpace;
+ return { tab, tabPane };
+ }
+
+ /**
+ * Reset all the spaces to their default customization state.
+ */
+ #reset() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ for (const pane of tabPanes) {
+ pane.reset();
+ }
+ }
+
+ /**
+ * Save the current state of the toolbar and hide the customization.
+ */
+ #save() {
+ const tabPanes = Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane")
+ );
+ const state = Object.fromEntries(
+ tabPanes
+ .filter(pane => !pane.matchesDefaultState)
+ .map(pane => [pane.getAttribute("space"), pane.itemIds])
+ );
+ Services.prefs.setIntPref(
+ BUTTON_STYLE_PREF,
+ BUTTON_STYLE_MAP.indexOf(this.#buttonStyle.value)
+ );
+ // Toggle happens before saving, so the newly restored buttons don't have to
+ // be updated when the globalOverlay flag on tabmail goes away.
+ this.toggle(false);
+ storeState(state);
+ }
+
+ /**
+ * Initialize the contents of this from the current state. Specifically makes
+ * sure all the spaces have a tab, and all tabs still have a space.
+ *
+ * @param {boolean} [deep = false] - If true calls initialize on all tab
+ * panes.
+ */
+ initialize(deep = false) {
+ const state = getState();
+ const existingTabs = Array.from(this.#tabList.children);
+ const tabSpaces = existingTabs.map(tab => tab.id.split("-").pop());
+ const spaceNames = new Set(gSpacesToolbar.spaces.map(space => space.name));
+ const removedTabs = existingTabs.filter(
+ (tab, index) => !spaceNames.has(tabSpaces[index])
+ );
+ for (const tab of removedTabs) {
+ tab.pane.remove();
+ tab.remove();
+ }
+ const newTabs = gSpacesToolbar.spaces.map(space => {
+ if (tabSpaces.includes(space.name)) {
+ const tab = existingTabs[tabSpaces.indexOf(space.name)];
+ if (!BUILTIN_SPACES.has(space.name)) {
+ const title = space.button.title;
+ tab.textContent = title;
+ tab.title = title;
+ tab.style = space.button.querySelector("img").style.cssText;
+ }
+ return [tab, tab.pane];
+ }
+ const { tab, tabPane } = this.#makeSpaceTab(space);
+ return [tab, tabPane];
+ });
+ this.#tabList.replaceChildren(...newTabs.map(([tab]) => tab));
+ let previousNode = this.#tabList;
+ for (const [, tabPane] of newTabs) {
+ previousNode.after(tabPane);
+ const space = tabPane.getAttribute("space");
+ if (state.hasOwnProperty(space)) {
+ tabPane.setAttribute("current-items", state[space].join(","));
+ } else {
+ tabPane.removeAttribute("current-items");
+ }
+ previousNode = tabPane;
+ if (deep) {
+ tabPane.initialize(deep);
+ }
+ }
+ this.#buttonStyle.value =
+ BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)];
+ // Update state of reset to default button only when updating tab panes too.
+ if (deep) {
+ this.#updateResetToDefault();
+ this.#updateUnsavedChangesState();
+ }
+ }
+
+ /**
+ * Toggle unified toolbar customization.
+ *
+ * @param {boolean} [visible] - If passed, defines if customization should
+ * be active.
+ */
+ toggle(visible) {
+ if (visible) {
+ this.initialize(true);
+ let tabToSelect;
+ if (gSpacesToolbar.currentSpace) {
+ tabToSelect = document.getElementById(
+ `unified-toolbar-customization-tab-${gSpacesToolbar.currentSpace.name}`
+ );
+ }
+ if (
+ !tabToSelect &&
+ !this.querySelector(`unified-toolbar-tab[selected="true"]`)
+ ) {
+ tabToSelect = this.querySelector("unified-toolbar-tab");
+ }
+ if (tabToSelect) {
+ tabToSelect.select();
+ }
+ }
+
+ document.getElementById("tabmail").globalOverlay = visible;
+ document.documentElement.classList.toggle(
+ "customizingUnifiedToolbar",
+ visible
+ );
+
+ // Make sure focus is where it belongs.
+ if (visible) {
+ if (
+ document.activeElement !== this &&
+ !this.contains(document.activeElement)
+ ) {
+ Services.focus.moveFocus(
+ window,
+ this,
+ Services.focus.MOVEFOCUS_FIRST,
+ 0
+ );
+ }
+ } else {
+ Services.focus.moveFocus(
+ window,
+ document.body,
+ Services.focus.MOVEFOCUS_ROOT,
+ 0
+ );
+ }
+ }
+
+ /**
+ * Check if an item is active in all spaces.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the given item is found active in all spaces.
+ */
+ activeInAllSpaces(itemId) {
+ return Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane"),
+ pane => pane.hasItem(itemId)
+ ).every(hasItem => hasItem);
+ }
+
+ /**
+ * Check if an item is active in two or more spaces.
+ *
+ * @param {string} itemId - Item ID of the item to check for.
+ * @returns {boolean} If the given item is active in at least two spaces.
+ */
+ activeInMultipleSpaces(itemId) {
+ return (
+ Array.from(
+ this.querySelectorAll("unified-toolbar-customization-pane"),
+ pane => pane.hasItem(itemId)
+ ).filter(Boolean).length > 1
+ );
+ }
+}
+customElements.define(
+ "unified-toolbar-customization",
+ UnifiedToolbarCustomization
+);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs
new file mode 100644
index 0000000000..134aec6cf1
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar-tab.mjs
@@ -0,0 +1,119 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Template ID: unifiedToolbarTabTemplate
+ * Attributes:
+ * - selected: If the tab is active.
+ * - aria-controls: The ID of the tab pane this controls.
+ * Events:
+ * - tabswitch: When the active tab is changed.
+ */
+class UnifiedToolbarTab extends HTMLElement {
+ /**
+ * @type {?HTMLButtonElement}
+ */
+ #tab = null;
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ this.setAttribute("role", "presentation");
+ const shadowRoot = this.attachShadow({ mode: "open" });
+
+ const template = document
+ .getElementById("unifiedToolbarTabTemplate")
+ .content.cloneNode(true);
+ this.#tab = template.querySelector("button");
+ this.#tab.tabIndex = this.hasAttribute("selected") ? 0 : -1;
+ if (this.hasAttribute("selected")) {
+ this.#tab.setAttribute("aria-selected", "true");
+ }
+ this.#tab.setAttribute("aria-controls", this.getAttribute("aria-controls"));
+ this.removeAttribute("aria-controls");
+
+ const styles = document.createElement("link");
+ styles.setAttribute("rel", "stylesheet");
+ styles.setAttribute(
+ "href",
+ "chrome://messenger/skin/shared/unifiedToolbarTab.css"
+ );
+
+ shadowRoot.append(styles, template);
+
+ this.#tab.addEventListener("click", () => {
+ this.select();
+ });
+ this.#tab.addEventListener("keydown", this.#handleKey);
+ }
+
+ #handleKey = event => {
+ const rightIsForward = document.dir === "ltr";
+ const rightSibling =
+ (rightIsForward ? "next" : "previous") + "ElementSibling";
+ const leftSibling =
+ (rightIsForward ? "previous" : "next") + "ElementSibling";
+ switch (event.key) {
+ case "ArrowLeft":
+ this[leftSibling]?.focus();
+ break;
+ case "ArrowRight":
+ this[rightSibling]?.focus();
+ break;
+ case "Home":
+ this.parentNode.firstElementChild?.focus();
+ break;
+ case "End":
+ this.parentNode.lastElementChild?.focus();
+ break;
+ default:
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ #toggleTabPane(visible) {
+ this.pane.hidden = !visible;
+ }
+
+ /**
+ * Select this tab. Deselects the previously selected tab and shows the tab
+ * pane for this tab.
+ */
+ select() {
+ this.parentElement
+ .querySelector("unified-toolbar-tab[selected]")
+ ?.unselect();
+ this.#tab.setAttribute("aria-selected", "true");
+ this.#tab.tabIndex = 0;
+ this.setAttribute("selected", true);
+ this.#toggleTabPane(true);
+ const tabSwitchEvent = new Event("tabswitch", {
+ bubbles: true,
+ });
+ this.dispatchEvent(tabSwitchEvent);
+ }
+
+ /**
+ * Remove the selection for this tab and hide the associated tab pane.
+ */
+ unselect() {
+ this.#tab.removeAttribute("aria-selected");
+ this.#tab.tabIndex = -1;
+ this.removeAttribute("selected");
+ this.#toggleTabPane(false);
+ }
+
+ focus() {
+ this.#tab.focus();
+ }
+
+ get pane() {
+ return document.getElementById(this.#tab.getAttribute("aria-controls"));
+ }
+}
+customElements.define("unified-toolbar-tab", UnifiedToolbarTab);
diff --git a/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs b/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs
new file mode 100644
index 0000000000..e8624750af
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unified-toolbar.mjs
@@ -0,0 +1,540 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global gSpacesToolbar, ToolbarContextMenu */
+
+import { getState } from "resource:///modules/CustomizationState.mjs";
+import {
+ BUTTON_STYLE_MAP,
+ BUTTON_STYLE_PREF,
+} from "resource:///modules/ButtonStyle.mjs";
+import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ getDefaultItemIdsForSpace: "resource:///modules/CustomizableItems.sys.mjs",
+ getAvailableItemIdsForSpace: "resource:///modules/CustomizableItems.sys.mjs",
+ SKIP_FOCUS_ITEM_IDS: "resource:///modules/CustomizableItems.sys.mjs",
+});
+
+/**
+ * Unified toolbar container custom element. Used to contain the state
+ * management and interaction logic. Template: #unifiedToolbarTemplate.
+ * Requires unifiedToolbarPopups.inc.xhtml to be in a popupset of the same
+ * document.
+ */
+class UnifiedToolbar extends HTMLElement {
+ constructor() {
+ super();
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "buttonStyle",
+ BUTTON_STYLE_PREF,
+ 0,
+ (preference, prevVal, newVal) => {
+ if (preference !== BUTTON_STYLE_PREF) {
+ return;
+ }
+ this.classList.remove(prevVal);
+ this.classList.add(newVal);
+ },
+ value => BUTTON_STYLE_MAP[value]
+ );
+ }
+
+ /**
+ * List containing the customizable content of the unified toolbar.
+ *
+ * @type {?HTMLUListElement}
+ */
+ #toolbarContent = null;
+
+ /**
+ * The current customization state of the unified toolbar.
+ *
+ * @type {?UnifiedToolbarCustomizationState}
+ */
+ #state = null;
+
+ /**
+ * Arrays of item IDs available in a given space.
+ *
+ * @type {object}
+ */
+ #itemsAvailableInSpace = {};
+
+ /**
+ * Observer triggered when the state for the unified toolbar is changed.
+ *
+ * @type {nsIObserver}
+ */
+ #stateObserver = {
+ observe: (subject, topic) => {
+ if (topic === "unified-toolbar-state-change") {
+ this.initialize();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ /**
+ * A MozTabmail tab monitor to listen for tab switch and close events. Calls
+ * onTabSwitched on currently visible toolbar content and onTabClosing on
+ * all toolbar content.
+ *
+ * @type {object}
+ */
+ #tabMonitor = {
+ monitorName: "UnifiedToolbar",
+ onTabTitleChanged() {},
+ onTabSwitched: (tab, oldTab) => {
+ for (const element of this.#toolbarContent.children) {
+ if (!element.hidden) {
+ element.onTabSwitched(tab, oldTab);
+ }
+ }
+ },
+ onTabOpened() {},
+ onTabClosing: tab => {
+ for (const element of this.#toolbarContent.children) {
+ element.onTabClosing(tab);
+ }
+ },
+ onTabPersist() {},
+ onTabRestored() {},
+ };
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ // No shadow root so other stylesheets can style the contents of the
+ // toolbar, like the window controls.
+ this.hasConnected = true;
+ this.classList.add(this.buttonStyle);
+ const template = document
+ .getElementById("unifiedToolbarTemplate")
+ .content.cloneNode(true);
+
+ // TODO Don't show context menu when there is a native one, like for example
+ // in a search field.
+ template
+ .querySelector("#unifiedToolbarContainer")
+ .addEventListener("contextmenu", this.#handleContextMenu);
+ this.#toolbarContent = template.querySelector("#unifiedToolbarContent");
+
+ this.#toolbarContent.addEventListener("keydown", this.#handleKey, {
+ capture: true,
+ });
+ this.#toolbarContent.addEventListener(
+ "buttondisabled",
+ this.#handleButtonDisabled,
+ { capture: true }
+ );
+ this.#toolbarContent.addEventListener(
+ "buttonenabled",
+ this.#handleButtonEnabled,
+ { capture: true }
+ );
+
+ if (gSpacesToolbar.isLoaded) {
+ this.initialize();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", () => this.initialize(), {
+ once: true,
+ });
+ document
+ .getElementById("cmd_CustomizeMailToolbar")
+ .setAttribute("disabled", true);
+ }
+
+ this.append(template);
+
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .addEventListener("command", this.#handleCustomizeCommand);
+
+ document
+ .getElementById("menuBarToggleVisible")
+ .addEventListener("command", this.#handleMenuBarCommand);
+
+ document
+ .getElementById("spacesToolbar")
+ .addEventListener("spacechange", this.#handleSpaceChange);
+
+ Services.obs.addObserver(
+ this.#stateObserver,
+ "unified-toolbar-state-change",
+ true
+ );
+
+ if (document.readyState === "complete") {
+ document.getElementById("tabmail").registerTabMonitor(this.#tabMonitor);
+ return;
+ }
+ window.addEventListener(
+ "load",
+ () => {
+ document.getElementById("tabmail").registerTabMonitor(this.#tabMonitor);
+ },
+ { once: true }
+ );
+ }
+
+ disconnectedCallback() {
+ Services.obs.removeObserver(
+ this.#stateObserver,
+ "unified-toolbar-state-change"
+ );
+
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .removeEventListener("command", this.#handleCustomizeCommand);
+
+ document
+ .getElementById("spacesToolbar")
+ .removeEventListener("spacechange", this.#handleSpaceChange);
+
+ document.getElementById("tabmail").unregisterTabMonitor(this.#tabMonitor);
+ }
+
+ #handleContextMenu = event => {
+ if (!event.target.closest("#unifiedToolbarContent")) {
+ return;
+ }
+ const customizableElement = event.target.closest(
+ '[is="customizable-element"]'
+ );
+ if (customizableElement?.hasContextMenu) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const popup = document.getElementById("unifiedToolbarMenu");
+
+ // If not Mac OS, set checked attribute for menu item, otherwise remove item.
+ const menuBarMenuItem = document.getElementById("menuBarToggleVisible");
+ if (AppConstants.platform != "macosx") {
+ const menubarToolbar = document.getElementById("toolbar-menubar");
+ menuBarMenuItem.setAttribute(
+ "checked",
+ menubarToolbar.getAttribute("autohide") != "true"
+ );
+ } else if (menuBarMenuItem) {
+ menuBarMenuItem.remove();
+ // Remove the menubar separator as well.
+ const menuBarSeparator = document.getElementById(
+ "menuBarToggleMenuSeparator"
+ );
+ menuBarSeparator.remove();
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ if (gSpacesToolbar.isLoaded) {
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .removeAttribute("disabled");
+ } else {
+ document
+ .getElementById("unifiedToolbarCustomize")
+ .setAttribute("disabled", true);
+ }
+ ToolbarContextMenu.updateExtension(popup);
+ };
+
+ #handleCustomizeCommand = () => {
+ this.showCustomization();
+ };
+
+ #handleMenuBarCommand = () => {
+ const menubarToolbar = document.getElementById("toolbar-menubar");
+ const menuItem = document.getElementById("menuBarToggleVisible");
+
+ if (menubarToolbar.getAttribute("autohide") != "true") {
+ menubarToolbar.setAttribute("autohide", "true");
+ menuItem.removeAttribute("checked");
+ } else {
+ menuItem.setAttribute("checked", true);
+ menubarToolbar.removeAttribute("autohide");
+ }
+ Services.xulStore.persist(menubarToolbar, "autohide");
+ };
+
+ #handleSpaceChange = event => {
+ // Switch to the current space or show a generic default state toolbar.
+ this.#showToolbarForSpace(event.detail?.name ?? "default");
+ };
+
+ #handleKey = event => {
+ // Don't handle any key events within menupopups that are children of the
+ // toolbar contents.
+ if (event.target.closest("menupopup")) {
+ return;
+ }
+ switch (event.key) {
+ case "ArrowLeft":
+ case "ArrowRight": {
+ event.preventDefault();
+ event.stopPropagation();
+ const rightIsForward = document.dir !== "rtl";
+ //TODO groups split by search bar.
+ const focusableChildren = Array.from(
+ this.querySelectorAll(
+ `li[is="customizable-element"]:not([disabled], .skip-focus)`
+ )
+ ).filter(
+ element => !element.querySelector(".live-content button[disabled]")
+ );
+ if (!focusableChildren.length) {
+ return;
+ }
+ const activeItem = document.activeElement.closest(
+ 'li[is="customizable-element"]'
+ );
+ const activeIndex = focusableChildren.indexOf(activeItem);
+ if (activeIndex === -1) {
+ return;
+ }
+ if (!activeItem) {
+ focusableChildren[0].focus();
+ return;
+ }
+ const isForward = rightIsForward === (event.key === "ArrowRight");
+ const delta = isForward ? 1 : -1;
+ const focusableSibling = focusableChildren.at(activeIndex + delta);
+ if (focusableSibling) {
+ focusableSibling.tabIndex = 0;
+ focusableSibling.focus();
+ } else if (isForward) {
+ focusableChildren[0].tabIndex = 0;
+ focusableChildren[0].focus();
+ } else {
+ focusableChildren.at(-1).tabIndex = 0;
+ focusableChildren.at(-1).focus();
+ }
+ activeItem.tabIndex = -1;
+ }
+ }
+ };
+
+ #handleButtonDisabled = () => {
+ if (
+ this.#toolbarContent.querySelector(
+ 'li[is="customizable-element"]:not(.skip-focus) .live-content button[tabindex="0"]'
+ )
+ ) {
+ return;
+ }
+ const newItem = this.#toolbarContent
+ .querySelector(
+ 'li[is="customizable-element"]:not([disabled], .skip-focus) .live-content button:not([disabled])'
+ )
+ ?.closest('li[is="customizable-element"]');
+ if (newItem) {
+ newItem.tabIndex = 0;
+ }
+ };
+
+ #handleButtonEnabled = event => {
+ if (
+ this.#toolbarContent.querySelector(
+ 'li[is="customizable-element"]:not(.skip-focus) .live-content button[tabindex="0"]'
+ )
+ ) {
+ return;
+ }
+ // If there is currently no focusable button, make the button triggering the
+ // event available.
+ const newItem = event.target.closest('li[is="customizable-element"]');
+ if (newItem) {
+ newItem.tabIndex = 0;
+ }
+ };
+
+ /**
+ * Make sure the customization for unified toolbar is injected into the
+ * document.
+ *
+ * @returns {Promise<void>}
+ */
+ async #ensureCustomizationInserted() {
+ if (document.querySelector("unified-toolbar-customization")) {
+ return;
+ }
+ await import("./unified-toolbar-customization.mjs");
+ const customization = document.createElement(
+ "unified-toolbar-customization"
+ );
+ document.body.appendChild(customization);
+ }
+
+ /**
+ * Get the items currently visible in a given space. Filters out items that
+ * are part of the state but not visible.
+ *
+ * @param {string} space - Name of the space to get the active items for. May
+ * be "default" to indicate a generic default item set should be produced.
+ * @returns {string[]} Array of item IDs visible in the given space.
+ */
+ #getItemsForSpace(space) {
+ if (!this.#state[space]) {
+ this.#state[space] = lazy.getDefaultItemIdsForSpace(space);
+ }
+ if (!this.#itemsAvailableInSpace[space]) {
+ this.#itemsAvailableInSpace[space] = new Set(
+ lazy.getAvailableItemIdsForSpace(space, true)
+ );
+ }
+ return this.#state[space].filter(itemId =>
+ this.#itemsAvailableInSpace[space].has(itemId)
+ );
+ }
+
+ /**
+ * Show the items for the specified space in the toolbar. Only creates
+ * missing elements when not already created for another space.
+ *
+ * @param {string} space - Name of the space to make visible. May be "default"
+ * to indicate that a generic default state should be shown instead.
+ */
+ #showToolbarForSpace(space) {
+ if (!this.#state) {
+ return;
+ }
+ const itemIds = this.#getItemsForSpace(space);
+ // Handling elements which might occur more than once requires us to keep
+ // track which existing elements we've already used.
+ const elementTypeOffset = {};
+ let focusableElementSet = false;
+ const wantedElements = itemIds.map(itemId => {
+ // We want to re-use existing elements to reduce flicker when switching
+ // spaces and to preserve widget specific state, like a search string.
+ const existingElements = this.#toolbarContent.querySelectorAll(
+ `[item-id="${CSS.escape(itemId)}"]`
+ );
+ const nthChild = elementTypeOffset[itemId] ?? 0;
+ if (existingElements.length > nthChild) {
+ const existingElement = existingElements[nthChild];
+ elementTypeOffset[itemId] = nthChild + 1;
+ existingElement.hidden = false;
+ if (
+ !(
+ existingElement.details?.skipFocus ||
+ lazy.SKIP_FOCUS_ITEM_IDS.has(itemId)
+ ) &&
+ existingElement.querySelector(".live-content button:not([disabled])")
+ ) {
+ if (focusableElementSet) {
+ existingElement.tabIndex = -1;
+ } else {
+ existingElement.tabIndex = 0;
+ focusableElementSet = true;
+ }
+ }
+ return existingElement;
+ }
+ const element = document.createElement("li", {
+ is: "customizable-element",
+ });
+ element.setAttribute("item-id", itemId);
+ if (!lazy.SKIP_FOCUS_ITEM_IDS.has(itemId)) {
+ if (focusableElementSet) {
+ element.tabIndex = -1;
+ } else {
+ element.tabIndex = 0;
+ focusableElementSet = true;
+ }
+ }
+ return element;
+ });
+ for (const element of this.#toolbarContent.children) {
+ if (!wantedElements.includes(element)) {
+ element.hidden = true;
+ }
+ }
+ this.#toolbarContent.append(...wantedElements);
+ }
+
+ /**
+ * Initialize the unified toolbar contents.
+ */
+ initialize() {
+ this.#state = getState();
+ this.#itemsAvailableInSpace = {};
+ // Remove unused items from the toolbar.
+ const currentElements = this.#toolbarContent.children;
+ if (currentElements.length) {
+ const filledOutState = Object.fromEntries(
+ (gSpacesToolbar.spaces ?? Object.keys(this.#state)).map(space => [
+ space.name,
+ this.#getItemsForSpace(space.name),
+ ])
+ );
+ const allItems = new Set(Object.values(filledOutState).flat());
+ const spaceCounts = Object.keys(filledOutState).map(space =>
+ filledOutState[space].reduce((counts, itemId) => {
+ if (counts[itemId]) {
+ ++counts[itemId];
+ } else {
+ counts[itemId] = 1;
+ }
+ return counts;
+ }, {})
+ );
+ const elementCounts = Object.fromEntries(
+ Array.from(allItems, itemId => [
+ itemId,
+ Math.max(...spaceCounts.map(spaceCount => spaceCount[itemId])),
+ ])
+ );
+ const encounteredElements = {};
+ for (const element of currentElements) {
+ const itemId = element.getAttribute("item-id");
+ if (
+ allItems.has(itemId) &&
+ (!encounteredElements[itemId] ||
+ encounteredElements[itemId] < elementCounts[itemId])
+ ) {
+ encounteredElements[itemId] = encounteredElements[itemId]
+ ? encounteredElements[itemId] + 1
+ : 1;
+ continue;
+ }
+ // We don't need that many of this item.
+ element.remove();
+ }
+ }
+ this.#showToolbarForSpace(gSpacesToolbar.currentSpace?.name ?? "default");
+ document
+ .getElementById("cmd_CustomizeMailToolbar")
+ .removeAttribute("disabled");
+ }
+
+ /**
+ * Opens the customization UI for the unified toolbar.
+ */
+ async showCustomization() {
+ if (!gSpacesToolbar.isLoaded) {
+ return;
+ }
+ await this.#ensureCustomizationInserted();
+ document.querySelector("unified-toolbar-customization").toggle(true);
+ }
+
+ focus() {
+ this.firstElementChild.focus();
+ }
+}
+customElements.define("unified-toolbar", UnifiedToolbar);
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml
new file mode 100644
index 0000000000..88cb8b0c62
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarCustomizableItems.inc.xhtml
@@ -0,0 +1,366 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+<html:template id="searchBarItemTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <global-search-bar data-l10n-id="search-bar-item"
+ data-l10n-attrs="label"
+ aria-keyshortcuts="Control+K">
+ <span slot="placeholder" data-l10n-id="search-bar-placeholder-with-key2"></span>
+ <img data-l10n-id="search-bar-button"
+ slot="button"
+ class="search-button-icon"
+ src="" />
+ </global-search-bar>
+</html:template>
+
+<html:template id="writeMessageTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="cmd_newMessage"
+ label-id="toolbar-write-message-label"
+ data-l10n-id="toolbar-write-message"></button>
+</html:template>
+
+<html:template id="moveToTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ popup="toolbarMoveToPopup"
+ observes="cmd_moveMessage"
+ label-id="toolbar-move-to-label"
+ data-l10n-id="toolbar-move-to"></button>
+</html:template>
+
+<html:template id="calendarUnifinderTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_show_unifinder_command"
+ aria-pressed="false"
+ class="check-button"
+ label-id="toolbar-unifinder-label"
+ data-l10n-id="toolbar-unifinder"></button>
+</html:template>
+
+<html:template id="folderLocationTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="folder-location-button"
+ popup="toolbarFolderLocationPopup"
+ data-l10n-id="toolbar-folder-location"></button>
+</html:template>
+
+<html:template id="editEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_modify_focused_item_command"
+ label-id="toolbar-edit-event-label"
+ data-l10n-id="toolbar-edit-event"></button>
+</html:template>
+
+<html:template id="getMessagesTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="cmd_getMsgsForAuthAccounts"
+ label-id="toolbar-get-messages-label"
+ data-l10n-id="toolbar-get-messages"></button>
+</html:template>
+
+<html:template id="replyTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_reply"
+ label-id="toolbar-reply-label"
+ data-l10n-id="toolbar-reply"></button>
+</html:template>
+
+<html:template id="replyAllTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_replyall"
+ label-id="toolbar-reply-all-label"
+ data-l10n-id="toolbar-reply-all"></button>
+</html:template>
+
+<html:template id="replyToListTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="reply-list-button"
+ command="cmd_replylist"
+ label-id="toolbar-reply-to-list-label"
+ data-l10n-id="toolbar-reply-to-list"></button>
+</html:template>
+
+<html:template id="redirectTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_redirect"
+ label-id="toolbar-redirect-label"
+ data-l10n-id="toolbar-redirect"></button>
+</html:template>
+
+<html:template id="archiveTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_archive"
+ label-id="toolbar-archive-label"
+ data-l10n-id="toolbar-archive"></button>
+</html:template>
+
+<html:template id="conversationTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_openConversation"
+ label-id="toolbar-conversation-label"
+ data-l10n-id="toolbar-conversation"></button>
+</html:template>
+
+<html:template id="previousUnreadTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_previousUnreadMsg"
+ label-id="toolbar-previous-unread-label"
+ data-l10n-id="toolbar-previous-unread"></button>
+</html:template>
+
+<html:template id="previousTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_previousMsg"
+ label-id="toolbar-previous-label"
+ data-l10n-id="toolbar-previous"></button>
+</html:template>
+
+<html:template id="nextUnreadTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_nextUnreadMsg"
+ label-id="toolbar-next-unread-label"
+ data-l10n-id="toolbar-next-unread"></button>
+</html:template>
+
+<html:template id="nextTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_nextMsg"
+ label-id="toolbar-next-label"
+ data-l10n-id="toolbar-next"></button>
+</html:template>
+
+<html:template id="junkTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_markAsJunk"
+ label-id="toolbar-junk-label"
+ data-l10n-id="toolbar-junk"></button>
+</html:template>
+
+<html:template id="deleteTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="delete-button"
+ label-id="toolbar-delete-label"
+ data-l10n-id="toolbar-delete-title"></button>
+</html:template>
+
+<html:template id="compactTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="compact-folder-button"
+ label-id="toolbar-compact-label"
+ data-l10n-id="toolbar-compact"></button>
+</html:template>
+
+<html:template id="addAsEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="add-to-calendar-button"
+ type="event"
+ label-id="toolbar-add-as-event-label"
+ data-l10n-id="toolbar-add-as-event"></button>
+</html:template>
+
+<html:template id="addAsTaskTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="add-to-calendar-button"
+ type="task"
+ label-id="toolbar-add-as-task-label"
+ data-l10n-id="toolbar-add-as-task"></button>
+</html:template>
+
+<html:template id="tagMessageTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ popup="toolbarTagPopup"
+ observes="cmd_tag"
+ label-id="toolbar-tag-message-label"
+ data-l10n-id="toolbar-tag-message"></button>
+</html:template>
+
+<html:template id="forwardInlineTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_forwardInline"
+ label-id="toolbar-forward-inline-label"
+ data-l10n-id="toolbar-forward-inline"></button>
+</html:template>
+
+<html:template id="forwardAttachmentTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_forwardAttachment"
+ label-id="toolbar-forward-attachment-label"
+ data-l10n-id="toolbar-forward-attachment"></button>
+</html:template>
+
+<html:template id="markAsTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ popup="toolbarMarkPopup"
+ observes="cmd_tag"
+ label-id="toolbar-mark-as-label"
+ data-l10n-id="toolbar-mark-as"></button>
+</html:template>
+
+<html:template id="viewPickerTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="view-picker-button"
+ popup="toolbarViewPickerPopup"
+ data-l10n-id="toolbar-view-picker"></button>
+</html:template>
+
+<html:template id="addressBookTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="addressbook"
+ label-id="toolbar-address-book-label"
+ data-l10n-id="toolbar-address-book"></button>
+</html:template>
+
+<html:template id="chatTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="chat"
+ label-id="toolbar-chat-label"
+ data-l10n-id="toolbar-chat"></button>
+</html:template>
+
+<html:template id="addOnsAndThemesTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="addons-button"
+ label-id="toolbar-add-ons-and-themes-label"
+ data-l10n-id="toolbar-add-ons-and-themes"></button>
+</html:template>
+
+<html:template id="calendarTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="calendar"
+ label-id="toolbar-calendar-label"
+ data-l10n-id="toolbar-calendar"></button>
+</html:template>
+
+<html:template id="tasksTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="tasks"
+ label-id="toolbar-tasks-label"
+ data-l10n-id="toolbar-tasks"></button>
+</html:template>
+
+<html:template id="mailTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="space-button"
+ space="mail"
+ label-id="toolbar-mail-label"
+ data-l10n-id="toolbar-mail"></button>
+</html:template>
+
+<html:template id="printTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_print"
+ label-id="toolbar-print-label"
+ data-l10n-id="toolbar-print"></button>
+</html:template>
+
+<html:template id="quickFilterBarTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="quick-filter-bar-toggle"
+ command="cmd_toggleQuickFilterBar"
+ class="check-button"
+ label-id="toolbar-quick-filter-bar-label"
+ data-l10n-id="toolbar-quick-filter-bar"></button>
+</html:template>
+
+<html:template id="synchronizeTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_reload_remote_calendars"
+ label-id="toolbar-synchronize-label"
+ data-l10n-id="toolbar-synchronize"></button>
+</html:template>
+
+<html:template id="newEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_new_event_command"
+ label-id="toolbar-new-event-label"
+ data-l10n-id="toolbar-new-event"></button>
+</html:template>
+
+<html:template id="newTaskTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_new_todo_command"
+ label-id="toolbar-new-task-label"
+ data-l10n-id="toolbar-new-task"></button>
+</html:template>
+
+<html:template id="goToTodayTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_go_to_today_command"
+ observes="calendar_mode_calendar"
+ label-id="toolbar-go-to-today-label"
+ data-l10n-id="toolbar-go-to-today"></button>
+</html:template>
+
+<html:template id="deleteEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="calendar_delete_focused_item_command"
+ label-id="toolbar-delete-event-label"
+ data-l10n-id="toolbar-delete-event"></button>
+</html:template>
+
+<html:template id="printEventTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="unified-toolbar-button"
+ command="cmd_print"
+ label-id="toolbar-print-event-label"
+ data-l10n-id="toolbar-print-event"></button>
+</html:template>
+
+<html:template id="goBackTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-go-button"
+ direction="back"
+ label-id="toolbar-go-back-label"
+ data-l10n-id="toolbar-go-back"></button>
+</html:template>
+
+<html:template id="goForwardTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-go-button"
+ direction="forward"
+ label-id="toolbar-go-forward-label"
+ data-l10n-id="toolbar-go-forward"></button>
+</html:template>
+
+<html:template id="stopTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button is="mail-tab-button"
+ command="cmd_stop"
+ label-id="toolbar-stop-label"
+ data-l10n-id="toolbar-stop"></button>
+</html:template>
+
+<html:template id="throbberTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <img class="throbber-icon" alt="" data-l10n-id="toolbar-throbber" />
+</html:template>
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml
new file mode 100644
index 0000000000..b0edb1b67f
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml
@@ -0,0 +1,133 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+<menupopup id="unifiedToolbarMenu">
+ <menuitem id="menuBarToggleVisible"
+ type="checkbox"
+ label="&menubarCmd.label;"
+ accesskey="&menubarCmd.accesskey;"/>
+ <menuseparator id="menuBarToggleMenuSeparator"/>
+ <menuitem id="unifiedToolbarCustomize" data-l10n-id="customize-menu-customize" />
+ <menuseparator id="extensionsMailToolbarMenuSeparator"/>
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+<menupopup id="customizationTargetMenu">
+ <menuitem id="customizationTargetEnd" data-l10n-id="customize-target-end" />
+ <menuitem id="customizationTargetForward" data-l10n-id="customize-target-forward" />
+ <menuitem id="customizationTargetBackward" data-l10n-id="customize-target-backward" />
+ <menuitem id="customizationTargetStart" data-l10n-id="customize-target-start" />
+ <menuitem id="customizationTargetRemove" data-l10n-id="customize-target-remove" />
+ <menuitem id="customizationTargetRemoveEverywhere"
+ data-l10n-id="customize-target-remove-everywhere"
+ hidden="true" />
+ <menuitem id="customizationTargetAddEverywhere"
+ data-l10n-id="customize-target-add-everywhere"
+ hidden="true" />
+</menupopup>
+<menupopup id="customizationPaletteMenu">
+ <menuitem id="customizationPaletteAddEverywhere"
+ data-l10n-id="customize-palette-add-everywhere"
+ hidden="true" />
+</menupopup>
+<menupopup is="folder-menupopup" id="toolbarMoveToPopup"
+ mode="filing"
+ showRecent="true"
+ showFileHereLabel="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"
+ oncommand="goDoCommand('cmd_moveMessage', event.target._folder);event.stopPropagation()"/>
+<menupopup is="folder-menupopup" id="toolbarFolderLocationPopup"
+ class="menulist-menupopup"
+ mode="notDeferred"
+ showFileHereLabel="true"/>
+<menupopup id="toolbarTagPopup"
+ onpopupshowing="InitMessageTags(this);">
+ <menuitem id="button-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ command="cmd_addTag"/>
+ <menuitem id="button-manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ command="cmd_manageTags"/>
+ <menuseparator id="button-tagpopup-sep-afterTagAddNew"/>
+ <menuitem id="button-tagRemoveAll"
+ command="cmd_removeTags"/>
+ <menuseparator id="button-afterTagRemoveAllSeparator"/>
+</menupopup>
+<menupopup id="toolbarMarkPopup" onpopupshowing="InitMessageMark()">
+ <menuitem id="markReadToolbarItem"
+ label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsRead"/>
+ <menuitem id="markUnreadToolbarItem"
+ label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsUnread"/>
+ <menuitem id="button-markThreadAsRead"
+ label="&markThreadAsReadCmd.label;"
+ key="key_markThreadAsRead"
+ accesskey="&markThreadAsReadCmd.accesskey;"
+ command="cmd_markThreadAsRead"/>
+ <menuitem id="button-markReadByDate"
+ label="&markReadByDateCmd.label;"
+ key="key_markReadByDate"
+ accesskey="&markReadByDateCmd.accesskey;"
+ command="cmd_markReadByDate"/>
+ <menuitem id="button-markAllRead"
+ label="&markAllReadCmd.label;"
+ key="key_markAllRead"
+ accesskey="&markAllReadCmd.accesskey;"
+ command="cmd_markAllRead"/>
+ <menuseparator id="button-markAllReadSeparator"/>
+ <menuitem id="markFlaggedToolbarItem"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"
+ key="key_toggleFlagged"
+ command="cmd_markAsFlagged"/>
+</menupopup>
+<menupopup id="toolbarViewPickerPopup"
+ onpopupshowing="RefreshViewPopup(this);">
+ <menuitem id="viewPickerAll" value="0"
+ label="&viewAll.label;"
+ type="radio"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuitem id="viewPickerUnread" value="1"
+ label="&viewUnread.label;"
+ type="radio"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuitem id="viewPickerNotDeleted" value="3"
+ label="&viewNotDeleted.label;"
+ type="radio"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuseparator id="afterViewPickerUnreadSeparator"/>
+ <menu id="viewPickerTags" label="&viewTags.label;">
+ <menupopup id="viewPickerTagsPopup"
+ class="menulist-menupopup"
+ onpopupshowing="RefreshTagsPopup(this);"/>
+ </menu>
+ <menu id="viewPickerCustomViews" label="&viewCustomViews.label;">
+ <menupopup id="viewPickerCustomViewsPopup"
+ class="menulist-menupopup"
+ onpopupshowing="RefreshCustomViewsPopup(this);"/>
+ </menu>
+ <menuseparator id="afterViewPickerCustomViewsSeparator"/>
+ <menuitem id="viewPickerVirtualFolder"
+ value="7"
+ label="&viewVirtualFolder.label;"
+ oncommand="ViewChangeByMenuitem(this);"/>
+ <menuitem id="viewPickerCustomize"
+ value="8"
+ label="&viewCustomizeView.label;"
+ oncommand="ViewChangeByMenuitem(this);"/>
+</menupopup>
+<menupopup id="messageHistoryPopup">
+</menupopup>
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml
new file mode 100644
index 0000000000..3953ed8871
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml
@@ -0,0 +1,137 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+#include ./unifiedToolbarCustomizableItems.inc.xhtml
+
+<html:template id="searchBarTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <form>
+ <input type="search" placeholder="" required="required" />
+ <div aria-hidden="true"><slot name="placeholder"></slot></div>
+ <button class="button button-flat icon-button"><slot name="button"></slot></button>
+ </form>
+</html:template>
+
+<html:template id="unifiedToolbarTemplate">
+# Required for placing the window controls in the proper place without having
+# them inside the toolbar.
+ <html:div id="unifiedToolbarContainer">
+ <html:div id="unifiedToolbar" role="toolbar">
+#include ../../../base/content/spacesToolbarPin.inc.xhtml
+ <html:ul id="unifiedToolbarContent" class="unified-toolbar">
+ </html:ul>
+ <html:div id="notification-popup-box" hidden="true">
+ <html:img id="addons-notification-icon"
+ src="chrome://messenger/skin/icons/new/compact/extension.svg"
+ alt=""
+ class="notification-anchor-icon"
+ role="button" />
+ </html:div>
+ <toolbarbutton id="button-appmenu"
+ type="menu"
+ badged="true"
+ class="button toolbar-button button-appmenu"
+ label="&appmenuButton.label;"
+ tooltiptext="&appmenuButton1.tooltip;"
+ tabindex="0" />
+ </html:div>
+#include ../../../base/content/messenger-titlebar-items.inc.xhtml
+ </html:div>
+</html:template>
+
+<html:template id="unifiedToolbarCustomizationTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <form id="unifiedToolbarCustomizationContainer"
+ aria-labelledby="customizationHeading">
+ <h1 id="customizationHeading" data-l10n-id="customize-title"></h1>
+ <div role="tablist" id="customizationTabs" data-l10n-id="customize-spaces-tabs"></div>
+ <div id="customizationFooter">
+ <div>
+ <button type="reset"
+ class="button"
+ data-l10n-id="customize-restore-default"></button>
+ <button id="customizationToSettingsButton"
+ type="button"
+ class="button link-button"
+ data-l10n-id="customize-change-appearance"></button>
+ </div>
+ <div>
+ <label id="buttonStyleLabel"
+ for="buttonStyle"
+ data-l10n-id="customize-button-style-label"></label>
+ <select id="buttonStyle" class="select">
+ <option value="icons-beside-text"
+ data-l10n-id="customize-button-style-icons-beside-text-option"
+ selected="selected"></option>
+ <option value="icons-above-text"
+ data-l10n-id="customize-button-style-icons-above-text-option"></option>
+ <option value="icons-only"
+ data-l10n-id="customize-button-style-icons-only-option"></option>
+ <option value="text-only"
+ data-l10n-id="customize-button-style-text-only-option"></option>
+ </select>
+ </div>
+ <div>
+ <button id="unifiedToolbarCustomizationCancel"
+ type="button"
+ class="button"
+ data-l10n-id="customize-cancel"></button>
+ <button type="submit"
+ class="button button-primary"
+ data-l10n-id="customize-save"
+ disabled="disabled"></button>
+ </div>
+ </div>
+ <small id="unifiedToolbarCustomizationUnsavedChanges"
+ data-l10n-id="customize-unsaved-changes"
+ hidden="hidden"></small>
+ </form>
+</html:template>
+
+<html:template id="unifiedToolbarTabTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button role="tab">
+ <img alt="" src="" part="icon" />
+ <span><slot></slot></span>
+ </button>
+</html:template>
+
+<html:template id="unifiedToolbarCustomizationPaneTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <ul is="customization-target"
+ data-l10n-id="customize-main-toolbar-target"
+ class="toolbar-target unified-toolbar"></ul>
+ <search-bar data-l10n-id="customize-search-bar"
+ data-l10n-attrs="label"
+ class="palette-search">
+ <img data-l10n-id="search-bar-button"
+ slot="button"
+ src=""
+ class="search-button-icon" />
+ </search-bar>
+ <div class="customization-palettes">
+ <h2 class="space-specific-title"></h2>
+ <ul is="customization-palette" class="space-specific-palette">
+ </ul>
+ <h2 data-l10n-id="customize-palette-generic-title"
+ class="generic-palette-title"></h2>
+ <ul is="customization-palette" space="all" class="generic-palette">
+ </ul>
+ </div>
+</html:template>
+
+<html:template id="unifiedToolbarCustomizableElementTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <div class="live-content"></div>
+ <div class="preview">
+ <img src="" alt="" class="preview-icon" />
+ <span class="preview-label"></span>
+ </div>
+</html:template>
+
+<html:template id="unifiedToolbarButtonTemplate"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <img class="button-icon" alt="" src="" />
+ <span class="button-label"></span>
+</html:template>
diff --git a/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css
new file mode 100644
index 0000000000..3fcafe9c48
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/content/unifiedToolbarWebextensions.css
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* This file needs to be in content so it can load the moz-extension:// images. */
+
+.unified-toolbar .extension-action .button-icon {
+ height: 16px;
+ width: 16px;
+ margin-inline: 1px;
+ content: var(--webextension-toolbar-image, inherit);
+}
+
+:is(.icons-only, .icons-above-text, .icons-beside-text) .extension-action .prefer-icon-only .button-label {
+ display: none;
+}
+
+.unified-toolbar .extension-action .button-icon:-moz-lwtheme {
+ content: var(--webextension-toolbar-image-dark, inherit);
+}
+
+.extension-action .preview-icon {
+ content: var(--webextension-icon, inherit);
+}
+
+@media (prefers-color-scheme: dark) {
+ .unified-toolbar .extension-action .button-icon,
+ :root[lwt-tree-brighttext] .unified-toolbar .extension-action .button-icon {
+ content: var(--webextension-toolbar-image-light, inherit) !important;
+ }
+}
+
+
+@media (min-resolution: 1.1dppx) {
+ .unified-toolbar .extension-action .button-icon {
+ content: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ .unified-toolbar .extension-action .button-icon:-moz-lwtheme {
+ content: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ .extension-action .preview-icon {
+ content: var(--webextension-icon-2x, inherit);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ .unified-toolbar .extension-action .button-icon,
+ :root[lwt-tree-brighttext] .unified-toolbar .extension-action .button-icon {
+ content: var(--webextension-toolbar-image-2x-light, inherit) !important;
+ }
+ }
+}
diff --git a/comm/mail/components/unifiedtoolbar/jar.mn b/comm/mail/components/unifiedtoolbar/jar.mn
new file mode 100644
index 0000000000..ad4478e170
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/jar.mn
@@ -0,0 +1,29 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+messenger.jar:
+ content/messenger/unifiedtoolbar/customizable-element.mjs (content/customizable-element.mjs)
+ content/messenger/unifiedtoolbar/customization-palette.mjs (content/customization-palette.mjs)
+ content/messenger/unifiedtoolbar/customization-target.mjs (content/customization-target.mjs)
+ content/messenger/unifiedtoolbar/add-to-calendar-button.mjs (content/items/add-to-calendar-button.mjs)
+ content/messenger/unifiedtoolbar/addons-button.mjs (content/items/addons-button.mjs)
+ content/messenger/unifiedtoolbar/compact-folder-button.mjs (content/items/compact-folder-button.mjs)
+ content/messenger/unifiedtoolbar/delete-button.mjs (content/items/delete-button.mjs)
+ content/messenger/unifiedtoolbar/folder-location-button.mjs (content/items/folder-location-button.mjs)
+ content/messenger/unifiedtoolbar/global-search-bar.mjs (content/items/global-search-bar.mjs)
+ content/messenger/unifiedtoolbar/mail-go-button.mjs (content/items/mail-go-button.mjs)
+ content/messenger/unifiedtoolbar/quick-filter-bar-toggle.mjs (content/items/quick-filter-bar-toggle.mjs)
+ content/messenger/unifiedtoolbar/space-button.mjs (content/items/space-button.mjs)
+ content/messenger/unifiedtoolbar/view-picker-button.mjs (content/items/view-picker-button.mjs)
+ content/messenger/unifiedtoolbar/extension-action-button.mjs (content/extension-action-button.mjs)
+ content/messenger/unifiedtoolbar/list-box-selection.mjs (content/list-box-selection.mjs)
+ content/messenger/unifiedtoolbar/mail-tab-button.mjs (content/mail-tab-button.mjs)
+ content/messenger/unifiedtoolbar/reply-list-button.mjs (content/items/reply-list-button.mjs)
+ content/messenger/unifiedtoolbar/search-bar.mjs (content/search-bar.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar.mjs (content/unified-toolbar.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-button.mjs (content/unified-toolbar-button.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-customization.mjs (content/unified-toolbar-customization.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-customization-pane.mjs (content/unified-toolbar-customization-pane.mjs)
+ content/messenger/unifiedtoolbar/unified-toolbar-tab.mjs (content/unified-toolbar-tab.mjs)
+ content/messenger/unifiedtoolbar/unifiedToolbarWebextensions.css (content/unifiedToolbarWebextensions.css)
diff --git a/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs b/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs
new file mode 100644
index 0000000000..c65f3ed16a
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/ButtonStyle.mjs
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Array of button styles with the class name at the index of the corresponding
+ * button style pref integer value.
+ *
+ * @type {Array<string>}
+ */
+export const BUTTON_STYLE_MAP = [
+ "icons-beside-text",
+ "icons-above-text",
+ "icons-only",
+ "text-only",
+];
+
+/**
+ * Name of preference that stores the button style as an integer.
+ *
+ * @type {string}
+ */
+export const BUTTON_STYLE_PREF = "toolbar.unifiedtoolbar.buttonstyle";
diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs
new file mode 100644
index 0000000000..eb9ccee46f
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/CustomizableItems.sys.mjs
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import CUSTOMIZABLE_ITEMS from "resource:///modules/CustomizableItemsDetails.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "glodaEnabled",
+ "mailnews.database.global.indexer.enabled",
+ true,
+ () => Services.obs.notifyObservers(null, "unified-toolbar-state-change")
+);
+
+const DEFAULT_ITEMS = ["spacer", "search-bar", "spacer"];
+const DEFAULT_ITEMS_WITHOUT_SEARCH = ["spacer"];
+
+/**
+ * @type {{id: string, spaces: string[], installDate: Date}[]}
+ */
+const EXTENSIONS = [];
+
+export const EXTENSION_PREFIX = "ext-";
+
+/**
+ * Add an extension button that is available in the given spaces. Defaults to
+ * making the button only available in the mail space. To provide it in all
+ * spaces, pass an empty array for the spaces.
+ *
+ * @param {string} id - Extension ID to add the button for.
+ * @param {string[]} [spaces=["mail"]] - Array of spaces the button can be used
+ * in.
+ */
+export async function registerExtension(id, spaces = ["mail"]) {
+ if (EXTENSIONS.some(extension => extension.id === id)) {
+ return;
+ }
+ const addon = await lazy.AddonManager.getAddonByID(id);
+ EXTENSIONS.push({
+ id,
+ spaces,
+ installDate: addon?.installDate ?? new Date(),
+ });
+ EXTENSIONS.sort(
+ (extA, extB) => extA.installDate.valueOf() - extB.installDate.valueOf()
+ );
+}
+
+/**
+ * Remove the extension from the palette of available items.
+ *
+ * @param {string} id - Extension ID to remove.
+ */
+export function unregisterExtension(id) {
+ const index = EXTENSIONS.findIndex(extension => extension.id === id);
+ EXTENSIONS.splice(index, 1);
+}
+
+/**
+ * Get the IDs for the extension buttons available in a given space.
+ *
+ * @param {string} [space] - Space name, "default" or falsy value to specify the
+ * space the extension items should be returned for. For default, extensions
+ * explicitly available in the default space are returned. With a falsy value,
+ * extensions available in all spaces are returned.
+ * @param {boolean} [includeSpaceAgnostic=false] - When set, extensions that are
+ * available for all spaces and the provided space are returned. Only has an
+ * effect if space is not falsy.
+ * @returns {string[]} Array of item IDs for extensions in the given space.
+ */
+function getExtensionsForSpace(space, includeSpaceAgnostic = false) {
+ return EXTENSIONS.filter(
+ extension =>
+ (space && extension.spaces?.includes(space)) ||
+ ((!space || includeSpaceAgnostic) && !extension.spaces?.length)
+ ).map(extension => `${EXTENSION_PREFIX}${extension.id}`);
+}
+
+/**
+ * Get the items available for the unified toolbar in a given space.
+ *
+ * @param {string} [space] - ID of the space to get the available exclusive
+ * items of. When omitted only items allowed in all spaces are returned.
+ * @param {boolean} [includeSpaceAgnostic=false] - When set, extensions that are
+ * available for all spaces and the provided space are returned. Only has an
+ * effect if space is not falsy.
+ * @returns {string[]} Array of item IDs available in the space.
+ */
+export function getAvailableItemIdsForSpace(
+ space,
+ includeSpaceAgnostic = false
+) {
+ return CUSTOMIZABLE_ITEMS.filter(
+ item =>
+ ((space && item.spaces?.includes(space)) ||
+ ((!space || includeSpaceAgnostic) &&
+ (!item.spaces || item.spaces.length === 0))) &&
+ (item.id !== "search-bar" || lazy.glodaEnabled)
+ )
+ .map(item => item.id)
+ .concat(getExtensionsForSpace(space, includeSpaceAgnostic));
+}
+
+/**
+ * Retrieve the set of items that are in the default configuration of the
+ * toolbar for a given space.
+ *
+ * @param {string} space - ID of the space to get the default items for.
+ * "default" is passed to indicate a default state without any active space.
+ * @returns {string[]} Array of item IDs to show by default in the space.
+ */
+export function getDefaultItemIdsForSpace(space) {
+ return (
+ lazy.glodaEnabled ? DEFAULT_ITEMS : DEFAULT_ITEMS_WITHOUT_SEARCH
+ ).concat(getExtensionsForSpace(space, true));
+}
+
+/**
+ * Set of item IDs that can occur more than once in the targets of a space.
+ *
+ * @type {Set<string>}
+ */
+export const MULTIPLE_ALLOWED_ITEM_IDS = new Set(
+ CUSTOMIZABLE_ITEMS.filter(item => item.allowMultiple).map(item => item.id)
+);
+
+export const SKIP_FOCUS_ITEM_IDS = new Set(
+ CUSTOMIZABLE_ITEMS.filter(item => item.skipFocus).map(item => item.id)
+);
diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs
new file mode 100644
index 0000000000..1e3900d6c3
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/CustomizableItemsDetails.mjs
@@ -0,0 +1,445 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* This has the following companion definition files:
+ * - unifiedToolbarCustomizableItems.css for the preview icons based on the id.
+ * - unifiedToolbarItems.ftl for the labels associated with the labelId.
+ * - unifiedToolbarCustomizableItems.inc.xhtml for the templates referenced with
+ * templateId.
+ * - unifiedToolbarShared.css contains styles for the template contents shared
+ * between the customization preview and the actual toolbar.
+ * - unifiedtoolbar/content/items contains all item specific custom elements.
+ */
+
+/**
+ * @typedef {object} CustomizableItemDetails
+ * @property {string} id - The ID of the item. Will be set as a class on the
+ * outer wrapper. May not contain commas.
+ * @property {string} labelId - Fluent ID for the label shown while in the
+ * palette.
+ * @property {boolean} [allowMultiple] - If this item can be added more than
+ * once to a space.
+ * @property {string[]} [spaces] - If empty or omitted, item is allowed in all
+ * spaces.
+ * @property {string} [templateId] - ID of template defining the "live" markup.
+ * @property {string[]} [requiredModules] - List of modules that must be loaded
+ * for the template of this item.
+ * @property {boolean} [hasContextMenu] - Indicates that this item has its own
+ * context menu, and the global unified toolbar one shouldn't be shown.
+ * @property {boolean} [skipFocus] - If this item should be skipped in keyboard
+ * focus navigation.
+ */
+
+/**
+ * @type {CustomizableItemDetails[]}
+ */
+export default [
+ // Universal items (all spaces)
+ {
+ id: "spacer",
+ labelId: "spacer",
+ allowMultiple: true,
+ skipFocus: true,
+ },
+ {
+ // This item gets filtered out when gloda is disabled.
+ id: "search-bar",
+ labelId: "search-bar",
+ templateId: "searchBarItemTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/global-search-bar.mjs",
+ ],
+ hasContextMenu: true,
+ skipFocus: true,
+ },
+ {
+ id: "write-message",
+ labelId: "toolbar-write-message",
+ templateId: "writeMessageTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "get-messages",
+ labelId: "toolbar-get-messages",
+ templateId: "getMessagesTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "address-book",
+ labelId: "toolbar-address-book",
+ templateId: "addressBookTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "chat",
+ labelId: "toolbar-chat",
+ templateId: "chatTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "add-ons-and-themes",
+ labelId: "toolbar-add-ons-and-themes",
+ templateId: "addOnsAndThemesTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/addons-button.mjs",
+ ],
+ },
+ {
+ id: "calendar",
+ labelId: "toolbar-calendar",
+ templateId: "calendarTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "tasks",
+ labelId: "toolbar-tasks",
+ templateId: "tasksTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "mail",
+ labelId: "toolbar-mail",
+ templateId: "mailTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/space-button.mjs",
+ ],
+ },
+ {
+ id: "new-event",
+ labelId: "toolbar-new-event",
+ templateId: "newEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "new-task",
+ labelId: "toolbar-new-task",
+ templateId: "newTaskTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ // Mail space
+ {
+ id: "move-to",
+ labelId: "toolbar-move-to",
+ spaces: ["mail"],
+ templateId: "moveToTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "reply",
+ labelId: "toolbar-reply",
+ spaces: ["mail"],
+ templateId: "replyTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "reply-all",
+ labelId: "toolbar-reply-all",
+ spaces: ["mail"],
+ templateId: "replyAllTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "reply-to-list",
+ labelId: "toolbar-reply-to-list",
+ spaces: ["mail"],
+ templateId: "replyToListTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/reply-list-button.mjs",
+ ],
+ },
+ {
+ id: "redirect",
+ labelId: "toolbar-redirect",
+ spaces: ["mail"],
+ templateId: "redirectTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "archive",
+ labelId: "toolbar-archive",
+ spaces: ["mail"],
+ templateId: "archiveTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "conversation",
+ labelId: "toolbar-conversation",
+ spaces: ["mail"],
+ templateId: "conversationTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "previous-unread",
+ labelId: "toolbar-previous-unread",
+ spaces: ["mail"],
+ templateId: "previousUnreadTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "previous",
+ labelId: "toolbar-previous",
+ spaces: ["mail"],
+ templateId: "previousTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "next-unread",
+ labelId: "toolbar-next-unread",
+ spaces: ["mail"],
+ templateId: "nextUnreadTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "next",
+ labelId: "toolbar-next",
+ spaces: ["mail"],
+ templateId: "nextTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "junk",
+ labelId: "toolbar-junk",
+ spaces: ["mail"],
+ templateId: "junkTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "delete",
+ labelId: "toolbar-delete",
+ spaces: ["mail"],
+ templateId: "deleteTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/delete-button.mjs",
+ ],
+ },
+ {
+ id: "compact",
+ labelId: "toolbar-compact",
+ spaces: ["mail"],
+ templateId: "compactTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/compact-folder-button.mjs",
+ ],
+ },
+ {
+ id: "add-as-event",
+ labelId: "toolbar-add-as-event",
+ spaces: ["mail"],
+ templateId: "addAsEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/add-to-calendar-button.mjs",
+ ],
+ },
+ {
+ id: "add-as-task",
+ labelId: "toolbar-add-as-task",
+ spaces: ["mail"],
+ templateId: "addAsTaskTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/add-to-calendar-button.mjs",
+ ],
+ },
+ {
+ id: "folder-location",
+ labelId: "toolbar-folder-location",
+ spaces: ["mail"],
+ templateId: "folderLocationTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/folder-location-button.mjs",
+ ],
+ },
+ {
+ id: "tag-message",
+ labelId: "toolbar-tag-message",
+ spaces: ["mail"],
+ templateId: "tagMessageTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "forward-inline",
+ labelId: "toolbar-forward-inline",
+ spaces: ["mail"],
+ templateId: "forwardInlineTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "forward-attachment",
+ labelId: "toolbar-forward-attachment",
+ spaces: ["mail"],
+ templateId: "forwardAttachmentTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "mark-as",
+ labelId: "toolbar-mark-as",
+ spaces: ["mail"],
+ templateId: "markAsTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "view-picker",
+ labelId: "toolbar-view-picker",
+ spaces: ["mail"],
+ templateId: "viewPickerTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/view-picker-button.mjs",
+ ],
+ },
+ {
+ id: "print",
+ labelId: "toolbar-print",
+ spaces: ["mail"],
+ templateId: "printTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "quick-filter-bar",
+ labelId: "toolbar-quick-filter-bar",
+ spaces: ["mail"],
+ templateId: "quickFilterBarTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/quick-filter-bar-toggle.mjs",
+ ],
+ },
+ {
+ id: "go-back",
+ labelId: "toolbar-go-back",
+ spaces: ["mail"],
+ templateId: "goBackTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-go-button.mjs",
+ ],
+ hasContextMenu: true,
+ },
+ {
+ id: "go-forward",
+ labelId: "toolbar-go-forward",
+ spaces: ["mail"],
+ templateId: "goForwardTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-go-button.mjs",
+ ],
+ hasContextMenu: true,
+ },
+ {
+ id: "stop",
+ labelId: "toolbar-stop",
+ spaces: ["mail"],
+ templateId: "stopTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/mail-tab-button.mjs",
+ ],
+ },
+ {
+ id: "throbber",
+ labelId: "toolbar-throbber",
+ spaces: ["mail"],
+ templateId: "throbberTemplate",
+ skipFocus: true,
+ },
+ // Calendar & Tasks space
+ {
+ id: "edit-event",
+ labelId: "toolbar-edit-event",
+ spaces: ["calendar", "tasks"],
+ templateId: "editEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "synchronize",
+ labelId: "toolbar-synchronize",
+ spaces: ["calendar", "tasks"],
+ templateId: "synchronizeTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "delete-event",
+ labelId: "toolbar-delete-event",
+ spaces: ["calendar", "tasks"],
+ templateId: "deleteEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "print-event",
+ labelId: "toolbar-print-event",
+ spaces: ["calendar", "tasks"],
+ templateId: "printEventTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ // Calendar space
+ {
+ id: "go-to-today",
+ labelId: "toolbar-go-to-today",
+ spaces: ["calendar"],
+ templateId: "goToTodayTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+ {
+ id: "unifinder",
+ labelId: "toolbar-unifinder",
+ spaces: ["calendar"],
+ templateId: "calendarUnifinderTemplate",
+ requiredModules: [
+ "chrome://messenger/content/unifiedtoolbar/unified-toolbar-button.mjs",
+ ],
+ },
+];
diff --git a/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs b/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs
new file mode 100644
index 0000000000..75c0b390be
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/CustomizationState.mjs
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const MAIN_WINDOW_DOCUMENT = "chrome://messenger/content/messenger.xhtml";
+const UNIFIED_TOOLBAR_ID = "unifiedToolbar";
+const CUSTOMIZATION_ATTRIBUTE_NAME = "state";
+
+/**
+ * @typedef {object} UnifiedToolbarCustomizationState
+ * @property {string[]} (spaceName) - Each space has a key on the object,
+ * containing an ordered array of item IDs.
+ */
+
+/**
+ * Store the customization state for the unified toolbar. Sends a global
+ * observer notification.
+ *
+ * @param {UnifiedToolbarCustomizationState} state
+ */
+export function storeState(state) {
+ Services.xulStore.setValue(
+ MAIN_WINDOW_DOCUMENT,
+ UNIFIED_TOOLBAR_ID,
+ CUSTOMIZATION_ATTRIBUTE_NAME,
+ JSON.stringify(state)
+ );
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+}
+
+/**
+ * Retrieve the customization state of the unified toolbar.
+ *
+ * @returns {UnifiedToolbarCustomizationState} A partial representation of the
+ * customization state of the unified toolbar. Missing spaces are in their
+ * default states.
+ */
+export function getState() {
+ let state = {};
+ if (
+ Services.xulStore.hasValue(
+ MAIN_WINDOW_DOCUMENT,
+ UNIFIED_TOOLBAR_ID,
+ CUSTOMIZATION_ATTRIBUTE_NAME
+ )
+ ) {
+ const rawState = Services.xulStore.getValue(
+ MAIN_WINDOW_DOCUMENT,
+ UNIFIED_TOOLBAR_ID,
+ CUSTOMIZATION_ATTRIBUTE_NAME
+ );
+ state = JSON.parse(rawState);
+ }
+ return state;
+}
diff --git a/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs b/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs
new file mode 100644
index 0000000000..117fb774ba
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/modules/ToolbarMigration.sys.mjs
@@ -0,0 +1,419 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ getState,
+ storeState,
+} from "resource:///modules/CustomizationState.mjs";
+import {
+ MULTIPLE_ALLOWED_ITEM_IDS,
+ EXTENSION_PREFIX,
+ getAvailableItemIdsForSpace,
+ getDefaultItemIdsForSpace,
+} from "resource:///modules/CustomizableItems.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ getCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+ setCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+});
+
+/**
+ * Maps XUL toolbar item IDs to unified toolbar item IDs. If null, the item is
+ * not available in the unified toolbar.
+ */
+const MIGRATION_MAP = {
+ separator: null,
+ spacer: "spacer",
+ spring: "spacer",
+ "button-getmsg": "get-messages",
+ "button-newmsg": "write-message",
+ "button-reply": "reply",
+ "button-replyall": "reply-all",
+ "button-replylist": "reply-list",
+ "button-forward": "forward-inline",
+ "button-redirect": "redirect",
+ "button-file": "move-to",
+ "button-archive": "archive",
+ "button-showconversation": "conversation",
+ "button-goback": "go-back",
+ "button-goforward": "go-forward",
+ "button-previous": "previous-unread",
+ "button-previousMsg": "previous",
+ "button-next": "next-unread",
+ "button-nextMsg": "next",
+ "button-junk": "junk",
+ "button-delete": "delete",
+ "button-print": "print",
+ "button-mark": "mark-as",
+ "button-tag": "tag-message",
+ "qfb-show-filter-bar": "quick-filter-bar",
+ "button-address": "address-book",
+ "button-chat": "chat",
+ "throbber-box": "throbber",
+ "button-stop": "stop",
+ "button-compact": "compact",
+ "folder-location-container": "folder-location",
+ "mailviews-container": "view-picker",
+ "button-addons": "add-ons-and-themes",
+ "button-appmenu": null,
+ "gloda-search": "search-bar",
+ "lightning-button-calendar": "calendar",
+ "lightning-button-tasks": "tasks",
+ extractEventButton: "add-as-event",
+ extractTaskButton: "add-as-task",
+ "menubar-items": null,
+ "calendar-synchronize-button": "synchronize",
+ "calendar-newevent-button": "new-event",
+ "calendar-newtask-button": "new-task",
+ "calendar-goto-today-button": "go-to-today",
+ "calendar-edit-button": "edit-event",
+ "calendar-delete-button": "delete-event",
+ "calendar-print-button": "print-event",
+ "calendar-unifinder-button": "unifinder",
+ "calendar-appmenu-button": null,
+ "task-synchronize-button": "synchronize",
+ "task-newevent-button": "new-event",
+ "task-newtask-button": "new-task",
+ "task-edit-button": "edit-event",
+ "task-delete-button": "delete-event",
+ "task-print-button": "print-event",
+ "task-appmenu-button": null,
+};
+
+/**
+ * Maps space names to the ID of the toolbar in the messenger window.
+ */
+const TOOLBAR_FOR_SPACE = {
+ mail: "mail-bar3",
+ calendar: "calendar-toolbar2",
+ tasks: "task-toolbar2",
+};
+
+/**
+ * XUL toolbars store a special value when there are no items in the toolbar.
+ */
+const EMPTY_SET = "__empty";
+/**
+ * Map from the XUL toolbar id to its default set. Since toolbars we're
+ * migrating were removed from the DOM. The value should be the value of the
+ * defaultset attribute of the respective element in the markup.
+ *
+ * @type {{[string]: string}}
+ */
+const XUL_TOOLBAR_DEFAULT_SET = {
+ "mail-bar3":
+ AppConstants.platform == "macosx"
+ ? "button-getmsg,button-newmsg,button-tag,qfb-show-filter-bar,spring,gloda-search,button-appmenu"
+ : "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,button-appmenu",
+ "tabbar-toolbar": "",
+ "toolbar-menubar": "menubar-items,spring",
+ "calendar-toolbar2":
+ "calendar-synchronize-button,calendar-newevent-button,calendar-newtask-button,calendar-edit-button,calendar-delete-button,spring,calendar-appmenu-button",
+ "task-toolbar2":
+ "task-synchronize-button,task-newevent-button,task-newtask-button,task-edit-button,task-delete-button,spring,task-appmenu-button",
+};
+const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml";
+const EXTENSION_WIDGET_SUFFIX = "-browserAction-toolbarbutton";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "extensionIds",
+ "extensions.webextensions.uuids",
+ "{}",
+ null,
+ value => Object.keys(JSON.parse(value))
+);
+
+/**
+ * Get the extension ID from a XUL toolbar button ID of an extension.
+ *
+ * @param {string} buttonId - ID of the XUL toolbar button.
+ * @returns {?string} ID of the extension the button belonged to.
+ */
+function getExtensionIdFromExtensionButton(buttonId) {
+ const widgetId = buttonId.slice(0, -EXTENSION_WIDGET_SUFFIX.length);
+ return lazy.extensionIds.find(
+ extensionId => lazy.ExtensionCommon.makeWidgetId(extensionId) === widgetId
+ );
+}
+
+/**
+ * Convert the string contents of an old toolbar *set attribute to an array of
+ * item IDs.
+ *
+ * @param {string} setString - Contents of the set attribute.
+ * @returns {string[]} Array of items in the set.
+ */
+function toolbarSetAttributeToArray(setString) {
+ if (!setString || setString === EMPTY_SET) {
+ return [];
+ }
+ return setString.split(",").filter(Boolean);
+}
+
+/**
+ * Get the default set (without extensions) of a XUL toolbar.
+ *
+ * @param {string} toolbarId - ID of the XUL toolbar element.
+ * @param {string} window - URI of the window the toolbar is in.
+ * @returns {string} defaultset attribute of the given XUL toolbar.
+ */
+function getOldToolbarDefaultContents(toolbarId, window = MESSENGER_WINDOW) {
+ let setString = Services.xulStore.getValue(window, toolbarId, "defaultset");
+ if (!setString) {
+ setString = XUL_TOOLBAR_DEFAULT_SET[toolbarId];
+ }
+ return setString;
+}
+
+/**
+ * Get the items in a XUL toolbar area. Will return defaults if the area is not
+ * customized.
+ *
+ * @param {string} toolbarId - ID of the XUL toolbar element.
+ * @param {string} window - URI of the window the toolbar is in.
+ * @returns {string[]} Item IDs in the given XUL toolbar.
+ */
+function getOldToolbarContents(toolbarId, window = MESSENGER_WINDOW) {
+ let setString = Services.xulStore.getValue(window, toolbarId, "currentset");
+ if (!setString) {
+ setString = getOldToolbarDefaultContents(toolbarId, window);
+ }
+ return toolbarSetAttributeToArray(setString);
+}
+
+/**
+ * Converts XUL toolbar item IDs to unified toolbar item IDs, filtering out
+ * items that are not supported in the unified toolbar.
+ *
+ * @param {string[]} items - XUL toolbar item IDs to convert.
+ * @returns {string[]} Unified toolbar item IDs.
+ */
+function convertContents(items) {
+ return items
+ .map(itemId => {
+ if (MIGRATION_MAP.hasOwnProperty(itemId)) {
+ return MIGRATION_MAP[itemId];
+ }
+ if (itemId.endsWith(EXTENSION_WIDGET_SUFFIX)) {
+ const extensionId = getExtensionIdFromExtensionButton(itemId);
+ if (extensionId) {
+ return `${EXTENSION_PREFIX}${extensionId}`;
+ }
+ }
+ return null;
+ })
+ .filter(Boolean);
+}
+
+/**
+ * Get the unified toolbar item IDs for items that were in the tab bar and the
+ * menu bar areas.
+ *
+ * @returns {string[]} Item IDs that were available in any tab in the XUL
+ * toolbars.
+ */
+function getGlobalItems() {
+ const tabsContent = convertContents(getOldToolbarContents("tabbar-toolbar"));
+ const menubarContent = convertContents(
+ getOldToolbarContents("toolbar-menubar")
+ );
+ return [...menubarContent, ...tabsContent];
+}
+
+/**
+ * Converts the items in the old xul toolbar of a given space and the tab bar
+ * and menu bar areas to unified toolbar item IDs.
+ *
+ * Filters out any items not available and items that appear multiple times, if
+ * they can't be repeated. The first instance is kept.
+ *
+ * If there is no old toolbar for the given space, only the global items are
+ * returned.
+ *
+ * @param {string} space - Name of the space to get the items for.
+ * @returns {string[]} Unified toolbar item IDs based on the old contents of the
+ * xul toolbar of the space.
+ */
+function getItemsForSpace(space) {
+ let spaceContent = [];
+ if (TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ spaceContent = convertContents(
+ getOldToolbarContents(TOOLBAR_FOR_SPACE[space])
+ );
+ } else {
+ spaceContent = getDefaultItemIdsForSpace(space);
+ }
+ const newContents = [...spaceContent, ...getGlobalItems()];
+ const availableItems = getAvailableItemIdsForSpace(space, true).concat(
+ lazy.extensionIds.map(id => `${EXTENSION_PREFIX}${id}`)
+ );
+ const encounteredItems = new Set();
+ const finalItems = newContents.filter((itemId, index, items) => {
+ if (
+ (encounteredItems.has(itemId) &&
+ !MULTIPLE_ALLOWED_ITEM_IDS.has(itemId)) ||
+ !availableItems.includes(itemId) ||
+ (itemId === "spacer" && index > 0 && items[index - 1] === itemId)
+ ) {
+ return false;
+ }
+ encounteredItems.add(itemId);
+ return true;
+ });
+ return finalItems;
+}
+
+/**
+ * Convert the persisted extensions from the old extensionset to the new space
+ * specific store for extensions.
+ *
+ * @param {string} space - Name of the migrated space.
+ */
+function convertExtensionState(space) {
+ if (
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ TOOLBAR_FOR_SPACE[space],
+ "extensionset"
+ )
+ ) {
+ return;
+ }
+ const extensionSet = Services.xulStore
+ .getValue(MESSENGER_WINDOW, TOOLBAR_FOR_SPACE[space], "extensionset")
+ .split(",")
+ .filter(Boolean);
+ const extensionsInExtensionSet = extensionSet.map(buttonId =>
+ getExtensionIdFromExtensionButton(buttonId)
+ );
+ const cachedAllowedSpaces = lazy.getCachedAllowedSpaces();
+ for (const extensionId of extensionsInExtensionSet) {
+ const allowedSpaces = cachedAllowedSpaces.get(extensionId) ?? [];
+ if (!allowedSpaces.includes(space)) {
+ allowedSpaces.push(space);
+ }
+ cachedAllowedSpaces.set(extensionId, allowedSpaces);
+ }
+ lazy.setCachedAllowedSpaces(cachedAllowedSpaces);
+}
+
+/**
+ * Check if the XUL toolbar matches the default state.
+ *
+ * @param {string} toolbarId - ID of the old XUL toolbar element to check the
+ * state of.
+ * @returns {boolean} If the toolbar with the given ID has a currentset matching
+ * the default state for that toolbar.
+ */
+function oldToolbarContainsDefaultItems(toolbarId) {
+ // Fast path: if there is no current set, the contents of the toolbar were
+ // never modified.
+ if (!Services.xulStore.hasValue(MESSENGER_WINDOW, toolbarId, "currentset")) {
+ return true;
+ }
+ const toolbarContents = getOldToolbarContents(toolbarId);
+ let defaultContents = toolbarSetAttributeToArray(
+ getOldToolbarDefaultContents(toolbarId)
+ );
+ const extensionContents = toolbarSetAttributeToArray(
+ Services.xulStore.getValue(MESSENGER_WINDOW, toolbarId, "extensionset")
+ );
+ // Extensions are inserted before the appmenu button, which is usually at the
+ // end of the default set.
+ if (extensionContents.length) {
+ const appmenuIndex = defaultContents.findIndex(
+ itemId => itemId === "button-appmenu"
+ );
+ if (appmenuIndex !== -1) {
+ defaultContents.splice(appmenuIndex, 0, ...extensionContents);
+ } else {
+ defaultContents = defaultContents.concat(extensionContents);
+ }
+ }
+ return (
+ toolbarContents.length === defaultContents.length &&
+ toolbarContents.every((itemId, index) => itemId === defaultContents[index])
+ );
+}
+
+/**
+ * Check if the XUL toolbar customization state is equivalent to its default set
+ * for a given space.
+ *
+ * @param {string} space - Name of the space to check the default set for.
+ * @returns {boolean} If the state of the old XUL toolbars matches the default
+ * set for that space. True if we don't know any toolbar for the given space.
+ */
+function stateMatchesDefault(space) {
+ if (!TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ return true;
+ }
+ if (!oldToolbarContainsDefaultItems(TOOLBAR_FOR_SPACE[space])) {
+ return false;
+ }
+ if (space === "mail") {
+ if (!oldToolbarContainsDefaultItems("tabbar-toolbar")) {
+ return false;
+ }
+ if (!oldToolbarContainsDefaultItems("toolbar-menubar")) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Remove all the persisted state of a XUL toolbar from the XUL store.
+ *
+ * @param {string} toolbarId - Element ID of the XUL toolbar to clear the state
+ * of.
+ */
+export function clearXULToolbarState(toolbarId) {
+ Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "currentset");
+ Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "defaultset");
+ Services.xulStore.removeValue(MESSENGER_WINDOW, toolbarId, "extensionset");
+}
+
+/**
+ * Migrate the old xul toolbar contents for a given space to the unified toolbar
+ * if the unified toolbar has not yet been customized.
+ *
+ * Adds both the contents of the space specific toolbar and the tab bar and menu
+ * bar areas to the unified toolbar, if the items are available.
+ *
+ * When the migration is complete, the old XUL store values for the XUL toolbar
+ * area are deleted.
+ *
+ * @param {string} space - Name of the space to migrate.
+ */
+export function migrateToolbarForSpace(space) {
+ const state = getState();
+ // If the mail toolbar areas are all in their default state, we don't want to
+ // migrate their contents.
+ const mailToolbarInDefaultState =
+ space === "mail" && stateMatchesDefault(space);
+ // Don't migrate contents if the state of the space is already customized.
+ if (state[space] || mailToolbarInDefaultState) {
+ if (mailToolbarInDefaultState && TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ clearXULToolbarState(TOOLBAR_FOR_SPACE[space]);
+ }
+ return;
+ }
+ state[space] = getItemsForSpace(space);
+ storeState(state);
+ if (TOOLBAR_FOR_SPACE.hasOwnProperty(space)) {
+ convertExtensionState(space);
+ // Remove all the state for the old toolbar of the space.
+ clearXULToolbarState(TOOLBAR_FOR_SPACE[space]);
+ }
+}
diff --git a/comm/mail/components/unifiedtoolbar/moz.build b/comm/mail/components/unifiedtoolbar/moz.build
new file mode 100644
index 0000000000..106b510067
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "modules/ButtonStyle.mjs",
+ "modules/CustomizableItems.sys.mjs",
+ "modules/CustomizableItemsDetails.mjs",
+ "modules/CustomizationState.mjs",
+ "modules/ToolbarMigration.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser.ini b/comm/mail/components/unifiedtoolbar/test/browser/browser.ini
new file mode 100644
index 0000000000..072a4571ef
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser.ini
@@ -0,0 +1,16 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/**
+
+[browser_customizableItems.js]
+[browser_searchBar.js]
+[browser_toolbarMigration.js]
+[browser_unifiedToolbarTab.js]
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js
new file mode 100644
index 0000000000..152ade47f3
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_customizableItems.js
@@ -0,0 +1,173 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ getAvailableItemIdsForSpace,
+ getDefaultItemIdsForSpace,
+ registerExtension,
+ unregisterExtension,
+} = ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+add_task(async function test_extensionRegisterUnregisterDefault() {
+ const extensionId = "thunderbird-compact-light@mozilla.org";
+ await registerExtension(extensionId);
+
+ const itemId = `ext-${extensionId}`;
+ ok(
+ getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item available in mail space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item in mail space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item not available in all spaces"
+ );
+
+ unregisterExtension(extensionId);
+
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item no longer available in mail space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item not in mail space by default"
+ );
+});
+
+add_task(async function test_extensionRegisterAllSpaces() {
+ const extensionId = "thunderbird-compact-light@mozilla.org";
+ await registerExtension(extensionId, []);
+
+ const itemId = `ext-${extensionId}`;
+ ok(
+ getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item available in all spaces"
+ );
+ ok(
+ getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item in all spaces by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item not available in mail space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item in mail space by default"
+ );
+
+ unregisterExtension(extensionId);
+
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item no longer available in all spaces"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item not in any space by default"
+ );
+});
+
+add_task(async function test_extensionRegisterMultipleSpaces() {
+ const extensionId = "thunderbird-compact-light@mozilla.org";
+ await registerExtension(extensionId, ["mail", "calendar", "default"]);
+
+ const itemId = `ext-${extensionId}`;
+ ok(
+ getAvailableItemIdsForSpace("calendar").includes(itemId),
+ "Extension item available in calendar space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("calendar").includes(itemId),
+ "Extension item in calendar space by default"
+ );
+ ok(
+ getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item available in mail space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item in mail space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item not available in all spaces"
+ );
+ ok(
+ getAvailableItemIdsForSpace("default").includes(itemId),
+ "Extension item available in default space"
+ );
+ ok(
+ getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item in default space"
+ );
+
+ unregisterExtension(extensionId);
+
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Extension item no longer available in mail space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Extension item not in mail space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("calendar").includes(itemId),
+ "Extension item no longer available in calendar space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("calendar").includes(itemId),
+ "Extension item not in calendar space by default"
+ );
+ ok(
+ !getAvailableItemIdsForSpace().includes(itemId),
+ "Extension item not available in all spaces"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("default").includes(itemId),
+ "Extension item not available in default space"
+ );
+ ok(
+ !getDefaultItemIdsForSpace("default").includes(itemId),
+ "Extension item not in default space"
+ );
+});
+
+add_task(async function test_extensionRegisterStableOrder() {
+ const extension1Id = "thunderbird-compact-light@mozilla.org";
+ const extension2Id = "thunderbird-compact-dark@mozilla.org";
+ await registerExtension(extension1Id);
+ await registerExtension(extension2Id);
+
+ const defaultItems = getDefaultItemIdsForSpace("mail");
+
+ const firstExtensionId = defaultItems
+ .find(itemId => itemId.startsWith("ext-"))
+ .slice(4);
+
+ unregisterExtension(firstExtensionId);
+
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(`ext-${firstExtensionId}`),
+ "Extension that was the first in the default set not in default set"
+ );
+
+ await registerExtension(firstExtensionId);
+
+ Assert.deepEqual(
+ getDefaultItemIdsForSpace("mail"),
+ defaultItems,
+ "Default items order stable for extensions"
+ );
+
+ unregisterExtension(extension1Id);
+ unregisterExtension(extension2Id);
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js
new file mode 100644
index 0000000000..b88c16f684
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_searchBar.js
@@ -0,0 +1,263 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+let browser;
+let searchBar;
+
+const waitForRender = () => {
+ return new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+ });
+};
+
+/* These are shadow-root safe variants of the methods in BrowserTestUtils. */
+
+/**
+ * Checks if a DOM element is hidden.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+function is_hidden(element) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+ if (style.display == "-moz-popup") {
+ return ["hiding", "closed"].includes(element.state);
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument && element.parentElement) {
+ return is_hidden(element.parentElement);
+ }
+
+ return false;
+}
+
+/**
+ * Checks if a DOM element is visible.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+function is_visible(element) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none") {
+ return false;
+ }
+ if (style.visibility != "visible") {
+ return false;
+ }
+ if (style.display == "-moz-popup" && element.state != "open") {
+ return false;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument && element.parentElement) {
+ return is_visible(element.parentElement);
+ }
+
+ return true;
+}
+
+add_setup(async function () {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+ browser = tab.browser;
+ searchBar = tab.browser.contentWindow.document.querySelector("search-bar");
+});
+
+add_task(async function test_initialState() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ is(
+ input.getAttribute("aria-label"),
+ searchBar.getAttribute("label"),
+ "Label forwarded to aria-label on input"
+ );
+});
+
+add_task(async function test_labelUpdate() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ searchBar.setAttribute("label", "foo");
+ await waitForRender();
+ is(
+ input.getAttribute("aria-label"),
+ "foo",
+ "Updated label applied to content"
+ );
+});
+
+add_task(async function test_focus() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ searchBar.focus();
+ is(
+ searchBar.shadowRoot.activeElement,
+ input,
+ "Input is focused when search bar is focused"
+ );
+});
+
+add_task(async function test_autocompleteEvent() {
+ const typeAndWaitForAutocomplete = async key => {
+ const eventPromise = BrowserTestUtils.waitForEvent(
+ searchBar,
+ "autocomplete"
+ );
+ await BrowserTestUtils.synthesizeKey(key, {}, browser);
+ return eventPromise;
+ };
+ searchBar.focus();
+ let event = await typeAndWaitForAutocomplete("T");
+ is(event.detail, "T", "Autocomplete for T");
+
+ event = await typeAndWaitForAutocomplete("e");
+ is(event.detail, "Te", "Autocomplete for e");
+
+ event = await typeAndWaitForAutocomplete("KEY_Backspace");
+ is(event.detail, "T", "Autocomplete for backspace");
+
+ await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, browser);
+});
+
+add_task(async function test_searchEventFromEnter() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ input.value = "Lorem ipsum";
+ searchBar.focus();
+
+ const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search");
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+ const event = await eventPromise;
+
+ is(event.detail, "Lorem ipsum", "Event contains search query");
+ await waitForRender();
+ is(input.value, "", "Input was cleared");
+});
+
+add_task(async function test_searchEventFromButton() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ input.value = "Lorem ipsum";
+
+ const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search");
+ searchBar.shadowRoot.querySelector("button").click();
+ const event = await eventPromise;
+
+ is(event.detail, "Lorem ipsum", "Event contains search query");
+ await waitForRender();
+ is(input.value, "", "Input was cleared");
+});
+
+add_task(async function test_searchEventPreventDefault() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ input.value = "Lorem ipsum";
+
+ searchBar.addEventListener(
+ "search",
+ event => {
+ event.preventDefault();
+ },
+ {
+ once: true,
+ passive: false,
+ }
+ );
+
+ const eventPromise = BrowserTestUtils.waitForEvent(searchBar, "search");
+ searchBar.shadowRoot.querySelector("button").click();
+ await eventPromise;
+ await waitForRender();
+
+ is(input.value, "Lorem ipsum");
+
+ input.value = "";
+});
+
+add_task(async function test_placeholderVisibility() {
+ const placeholder = searchBar.shadowRoot.querySelector("div");
+ const input = searchBar.shadowRoot.querySelector("input");
+
+ input.value = "";
+ await waitForRender();
+ ok(is_visible(placeholder), "Placeholder is visible initially");
+
+ input.value = "some input";
+ await waitForRender();
+ ok(is_hidden(placeholder), "Placeholder is hidden after text is entered");
+
+ input.value = "";
+ await waitForRender();
+ ok(
+ is_visible(placeholder),
+ "Placeholder is visible again after input is cleared"
+ );
+});
+
+add_task(async function test_placeholderFallbackToLabel() {
+ const placeholder = searchBar.querySelector("span");
+ placeholder.remove();
+
+ const shadowedPlaceholder = searchBar.shadowRoot.querySelector("div");
+ const label = searchBar.getAttribute("label");
+
+ is(
+ shadowedPlaceholder.textContent,
+ label,
+ "Falls back to label if no placeholder slot contents provided"
+ );
+
+ searchBar.setAttribute("label", "Foo bar");
+ is(
+ shadowedPlaceholder.textContent,
+ "Foo bar",
+ "Placeholder contents get updated with label attribute"
+ );
+
+ searchBar.prepend(placeholder);
+ searchBar.setAttribute("label", label);
+});
+
+add_task(async function test_reset() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ const placeholder = searchBar.shadowRoot.querySelector("div");
+ input.value = "Lorem ipsum";
+
+ searchBar.reset();
+
+ is(input.value, "", "Input empty after reset");
+ await waitForRender();
+ ok(is_visible(placeholder), "Placeholder visible");
+});
+
+add_task(async function test_disabled() {
+ const input = searchBar.shadowRoot.querySelector("input");
+ const button = searchBar.shadowRoot.querySelector("button");
+
+ ok(!input.disabled, "Input enabled");
+ ok(!button.disabled, "Button enabled");
+
+ searchBar.setAttribute("disabled", true);
+
+ ok(input.disabled, "Disabled propagated to input");
+ ok(button.disabled, "Disabled propagated to button");
+
+ searchBar.removeAttribute("disabled");
+
+ ok(!input.disabled, "Input enabled again");
+ ok(!button.disabled, "Button enabled again");
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js
new file mode 100644
index 0000000000..c2ca1147fd
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_toolbarMigration.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { migrateToolbarForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/ToolbarMigration.sys.mjs"
+);
+const { getState, storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { EXTENSION_PREFIX } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+const { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml";
+const EXTENSION_ID = "thunderbird-compact-light@mozilla.org";
+
+add_setup(() => {
+ storeState({});
+});
+
+add_task(async function test_migrate_extension() {
+ Services.xulStore.setValue(MESSENGER_WINDOW, "mail-bar3", "currentset", "");
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ "mail-bar3",
+ "defaultset",
+ "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,thunderbird-compact-light_mozilla_org-browserAction-toolbarbutton,button-appmenu"
+ );
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ "mail-bar3",
+ "extensionset",
+ "thunderbird-compact-light_mozilla_org-browserAction-toolbarbutton"
+ );
+ const extensionPref = Services.prefs.getStringPref(
+ "extensions.webextensions.uuids",
+ ""
+ );
+ const parsedPref = JSON.parse(extensionPref || "{}");
+ if (!parsedPref.hasOwnProperty(EXTENSION_ID)) {
+ parsedPref[EXTENSION_ID] = "foo";
+ Services.prefs.setStringPref(
+ "extensions.webextensions.uuids",
+ JSON.stringify(parsedPref)
+ );
+ }
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.mail,
+ [
+ "get-messages",
+ "write-message",
+ "tag-message",
+ "quick-filter-bar",
+ "spacer",
+ `${EXTENSION_PREFIX}${EXTENSION_ID}`,
+ "spacer",
+ ],
+ "Extension button was converted to new ID format"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "extensionset"),
+ "Old toolbar extension state is cleared"
+ );
+ Assert.deepEqual(
+ Object.fromEntries(getCachedAllowedSpaces()),
+ { [EXTENSION_ID]: ["mail"] },
+ "Extension set migrated to new persistent extension state"
+ );
+
+ storeState({});
+ setCachedAllowedSpaces(new Map());
+ if (extensionPref) {
+ Services.prefs.setStringPref(
+ "extensions.webextensions.uuids",
+ extensionPref
+ );
+ } else {
+ Services.prefs.clearUserPref("extensions.webextensions.uuids");
+ }
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js b/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js
new file mode 100644
index 0000000000..336199ee51
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/browser_unifiedToolbarTab.js
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+let browser;
+let testDocument;
+
+const waitForRender = () => {
+ return new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+ });
+};
+const getTabButton = tab => tab.shadowRoot.querySelector("button");
+/**
+ * Get the relevant elements for the tab at the given index.
+ *
+ * @param {number} tabIndex
+ * @returns {{tab: UnifiedToolbarTab, button: HTMLButtonElement, pane: HTMLElement}}
+ */
+const getTabElements = tabIndex => {
+ const tab = testDocument.querySelector(
+ `unified-toolbar-tab:nth-child(${tabIndex})`
+ );
+ const button = getTabButton(tab);
+ const pane = tab.pane;
+ return { tab, button, pane };
+};
+
+add_setup(async function () {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+ browser = tab.browser;
+ testDocument = tab.browser.contentWindow.document;
+});
+
+add_task(function test_tabElementInitialization() {
+ const activeTab = testDocument.querySelector("unified-toolbar-tab[selected]");
+ is(
+ activeTab.getAttribute("role"),
+ "presentation",
+ "The custom element is just for show"
+ );
+ ok(
+ !activeTab.hasAttribute("aria-controls"),
+ "aria-controls removed from custom element"
+ );
+ ok(activeTab.hasAttribute("selected"), "Active tab kept itself selected");
+ const tabButton = getTabButton(activeTab);
+ is(tabButton.getAttribute("role"), "tab", "Active tab is marked as tab");
+ is(tabButton.tabIndex, 0, "Active tab is in the focus ring");
+ is(
+ tabButton.getAttribute("aria-selected"),
+ "true",
+ "Tab is marked as selected"
+ );
+ ok(
+ tabButton.hasAttribute("aria-controls"),
+ "aria-controls got given to button"
+ );
+
+ const otherTab = testDocument.querySelector(
+ "unified-toolbar-tab:not([selected])"
+ );
+ is(
+ otherTab.getAttribute("role"),
+ "presentation",
+ "The custom element is just for show on the other tab"
+ );
+ ok(
+ !otherTab.hasAttribute("aria-controls"),
+ "aria-controls removed from the other tab"
+ );
+ ok(!otherTab.hasAttribute("selected"), "Other tab didn't select itself");
+ const otherButton = getTabButton(otherTab);
+ is(otherButton.getAttribute("role"), "tab", "Other tab is marked as tab");
+ is(otherButton.tabIndex, -1, "Other tab is not in the focus ring");
+ ok(
+ !otherButton.hasAttribute("aria-selected"),
+ "Other tab isn't marked as selected"
+ );
+ ok(
+ otherButton.hasAttribute("aria-controls"),
+ "aria-controls got given to other button"
+ );
+});
+
+add_task(async function test_paneGetter() {
+ const tab1 = getTabElements(1);
+ const tabPane = testDocument.getElementById("tabPane");
+ const tab2 = getTabElements(2);
+ const otherTabPane = testDocument.getElementById("otherTabPane");
+
+ is(
+ tab1.button.getAttribute("aria-controls"),
+ tabPane.id,
+ "Tab 1 controls tab 1 pane"
+ );
+ is(
+ tab2.button.getAttribute("aria-controls"),
+ otherTabPane.id,
+ "Tab 2 controls tab 2 pane"
+ );
+
+ Assert.strictEqual(
+ tab1.tab.pane,
+ tabPane,
+ "Tab 1 pane getter returns #tabPane"
+ );
+ Assert.strictEqual(
+ tab2.tab.pane,
+ otherTabPane,
+ "Tab 2 pane getter returns #otherTabPane"
+ );
+});
+
+add_task(async function test_unselect() {
+ const tab = getTabElements(1);
+
+ tab.tab.unselect();
+
+ ok(!tab.button.hasAttribute("aria-selected"), "Tab not marked as selected");
+ is(tab.button.tabIndex, -1, "Tab not in focus ring");
+ ok(!tab.tab.hasAttribute("selected"), "Tab not marked selected");
+ ok(tab.pane.hidden, "Tab pane hidden");
+});
+
+add_task(async function test_select() {
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ let tabswitchPromise = BrowserTestUtils.waitForEvent(
+ testDocument.body,
+ "tabswitch"
+ );
+ tab1.tab.select();
+
+ await tabswitchPromise;
+ ok(tab1.tab.hasAttribute("selected"), "Tab 1 selected");
+ is(
+ tab1.button.getAttribute("aria-selected"),
+ "true",
+ "Tab 1 marked as selected"
+ );
+ is(tab1.button.tabIndex, 0, "Tab 1 keyboard selectable");
+ ok(!tab1.pane.hidden, "Tab pane for tab 1 visible");
+
+ tabswitchPromise = BrowserTestUtils.waitForEvent(tab2.tab, "tabswitch");
+ tab2.tab.select();
+
+ await tabswitchPromise;
+ ok(tab2.tab.hasAttribute("selected"), "Tab 2 selected");
+ is(
+ tab2.button.getAttribute("aria-selected"),
+ "true",
+ "Tab 2 has a11y selection"
+ );
+ is(tab2.button.tabIndex, 0, "Tab 2 keyboard selectable");
+ ok(!tab2.pane.hidden, "Tab pane for tab 2 visible");
+
+ ok(!tab1.tab.hasAttribute("selected"), "Tab 1 unselected");
+ ok(!tab1.button.hasAttribute("aria-selected"), "Tab 1 marked as unselected");
+ is(tab1.button.tabIndex, -1, "Tab 1 not in focus ring");
+ ok(tab1.pane.hidden, "Tab pane for tab 1 hidden");
+});
+
+add_task(async function test_switchingTabWithMouse() {
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ tab2.button.click();
+ ok(tab2.tab.hasAttribute("selected"), "Other tab is selected");
+ is(tab2.button.tabIndex, 0, "Other tab is in focus ring");
+ ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected");
+ is(tab1.button.tabIndex, -1, "First tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab2.pane),
+ "Tab pane for selected tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden");
+
+ tab1.button.click();
+ ok(tab1.tab.hasAttribute("selected"), "First tab is selected");
+ is(tab1.button.tabIndex, 0, "First tab is in focus ring");
+ ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected");
+ is(tab2.button.tabIndex, -1, "Other tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab1.pane),
+ "Tab pane for first tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden");
+});
+
+add_task(async function test_switchingTabWithKeyboard() {
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ tab1.tab.focus();
+ is(testDocument.activeElement, tab1.tab, "Initially first tab is active");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser);
+ is(testDocument.activeElement, tab2.tab, "Second tab is focused");
+ is(
+ tab2.tab.shadowRoot.activeElement,
+ tab2.button,
+ "Button within tab is focused"
+ );
+ await BrowserTestUtils.synthesizeKey(" ", {}, browser);
+ ok(tab2.tab.hasAttribute("selected"), "Other tab is selected");
+ is(tab2.button.tabIndex, 0, "Other tab is in focus ring");
+ ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected");
+ is(tab1.button.tabIndex, -1, "First tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab2.pane),
+ "Tab pane for selected tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, browser);
+ is(testDocument.activeElement, tab1.tab, "Previous tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_End", {}, browser);
+ is(testDocument.activeElement, tab2.tab, "Last tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_Home", {}, browser);
+ is(testDocument.activeElement, tab1.tab, "First tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+ ok(tab1.tab.hasAttribute("selected"), "First tab is selected");
+ is(tab1.button.tabIndex, 0, "First tab is in focus ring");
+ ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected");
+ is(tab2.button.tabIndex, -1, "Other tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab1.pane),
+ "Tab pane for first tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden");
+});
+
+add_task(async function test_switchingTabWithKeyboardRTL() {
+ testDocument.dir = "rtl";
+ await waitForRender();
+ const tab1 = getTabElements(1);
+ const tab2 = getTabElements(2);
+
+ tab1.tab.focus();
+ is(testDocument.activeElement, tab1.tab, "Initially first tab is active");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, browser);
+ is(testDocument.activeElement, tab2.tab, "Second tab is selected");
+ is(
+ tab2.tab.shadowRoot.activeElement,
+ tab2.button,
+ "Button within tab is focused"
+ );
+ await BrowserTestUtils.synthesizeKey(" ", {}, browser);
+ ok(tab2.tab.hasAttribute("selected"), "Other tab is selected");
+ is(tab2.button.tabIndex, 0, "Other tab is in focus ring");
+ ok(!tab1.tab.hasAttribute("selected"), "First tab is not selected");
+ is(tab1.button.tabIndex, -1, "First tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab2.pane),
+ "Tab pane for selected tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab1.pane), "Tab pane for first tab is hidden");
+
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser);
+ is(testDocument.activeElement, tab1.tab, "Previous tab is selected");
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+ ok(tab1.tab.hasAttribute("selected"), "First tab is selected");
+ is(tab1.button.tabIndex, 0, "First tab is in focus ring");
+ ok(!tab2.tab.hasAttribute("selected"), "Other tab is not selected");
+ is(tab2.button.tabIndex, -1, "Other tab is not in focus ring");
+ ok(
+ BrowserTestUtils.is_visible(tab1.pane),
+ "Tab pane for first tab is visible"
+ );
+ ok(BrowserTestUtils.is_hidden(tab2.pane), "Tab pane for other tab is hidden");
+
+ testDocument.dir = "ltr";
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml b/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml
new file mode 100644
index 0000000000..33000135b4
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/files/searchBar.xhtml
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8" />
+ <title>Search bar element test</title>
+ <script type="module" src="chrome://messenger/content/unifiedtoolbar/search-bar.mjs"></script>
+ </head>
+ <body>
+ <template id="searchBarTemplate">
+ <form>
+ <input type="search" placeholder="" required="required"/>
+ <div aria-hidden="true"><slot name="placeholder"></slot></div>
+ <button class="button button-flat icon-button"><slot name="button"></slot></button>
+ </form>
+ </template>
+ <search-bar label="Search">
+ <span slot="placeholder">Placeholder</span>
+ <img slot="button" src="chrome://messenger/skin/icons/new/compact/search.svg" alt="Search"/>
+ </search-bar>
+ </body>
+</html>
diff --git a/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml b/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml
new file mode 100644
index 0000000000..f30f2b7d8b
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/browser/files/unifiedToolbarTab.xhtml
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">
+ <head>
+ <meta charset="utf-8" />
+ <title>Search bar element test</title>
+ <script type="module" src="chrome://messenger/content/unifiedtoolbar/unified-toolbar-tab.mjs"></script>
+ </head>
+ <body>
+ <template id="unifiedToolbarTabTemplate">
+ <button role="tab">
+ <img alt="" src="" />
+ <slot></slot>
+ </button>
+ </template>
+ <div role="tablist">
+ <unified-toolbar-tab selected="true" aria-controls="tabPane">Tab Title</unified-toolbar-tab>
+ <unified-toolbar-tab aria-controls="otherTabPane">Other Tab</unified-toolbar-tab>
+ </div>
+ <div id="tabPane" role="tabpanel">Panel 1</div>
+ <div id="otherTabPane" role="tabpanel" hidden="hidden">Panel 2</div>
+ </body>
+</html>
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js b/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js
new file mode 100644
index 0000000000..0ffeefa00b
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_buttonStyle.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { BUTTON_STYLE_MAP, BUTTON_STYLE_PREF } = ChromeUtils.importESModule(
+ "resource:///modules/ButtonStyle.mjs"
+);
+
+add_task(function test_buttonStyleMap() {
+ Assert.ok(Array.isArray(BUTTON_STYLE_MAP), "BUTTON_STYLE_MAP is an array");
+ Assert.ok(
+ BUTTON_STYLE_MAP.every(style => typeof style === "string"),
+ "All entries in the style map should be strings"
+ );
+ for (const style of BUTTON_STYLE_MAP) {
+ Assert.stringMatches(
+ style,
+ /[a-z-]/,
+ "Button style class should be formatted in kebab case"
+ );
+ }
+});
+
+add_task(function test_buttonStylePref() {
+ Assert.equal(
+ typeof BUTTON_STYLE_PREF,
+ "string",
+ "BUTTON_STYLE_PREF is a string"
+ );
+ const prefValue = Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0);
+ Assert.ok(
+ Number.isInteger(prefValue),
+ "BUTTON_STYLE_PREF pref should hold an integer"
+ );
+ Assert.less(
+ prefValue,
+ BUTTON_STYLE_MAP.length,
+ "Value of BUTTON_STYLE_PREF should be within map"
+ );
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js
new file mode 100644
index 0000000000..55d4f5ba91
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItems.js
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const {
+ getAvailableItemIdsForSpace,
+ getDefaultItemIdsForSpace,
+ MULTIPLE_ALLOWED_ITEM_IDS,
+ SKIP_FOCUS_ITEM_IDS,
+} = ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+const { default: CUSTOMIZABLE_ITEMS } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItemsDetails.mjs"
+);
+
+add_task(function test_getAvailableItemIdsForSpace_anySpace() {
+ const itemsForAnySpace = getAvailableItemIdsForSpace();
+ Assert.ok(Array.isArray(itemsForAnySpace), "returns an array");
+ for (const itemId of itemsForAnySpace) {
+ Assert.equal(typeof itemId, "string", `item ID "${itemId}" is string`);
+ Assert.greater(itemId.length, 0, `item ID is not empty`);
+ }
+});
+
+add_task(function test_getAvailableItemIdsForSpace_emptySpace() {
+ const itemsForEmptySpace = getAvailableItemIdsForSpace("test");
+ Assert.deepEqual(itemsForEmptySpace, [], "Empty array for empty space");
+});
+
+add_task(function test_getAvailableItemIdsForSpace_includingAgnostic() {
+ const items = getAvailableItemIdsForSpace("mail", true);
+ const itemsForAnySpace = getAvailableItemIdsForSpace();
+ const itemsForMailSpace = getAvailableItemIdsForSpace("mail");
+
+ Assert.ok(
+ itemsForAnySpace.every(itemId => items.includes(itemId)),
+ "All space agnostic items are included"
+ );
+
+ Assert.ok(
+ itemsForMailSpace.every(itemId => items.includes(itemId)),
+ "All mail space items are included"
+ );
+});
+
+add_task(function test_getDefaultItemIdsForSpace_default() {
+ const items = getDefaultItemIdsForSpace("default");
+
+ Assert.ok(Array.isArray(items), "Should return an array");
+ Assert.deepEqual(
+ items,
+ ["spacer", "search-bar", "spacer"],
+ "Default space should contain the default item set"
+ );
+});
+
+add_task(function test_getDefaultItemIdsForSpace_cloningArray() {
+ const items1 = getDefaultItemIdsForSpace("default");
+ const items2 = getDefaultItemIdsForSpace("default");
+ const items3 = getDefaultItemIdsForSpace("mail");
+
+ Assert.notStrictEqual(
+ items1,
+ items2,
+ "The default sets should be different array instances"
+ );
+ Assert.notStrictEqual(
+ items2,
+ items3,
+ "The second default set an mail space should be different array instances"
+ );
+ Assert.notStrictEqual(
+ items3,
+ items1,
+ "The mail space and first default set should be different array instances"
+ );
+
+ Assert.deepEqual(
+ items1,
+ items2,
+ "The two default pseudospace sets should contain the same items"
+ );
+});
+
+add_task(function test_multipleAllowedItemIds() {
+ Assert.equal(
+ typeof MULTIPLE_ALLOWED_ITEM_IDS.has,
+ "function",
+ "Multiple allowed item IDs should be set-like"
+ );
+ Assert.ok(
+ Array.from(MULTIPLE_ALLOWED_ITEM_IDS).every(
+ itemId => typeof itemId === "string"
+ ),
+ "Every item in the set should be a string"
+ );
+ for (const item of CUSTOMIZABLE_ITEMS) {
+ Assert.equal(
+ MULTIPLE_ALLOWED_ITEM_IDS.has(item.id),
+ Boolean(item.allowMultiple),
+ `Set's state should matche the allowMultiple value of ${item.allowMultiple} for ${item.id}`
+ );
+ }
+});
+
+add_task(function test_skipFocusItemIds() {
+ Assert.equal(
+ typeof SKIP_FOCUS_ITEM_IDS.has,
+ "function",
+ "Skip focus item IDs should be set-like"
+ );
+ Assert.ok(
+ Array.from(SKIP_FOCUS_ITEM_IDS).every(itemId => typeof itemId === "string"),
+ "Every item in the set should be a string"
+ );
+ for (const item of CUSTOMIZABLE_ITEMS) {
+ Assert.equal(
+ SKIP_FOCUS_ITEM_IDS.has(item.id),
+ Boolean(item.skipFocus),
+ `Set's state should match the skipFocus value of ${item.skipFocus} for ${item.id}`
+ );
+ }
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js
new file mode 100644
index 0000000000..474e5483ce
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizableItemsDetails.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { default: CUSTOMIZABLE_ITEMS } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItemsDetails.mjs"
+);
+
+add_task(function test_format() {
+ for (const item of CUSTOMIZABLE_ITEMS) {
+ Assert.equal(typeof item, "object", "Customizable item is an object");
+ Assert.equal(typeof item.id, "string", `id "${item.id}" is a string`);
+ Assert.ok(!item.id.includes(","), `id "${item.id}" may not contain commas`);
+ Assert.greater(item.id.length, 0, `id "${item.id}" is not empty`);
+ Assert.equal(
+ typeof item.labelId,
+ "string",
+ `labelId is a string for ${item.id}`
+ );
+ Assert.greater(
+ item.labelId.length,
+ 0,
+ `labelId is not empty for ${item.id}`
+ );
+ Assert.ok(
+ !item.allowMultiple || item.allowMultiple === true,
+ `allowMultiple is falsy or boolean for ${item.id}`
+ );
+ Assert.ok(
+ item.spaces === undefined || Array.isArray(item.spaces),
+ `spaces is undefined or an array for ${item.id}`
+ );
+ if (item.spaces) {
+ for (const space of item.spaces) {
+ Assert.equal(
+ typeof space,
+ "string",
+ `space "${space}" expected to be string for ${item.id}`
+ );
+ Assert.greater(
+ space.length,
+ 0,
+ `space is not empty in ${item.id} spaces`
+ );
+ }
+ }
+ Assert.ok(
+ item.templateId === undefined || typeof item.templateId === "string",
+ `templateId must be undefined or a string for ${item.id}`
+ );
+ if (item.templateId !== undefined) {
+ Assert.greater(
+ item.templateId.length,
+ 0,
+ `templateId is not empty for ${item.id}`
+ );
+ Assert.ok(
+ item.requiredModules === undefined ||
+ Array.isArray(item.requiredModules),
+ `requiredModules is undefined or an array for ${item.id}`
+ );
+ if (item.requiredModules) {
+ for (const module of item.requiredModules) {
+ Assert.equal(
+ typeof module,
+ "string",
+ `module "${module}" expected to be string for ${item.id}`
+ );
+ Assert.greater(
+ module.length,
+ 0,
+ `module is not empty in ${item.id} requiredModules`
+ );
+ }
+ }
+ } else {
+ Assert.strictEqual(
+ item.requiredModules,
+ undefined,
+ `requiredModules must not be set because there is no template for item ${item.id}`
+ );
+ }
+ Assert.ok(
+ item.hasContextMenu === undefined ||
+ typeof item.hasContextMenu === "boolean",
+ `hasContextMenu must be undefined or a boolean for ${item.id}`
+ );
+ Assert.ok(
+ item.skipFocus === undefined || typeof item.skipFocus === "boolean",
+ `skipFocus must be undefined or a boolean for ${item.id}`
+ );
+ }
+});
+
+add_task(function test_idsUnique() {
+ const allIds = CUSTOMIZABLE_ITEMS.map(item => item.id);
+ const idCounts = allIds.reduce((counts, id) => {
+ counts[id] = counts[id] ? counts[id] + 1 : 1;
+ return counts;
+ }, {});
+ const duplicateIds = Object.keys(idCounts).filter(id => idCounts[id] > 1);
+ Assert.deepEqual(duplicateIds, [], "All IDs should only be used once");
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js b/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js
new file mode 100644
index 0000000000..048b5c5cde
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_customizationState.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+const { storeState, getState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+
+add_setup(function () {
+ // Ensure xulStore has a profile to refer to.
+ do_get_profile();
+});
+
+add_task(function test_getState_empty() {
+ const state = getState();
+ Assert.equal(typeof state, "object", "State should be an object");
+ Assert.deepEqual(state, {}, "Empty state should be an empty object");
+});
+
+add_task(async function test_storeState_observer() {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState({
+ mail: ["write-message", "spacer", "search-bar", "spacer"],
+ });
+ await stateChangeObserved;
+});
+
+add_task(function test_storeState_getState() {
+ const state = {
+ mail: ["write-message", "spacer", "search-bar", "spacer"],
+ calendar: [],
+ };
+ const previousState = getState();
+ Assert.notDeepEqual(
+ previousState,
+ state,
+ "Current state should be different from the state to write"
+ );
+ storeState(state);
+ const newState = getState();
+ Assert.deepEqual(
+ newState,
+ state,
+ "State loaded should matche the stored state"
+ );
+ Assert.notStrictEqual(
+ newState,
+ state,
+ "State loaded should not be the same object as what was saved"
+ );
+});
+
+registerCleanupFunction(() => {
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "state"
+ );
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js b/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js
new file mode 100644
index 0000000000..637d40e066
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/test_toolbarMigration.js
@@ -0,0 +1,431 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { migrateToolbarForSpace, clearXULToolbarState } =
+ ChromeUtils.importESModule("resource:///modules/ToolbarMigration.sys.mjs");
+const { getState, storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const MESSENGER_WINDOW = "chrome://messenger/content/messenger.xhtml";
+
+function setXULToolbarState(
+ currentSet = "",
+ defaultSet = "",
+ toolbarId = "mail-bar3"
+) {
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ toolbarId,
+ "currentset",
+ currentSet
+ );
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ toolbarId,
+ "defaultset",
+ defaultSet
+ );
+}
+
+add_setup(() => {
+ do_get_profile();
+ storeState({});
+});
+
+add_task(function test_migration_customized() {
+ setXULToolbarState(
+ "button-getmsg,button-newmsg,button-reply,spacer,qfb-show-filter-bar,button-file,folder-location-container,spring,gloda-search,button-appmenu"
+ );
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.mail,
+ [
+ "get-messages",
+ "write-message",
+ "reply",
+ "spacer",
+ "quick-filter-bar",
+ "move-to",
+ "folder-location",
+ "spacer",
+ "search-bar",
+ "spacer",
+ "add-ons-and-themes",
+ "delete",
+ ],
+ "Items were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_migration_defaults() {
+ setXULToolbarState();
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.ok(!newState.mail, "New default state was preserved");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_migration_empty() {
+ setXULToolbarState("__empty");
+ setXULToolbarState("__empty", "menubar-items,spring", "toolbar-menubar");
+ setXULToolbarState("__empty", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState.mail, [], "The toolbar contents were emptied");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_migration_noop() {
+ const state = { mail: ["spacer", "search-bar", "spacer"] };
+ storeState(state);
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState, state, "Customization state is not modified");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_calendar_migration() {
+ setXULToolbarState(
+ "calendar-synchronize-button,calendar-newevent-button,separator,calendar-edit-button,calendar-delete-button,spring,calendar-unifinder-button,calendar-appmenu-button",
+ "",
+ "calendar-toolbar2"
+ );
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("calendar");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.calendar,
+ [
+ "synchronize",
+ "new-event",
+ "edit-event",
+ "delete-event",
+ "spacer",
+ "unifinder",
+ "spacer",
+ "add-ons-and-themes",
+ ],
+ "Items were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_calendar_migration_defaults() {
+ setXULToolbarState("", "", "calendar-toolbar2");
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("calendar");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.calendar,
+ [
+ "synchronize",
+ "new-event",
+ "new-task",
+ "edit-event",
+ "delete-event",
+ "spacer",
+ ],
+ "Default states were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "calendar-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_tasks_migration() {
+ setXULToolbarState(
+ "task-synchronize-button,task-newtask-button,task-edit-button,task-delete-button,task-print-button,spring,task-appmenu-button",
+ "",
+ "task-toolbar2"
+ );
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("tasks");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.tasks,
+ [
+ "synchronize",
+ "new-task",
+ "edit-event",
+ "delete-event",
+ "print-event",
+ "spacer",
+ "add-ons-and-themes",
+ ],
+ "Items were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_tasks_migration_defaults() {
+ setXULToolbarState("", "", "task-toolbar2");
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("tasks");
+
+ const newState = getState();
+
+ Assert.deepEqual(
+ newState.tasks,
+ [
+ "synchronize",
+ "new-event",
+ "new-task",
+ "edit-event",
+ "delete-event",
+ "spacer",
+ ],
+ "Default states were combined and migrated"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "task-toolbar2",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+
+ storeState({});
+});
+
+add_task(function test_global_items_migration() {
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "",
+ "toolbar-menubar"
+ );
+ setXULToolbarState("button-delete", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("settings");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState.settings, [
+ "spacer",
+ "search-bar",
+ "spacer",
+ "add-ons-and-themes",
+ ]);
+
+ storeState({});
+});
+
+add_task(function test_global_items_migration_defaults() {
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+
+ migrateToolbarForSpace("settings");
+
+ const newState = getState();
+
+ Assert.deepEqual(newState.settings, ["spacer", "search-bar", "spacer"]);
+
+ storeState({});
+});
+
+add_task(function test_clear_xul_toolbar_state() {
+ setXULToolbarState(
+ "menubar-items,spring,button-addons",
+ "menubar-items,spring",
+ "toolbar-menubar"
+ );
+
+ clearXULToolbarState("toolbar-menubar");
+
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "toolbar-menubar",
+ "currentset"
+ ),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(
+ MESSENGER_WINDOW,
+ "toolbar-menubar",
+ "defaultset"
+ ),
+ "Old toolbar default state is cleared"
+ );
+});
+
+add_task(function test_migration_defaults_with_extension() {
+ setXULToolbarState(
+ AppConstants.platform == "macosx"
+ ? "button-getmsg,button-newmsg,button-tag,qfb-show-filter-bar,spring,gloda-search,extension1,extension2,button-appmenu"
+ : "button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring,gloda-search,extension1,extension2,button-appmenu"
+ );
+ setXULToolbarState("", "", "toolbar-menubar");
+ setXULToolbarState("", "", "tabbar-toolbar");
+ Services.xulStore.setValue(
+ MESSENGER_WINDOW,
+ "mail-bar3",
+ "extensionset",
+ "extension1,extension2"
+ );
+
+ migrateToolbarForSpace("mail");
+
+ const newState = getState();
+
+ Assert.ok(!newState.mail, "New default state was preserved");
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "currentset"),
+ "Old toolbar state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "defaultset"),
+ "Old toolbar default state is cleared"
+ );
+ Assert.ok(
+ !Services.xulStore.hasValue(MESSENGER_WINDOW, "mail-bar3", "extensionset"),
+ "Old toolbar extension state is cleared"
+ );
+
+ storeState({});
+});
diff --git a/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini b/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..ec9807b399
--- /dev/null
+++ b/comm/mail/components/unifiedtoolbar/test/unit/xpcshell.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+head =
+
+[test_buttonStyle.js]
+[test_customizableItems.js]
+[test_customizableItemsDetails.js]
+[test_customizationState.js]
+[test_toolbarMigration.js]